From 60c612af8f7b33155f3bd04481281feb0992c1c0 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 21:19:54 -0700 Subject: [PATCH 01/43] fix(codegen): coordinate union names across shared packages --- codegen/generator/example.go | 13 +- ...erate_http_union_shape_integration_test.go | 139 +++++++++++++ codegen/generator/service.go | 23 ++- .../service_union_package_scope_test.go | 194 ++++++++++++++++++ codegen/generator/transport.go | 13 +- codegen/scope.go | 20 +- codegen/scope_test.go | 177 ++++++++++++++++ codegen/service/service.go | 17 +- codegen/service/service_data.go | 161 +++++++++++---- .../service/service_data_union_order_test.go | 13 +- codegen/service/service_test.go | 53 +++++ codegen/service/testdata/service_dsls.go | 47 +++++ codegen/service/views.go | 3 +- codegen/union.go | 106 ++++++++++ http/codegen/service_data.go | 2 +- http/codegen/service_data_union_order_test.go | 76 ++++++- 16 files changed, 974 insertions(+), 83 deletions(-) create mode 100644 codegen/generator/generate_http_union_shape_integration_test.go create mode 100644 codegen/generator/service_union_package_scope_test.go create mode 100644 codegen/union.go diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 556911b6b1..97effe960c 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -5,7 +5,6 @@ import ( "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" @@ -15,14 +14,10 @@ import ( // example service, server, and client. func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression - } - - // Create service data - services := service.NewServicesData(r) + designRoots := serviceRoots(roots) + servicesByRoot := service.NewServicesDataForRoots(designRoots) + for _, r := range designRoots { + services := servicesByRoot[r] for _, s := range r.Services { service.SetUserTypeImports(genpkg, services.Get(s.Name)) } diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go new file mode 100644 index 0000000000..2deee2b310 --- /dev/null +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -0,0 +1,139 @@ +// This file verifies that HTTP generation keeps request- and response-shaped +// unions separate when their nested branch types have different Go names. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(cmd string) ([]Genfunc, error) { + return []Genfunc{Service, Transport}, nil + } + + dsl := func() { + d.API("test", func() {}) + + siteSet := d.Type("SiteSet", func() { + d.Attribute("site_ids", d.ArrayOf(d.String)) + d.Required("site_ids") + }) + allSites := d.Type("AllSites", func() { + d.Attribute("include_current", d.Boolean) + d.Required("include_current") + }) + setup := d.Type("Setup", func() { + d.OneOf("scope", func() { + d.Attribute("site_set", siteSet) + d.Attribute("all_sites", allSites) + }) + d.Required("scope") + }) + + d.Service("front", func() { + d.Method("configure", func() { + d.Payload(setup) + d.Result(setup) + d.HTTP(func() { + d.POST("/configure") + d.Response(200) + }) + }) + d.Method("reconfigure", func() { + d.Payload(setup) + d.Result(d.String) + d.HTTP(func() { + d.POST("/reconfigure") + d.Response(200) + }) + }) + }) + } + + _ = codegen.RunDSL(t, dsl) + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + if _, err := Generate(dir, "gen", false); err != nil { + t.Fatalf("Generate failed: %v", err) + } + assertGeneratedUnionDeclarations(t, genDir) + runGeneratedTests(t, genDir) +} + +// assertGeneratedUnionDeclarations proves the two request copies share one +// declaration while the differently shaped response receives another. +func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { + t.Helper() + path := filepath.Join(genDir, "http", "front", "server", "types.go") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read generated server types: %v", err) + } + code := string(content) + if strings.Count(code, "type Scope struct {") != 1 { + t.Fatalf("expected one request Scope declaration:\n%s", code) + } + if strings.Count(code, "type Scope2 struct {") != 1 { + t.Fatalf("expected one response Scope2 declaration:\n%s", code) + } + if strings.Contains(code, "type Scope3 struct {") { + t.Fatalf("copied request union produced a third declaration:\n%s", code) + } +} + +// writeGeneratedModule creates a temporary module that resolves this Goa +// checkout explicitly instead of downloading a released generator runtime. +func writeGeneratedModule(t *testing.T, dir, modulePath string) { + t.Helper() + goaRoot := moduleDirectory(t, "goa.design/goa/v3") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create generated module directory: %v", err) + } + module := "module " + modulePath + "\n\ngo 1.24\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { + t.Fatalf("write generated go.mod: %v", err) + } +} + +// moduleDirectory returns the checked-out directory for module from the outer +// test environment. +func moduleDirectory(t *testing.T, module string) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("resolve module %s: %v\n%s", module, err, output) + } + dir := strings.TrimSpace(string(output)) + if dir == "" { + t.Fatalf("resolve module %s: empty directory", module) + } + return dir +} + +// runGeneratedTests compiles every generated service and HTTP transport +// package in the isolated module. +func runGeneratedTests(t *testing.T, dir string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("compile generated packages: %v\n%s", err, output) + } +} diff --git a/codegen/generator/service.go b/codegen/generator/service.go index b4c3782b8e..1f9c93a79f 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -13,13 +13,10 @@ import ( func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { var files []*codegen.File var userTypePkgs = make(map[string][]string) - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue - } - // Create service data - services := service.NewServicesData(r) + designRoots := serviceRoots(roots) + servicesByRoot := service.NewServicesDataForRoots(designRoots) + for _, r := range designRoots { + services := servicesByRoot[r] for _, s := range r.Services { d := services.Get(s.Name) @@ -52,6 +49,18 @@ func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { return files, nil } +// serviceRoots returns every Goa design root that emits files into the same +// generated package tree. +func serviceRoots(roots []eval.Root) []*expr.RootExpr { + var designRoots []*expr.RootExpr + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + designRoots = append(designRoots, design) + } + } + return designRoots +} + func addServiceImports(files []*codegen.File, d *service.Data) { for _, f := range files { if len(f.SectionTemplates) == 0 { diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go new file mode 100644 index 0000000000..6ff24e43e6 --- /dev/null +++ b/codegen/generator/service_union_package_scope_test.go @@ -0,0 +1,194 @@ +// This file verifies that service generation allocates relocated union symbols +// once across every design root that contributes to a generated Go package. +package generator + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { + roots := []eval.Root{ + codegen.RunDSL(t, unusedRelocatedValueRoot()), + codegen.RunDSL(t, relocatedUnionRoot("ZExistingValue", "FirstService")), + codegen.RunDSL(t, relocatedUnionRoot("MExistingValue", "SecondService")), + codegen.RunDSL(t, relocatedUnionRoot("AAddedValue", "ThirdService")), + codegen.RunDSL(t, relocatedDifferentUnionRoot()), + codegen.RunDSL(t, relocatedTopLevelValueRoot()), + } + files, err := Service("goa.design/goa/example", roots) + require.NoError(t, err) + + var generated strings.Builder + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + code := generated.String() + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type Value2 struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value3 struct {"), code) + require.Contains(t, code, "type Value3 struct {\n\tText string", code) + require.Equal(t, + []string{"Value", "Value", "Value", "Value2"}, + []string{ + unionFieldType(code, "ZExistingValue"), + unionFieldType(code, "MExistingValue"), + unionFieldType(code, "AAddedValue"), + unionFieldType(code, "DifferentValue"), + }, + ) +} + +func TestServiceSelectiveRelocatedUnionOwnerCompiles(t *testing.T) { + root := codegen.RunDSL(t, selectiveRelocatedUnionRoot()) + services := servicecodegen.NewServicesData(root) + data := services.Get(root.Services[1].Name) + servicecodegen.SetUserTypeImports("generated.local/gen", data) + files := servicecodegen.Files( + "generated.local/gen", + root.Services[1], + services, + make(map[string][]string), + ) + addServiceImports(files, data) + dir := t.TempDir() + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + +// unusedRelocatedValueRoot declares a relocated type that no service reaches +// and does not force generation. It must not reserve a generated package name. +func unusedRelocatedValueRoot() func() { + return func() { + dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("unused", dsl.String) + }) + } +} + +// selectiveRelocatedUnionRoot declares the same relocated union from two +// services so rendering only the later service must still emit its definition. +func selectiveRelocatedUnionRoot() func() { + return func() { + first := relocatedValueType("FirstValue") + second := relocatedValueType("SecondValue") + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + } +} + +// relocatedValueType defines one force-generated owner of the shared Value +// union in the generated types package. +func relocatedValueType(name string) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) +} + +// relocatedTopLevelValueRoot declares an emitted top-level Value after the +// union definitions, so it receives the next available package-wide name. +func relocatedTopLevelValueRoot() func() { + return func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("text", dsl.String) + dsl.Required("text") + }) + dsl.Service("FifthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedDifferentUnionRoot defines a union with the same natural name but +// a different branch shape, so it must receive the next package-wide name. +func relocatedDifferentUnionRoot() func() { + return func() { + value := dsl.Type("DifferentValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Text", dsl.String) + }) + }) + dsl.Service("FourthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedUnionRoot defines one independently declared union with the same +// name and branch shape as the declarations in the other design roots. +func relocatedUnionRoot(typeName, serviceName string) func() { + return func() { + value := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// unionFieldType returns the generated union type referenced by owner. +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index f69af44d50..3393199cc6 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -4,7 +4,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" @@ -14,14 +13,10 @@ import ( // the transport code. func Transport(genpkg string, roots []eval.Root) ([]*codegen.File, error) { var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression - } - - // Create service data - services := service.NewServicesData(r) + designRoots := serviceRoots(roots) + servicesByRoot := service.NewServicesDataForRoots(designRoots) + for _, r := range designRoots { + services := servicesByRoot[r] for _, s := range r.Services { service.SetUserTypeImports(genpkg, services.Get(s.Name)) } diff --git a/codegen/scope.go b/codegen/scope.go index 1b23d1b02c..225bbec573 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -42,11 +42,12 @@ func NewNameScope() *NameScope { // appending suffix and - if still not unique - a counter value. It returns // the same value when called multiple times for a key returning the same hash. func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) string { - if n, ok := s.names[key.Hash()]; ok { + hash := scopedTypeHash(key) + if n, ok := s.names[hash]; ok { return n } name = s.Unique(name, suffix...) - s.names[key.Hash()] = name + s.names[hash] = name return name } @@ -293,10 +294,8 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { if pkg == "" { return s.HashedUnique(actual, base, "") } - if UserTypeLocation(actual) == nil { - if n, ok := s.names[actual.Hash()]; ok { - return pkg + "." + n - } + if n, ok := s.names[scopedTypeHash(actual)]; ok { + return pkg + "." + n } return pkg + "." + base case expr.CompositeExpr: @@ -306,6 +305,15 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { } } +// scopedTypeHash returns the emitted-definition identity for unions and the +// existing type hash for every other scoped declaration. +func scopedTypeHash(key Hasher) string { + if union, ok := key.(*expr.Union); ok { + return UnionTypeHash(union) + } + return key.Hash() +} + // pkgWithDefault returns the package defining the given type. If the types is a // user type with "struct:pkg:path" metadata then it returns the corresponding // value, otherwise it returns pkg. diff --git a/codegen/scope_test.go b/codegen/scope_test.go index d40f7436a1..393d5ad65c 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -68,6 +68,183 @@ func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { } } +func TestNameScope_GoFullTypeName_ReusesStructuralUnionNameWhenQualified(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value"} + second := &expr.Union{TypeName: "Value"} + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "Value"; got != want { + t.Errorf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.Value"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + +func TestNameScope_GoTypeNameDistinguishesUnionWireKeys(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"} + second := &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchPackages(t *testing.T) { + branch := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: "Entry", + UID: path, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + } + } + first := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/first")}}, + }, + } + second := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/second")}}, + }, + } + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchOrder(t *testing.T) { + branch := func(name string) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + first := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("left"), branch("right")}} + second := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("right"), branch("left")}} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesInlineObjectFieldOrder(t *testing.T) { + object := func(names ...string) *expr.Object { + fields := make(expr.Object, len(names)) + for i, name := range names { + fields[i] = &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + return &fields + } + union := func(fields *expr.Object) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "object", Attribute: &expr.AttributeExpr{Type: fields}}, + }, + } + } + first := union(object("left", "right")) + second := union(object("right", "left")) + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesGoifiedBranchTypeCollisions(t *testing.T) { + branch := func(name, id string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + UID: id, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + } + union := func(user expr.UserType) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: user}}, + }, + } + } + first := union(branch("foo-bar", "first")) + second := union(branch("foo_bar", "second")) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestUnionTypeHashIgnoresNonEmittedPointerSharing(t *testing.T) { + object := func() *expr.Object { + fields := expr.Object{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + return &fields + } + innerUnion := func() *expr.Union { + return &expr.Union{ + TypeName: "Inner", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + } + outerUnion := func(left, right expr.DataType) *expr.Union { + return &expr.Union{ + TypeName: "Outer", + Values: []*expr.NamedAttributeExpr{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: left}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: right}}, + }, + } + } + + t.Run("inline object", func(t *testing.T) { + shared := object() + assert.Equal(t, UnionTypeHash(outerUnion(shared, shared)), UnionTypeHash(outerUnion(object(), object()))) + }) + t.Run("nested union", func(t *testing.T) { + shared := innerUnion() + assert.Equal(t, UnionTypeHash(outerUnion(shared, shared)), UnionTypeHash(outerUnion(innerUnion(), innerUnion()))) + }) +} + +func TestNameScope_GoFullTypeName_UsesScopedRelocatedUserTypeNameWhenQualified(t *testing.T) { + scope := NewNameScope() + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo-bar", + UID: "first", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo_bar", + UID: "second", + } + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "FooBar2"; got != want { + t.Fatalf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.FooBar2"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + func TestNameScope_PeekUnique_MatchesUniqueWithoutMutation(t *testing.T) { seed := func(scope *NameScope) { scope.Unique("a") diff --git a/codegen/service/service.go b/codegen/service/service.go index ba6ed8928c..5dd464077f 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -233,11 +233,13 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use ts := typesByPath[p] sort.Strings(ts) for _, name := range ts { - if strings.HasPrefix(name, "~union:") { - hasUnion = true + registry := p + isUnion := strings.HasPrefix(name, "~union:") + if isUnion { + registry = unionRegistryKey(p) } hasName := false - for _, n := range userTypePkgs[p] { + for _, n := range userTypePkgs[registry] { if hasName = n == name; hasName { break } @@ -245,8 +247,9 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use if hasName { continue } - userTypePkgs[p] = append(userTypePkgs[p], name) + userTypePkgs[registry] = append(userTypePkgs[registry], name) secs = append(secs, typeDefSections[p][name]) + hasUnion = hasUnion || isUnion } if len(secs) == 0 { continue @@ -271,6 +274,12 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use return files } +// unionRegistryKey returns the render-lifetime registry key shared by every +// relocated type file in the same generated Go package. +func unionRegistryKey(path string) string { + return "\x00union-package:" + filepath.Dir(path) +} + // dedupeByResult returns a slice of methods where only a single representative // per unique ResultRef is kept (first occurrence wins). Methods without a // ResultRef are ignored. diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index dc18b3eea9..5194bac77f 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -35,6 +35,14 @@ type ( ServicesData struct { Root *expr.RootExpr Services map[string]*Data + + packageScopes *packageScopes + } + + // packageScopes owns generated identifiers for every relocated Go package + // across a complete set of design roots. + packageScopes struct { + scopes map[string]*codegen.NameScope } // Data contains the data used to render the code related to a single @@ -637,14 +645,51 @@ type ( // Validate is the validation code. Validate string } + + // serviceNameScopes keeps service-local identifiers isolated while sharing + // the identifier namespace of every relocated Go package across services. + serviceNameScopes struct { + local *codegen.NameScope + packages *packageScopes + } + + // unionCompanionKey identifies a generated union companion, such as its kind + // type, by the emitted definition of its owning union and its role. + unionCompanionKey struct { + union *expr.Union + role string + } ) -// NewServicesData creates a new ServicesData instance for the given root. +// NewServicesData creates and analyzes service data for one design root. func NewServicesData(root *expr.RootExpr) *ServicesData { - return &ServicesData{ - Services: make(map[string]*Data), - Root: root, + return NewServicesDataForRoots([]*expr.RootExpr{root})[root] +} + +// NewServicesDataForRoots creates service data for the complete ordered roots +// set. All services are analyzed once in root and service declaration order so +// relocated types share one package namespace and only emitted declarations +// reserve names. +func NewServicesDataForRoots(roots []*expr.RootExpr) map[*expr.RootExpr]*ServicesData { + packageScopes := &packageScopes{scopes: make(map[string]*codegen.NameScope)} + servicesByRoot := make(map[*expr.RootExpr]*ServicesData, len(roots)) + for _, root := range roots { + if _, ok := servicesByRoot[root]; ok { + panic("duplicate root in complete service generation root set") + } + servicesByRoot[root] = &ServicesData{ + Services: make(map[string]*Data), + Root: root, + packageScopes: packageScopes, + } + } + for _, root := range roots { + services := servicesByRoot[root] + for _, service := range root.Services { + services.Get(service.Name) + } } + return servicesByRoot } // Get retrieves the data for the service with the given name computing it if @@ -749,6 +794,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { viewedRTs []*ViewedResultTypeData ) scope := codegen.NewNameScope() + scopes := &serviceNameScopes{local: scope, packages: d.packageScopes} scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket viewScope := codegen.NewNameScope() @@ -761,7 +807,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { // A function to collect user types from an error expression recordError := func(er *expr.ErrorExpr) { - errTypes = append(errTypes, collectTypes(er.AttributeExpr, scope, seen, nil)...) + errTypes = append(errTypes, collectTypes(er.AttributeExpr, scopes, seen, nil)...) if er.Type == expr.ErrorResult { if _, ok := seenErrors[er.Name]; ok { return @@ -784,7 +830,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { loc = codegen.UserTypeLocation(ut) att = ut.Attribute() } - types = append(types, collectTypes(att, scope, seen, loc)...) + types = append(types, collectTypes(att, scopes, seen, loc)...) } for _, m := range service.Methods { // collect inner user types @@ -844,12 +890,12 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { if len(svcs) > 0 { // Force generate type only in the specified services if slices.Contains(svcs, service.Name) { - types = append(types, collectTypes(att, scope, seen, nil)...) + types = append(types, collectTypes(att, scopes, seen, nil)...) } continue } // Force generate type in all the services - types = append(types, collectTypes(att, scope, seen, nil)...) + types = append(types, collectTypes(att, scopes, seen, nil)...) } var ( @@ -858,7 +904,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { ) methods = make([]*MethodData, len(service.Methods)) for i, e := range service.Methods { - m := d.buildMethodData(e, scope) + m := d.buildMethodData(e, scopes) methods[i] = m for _, s := range m.Schemes { schemes = schemes.Append(s) @@ -906,7 +952,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { unionByPackage := make(map[string]*UnionTypeData) seen = make(map[string]struct{}) collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) { - collectUnionTypes(att, scope, loc, unionByPackage, seen, false) + collectUnionTypes(att, scopes, loc, unionByPackage, seen, false) } for _, t := range types { collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) @@ -1025,12 +1071,12 @@ func projectedTypeContext(pkg string, ptr bool, scope *codegen.NameScope) *codeg // collectTypes recurses through the attribute to gather all user types and // records them in userTypes. -func collectTypes(at *expr.AttributeExpr, scope *codegen.NameScope, seen map[string]struct{}, loc *codegen.Location) (data []*UserTypeData) { +func collectTypes(at *expr.AttributeExpr, scopes *serviceNameScopes, seen map[string]struct{}, loc *codegen.Location) (data []*UserTypeData) { if at == nil || at.Type == expr.Empty { return data } collect := func(at *expr.AttributeExpr, loc *codegen.Location) []*UserTypeData { - return collectTypes(at, scope, seen, loc) + return collectTypes(at, scopes, seen, loc) } switch dt := at.Type.(type) { case expr.UserType: @@ -1041,12 +1087,21 @@ func collectTypes(at *expr.AttributeExpr, scope *codegen.NameScope, seen map[str if typeLoc == nil { typeLoc = loc } + typeScope := scopes.forLocation(typeLoc) + if typeScope != scopes.local { + // Preserve the service-local reservations used by method, endpoint, + // and helper naming. Relocated declarations additionally use their + // owning package scope so cross-service files agree on type names. + scopes.local.GoTypeName(at) + scopes.local.GoTypeDef(dt.Attribute(), false, true) + scopes.local.GoTypeRef(at) + } data = append(data, &UserTypeData{ Name: dt.Name(), - VarName: scope.GoTypeName(at), + VarName: typeScope.GoTypeName(at), Description: dt.Attribute().Description, - Def: scope.GoTypeDef(dt.Attribute(), false, true), - Ref: scope.GoTypeRef(at), + Def: typeScope.GoTypeDef(dt.Attribute(), false, true), + Ref: typeScope.GoTypeRef(at), Loc: typeLoc, Type: dt, }) @@ -1070,13 +1125,13 @@ func collectTypes(at *expr.AttributeExpr, scope *codegen.NameScope, seen map[str } // collectUnionTypes traverses the attribute to gather all union sum-type -// definitions referenced by the service. It records each union by its hash and +// definitions referenced by the service. It records each emitted definition by // generated package so Extend can copy one union into multiple packages while -// duplicate uses within one package still share a definition. When view is true -// the provided location is used for all nested user types so that unions are +// duplicate uses within one package share a definition. When view is true the +// provided location is used for all nested user types so that unions are // generated in the views package and refer to view-local types (preventing // import cycles). -func collectUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}, view bool) { +func collectUnionTypes(att *expr.AttributeExpr, scopes *serviceNameScopes, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}, view bool) { if att == nil || att.Type == expr.Empty { return } @@ -1090,27 +1145,51 @@ func collectUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, loc *c if !view { typeLoc = codegen.UserTypeLocation(dt) } - collectUnionTypes(dt.Attribute(), scope, typeLoc, unions, seen, view) + collectUnionTypes(dt.Attribute(), scopes, typeLoc, unions, seen, view) case *expr.Object: for _, nat := range sortedNamedAttributes(*dt) { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) + collectUnionTypes(nat.Attribute, scopes, loc, unions, seen, view) } case *expr.Array: - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) + collectUnionTypes(dt.ElemType, scopes, loc, unions, seen, view) case *expr.Map: - collectUnionTypes(dt.KeyType, scope, loc, unions, seen, view) - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) + collectUnionTypes(dt.KeyType, scopes, loc, unions, seen, view) + collectUnionTypes(dt.ElemType, scopes, loc, unions, seen, view) case *expr.Union: - key := dt.Hash() + "\x00" + unionPackageKey(loc, view) + key := codegen.UnionTypeHash(dt) + "\x00" + unionPackageKey(loc, view) if _, ok := unions[key]; !ok { - unions[key] = buildUnionTypeData(dt, scope, loc, view) + unionScope := scopes.local + if !view { + unionScope = scopes.forLocation(loc) + } + unions[key] = buildUnionTypeData(dt, unionScope, loc, view) } for _, nat := range dt.Values { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) + collectUnionTypes(nat.Attribute, scopes, loc, unions, seen, view) } } } +// forLocation returns the identifier scope for the package that owns loc. +// Relocated types from different services share a scope because their files +// compile together; a nil location belongs to the current service package. +func (s *serviceNameScopes) forLocation(loc *codegen.Location) *codegen.NameScope { + if loc == nil || loc.RelImportPath == "" { + return s.local + } + return s.packages.scope(loc.RelImportPath) +} + +// scope returns the identifier scope for path, creating it on first use. +func (s *packageScopes) scope(path string) *codegen.NameScope { + if scope, ok := s.scopes[path]; ok { + return scope + } + scope := codegen.NewNameScope() + s.scopes[path] = scope + return scope +} + // unionPackageKey identifies the generated package that owns a union. A nil // location is the current service package; viewed unions share the views // package regardless of locations inherited from service types. @@ -1132,7 +1211,7 @@ func unionPackageKey(loc *codegen.Location, view bool) string { func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Location, view bool) *UnionTypeData { att := &expr.AttributeExpr{Type: u} name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") + kindName := scope.HashedUnique(&unionCompanionKey{union: u, role: "kind"}, name+"Kind") var unionPkg string if !view { unionPkg = loc.PackageName() @@ -1177,6 +1256,11 @@ func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Lo } } +// Hash returns the structural identity of one generated union companion. +func (k *unionCompanionKey) Hash() string { + return codegen.UnionTypeHash(k.union) + "\x00" + k.role +} + // sortedNamedAttributes returns object fields sorted by attribute name. // Union naming uses NameScope uniqueness, so callers that discover unions while // traversing objects must use a deterministic field order to avoid oscillating @@ -1230,7 +1314,7 @@ func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInit // buildMethodData creates the data needed to render the given endpoint. It // records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope) *MethodData { +func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameScopes) *MethodData { var ( vname string desc string @@ -1252,18 +1336,20 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc reqs = make(RequirementsData, 0, len(m.Requirements)) schemes SchemesData ) + scope := scopes.local vname = scope.Unique(codegen.Goify(m.Name, true), "Endpoint") desc = m.Description if desc == "" { desc = codegen.Goify(m.Name, true) + " implements " + m.Name + "." } if m.Payload.Type != expr.Empty { - payloadName = scope.GoTypeName(m.Payload) + payloadLoc = codegen.UserTypeLocation(m.Payload.Type) + payloadScope := scopes.forLocation(payloadLoc) + payloadName = payloadScope.GoTypeName(m.Payload) if dt, ok := m.Payload.Type.(expr.UserType); ok { - payloadDef = scope.GoTypeDef(dt.Attribute(), false, true) - payloadLoc = codegen.UserTypeLocation(dt) + payloadDef = payloadScope.GoTypeDef(dt.Attribute(), false, true) } - payloadRef = scope.GoFullTypeRef(m.Payload, payloadLoc.PackageName()) + payloadRef = payloadScope.GoFullTypeRef(m.Payload, payloadLoc.PackageName()) payloadDesc = m.Payload.Description if payloadDesc == "" { payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", @@ -1272,12 +1358,13 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc payloadEx = m.Payload.Example(d.Root.API.ExampleGenerator) } if m.Result.Type != expr.Empty { - rname = scope.GoTypeName(m.Result) + resultLoc = codegen.UserTypeLocation(m.Result.Type) + resultScope := scopes.forLocation(resultLoc) + rname = resultScope.GoTypeName(m.Result) if dt, ok := m.Result.Type.(expr.UserType); ok { - resultDef = scope.GoTypeDef(dt.Attribute(), false, true) - resultLoc = codegen.UserTypeLocation(dt) + resultDef = resultScope.GoTypeDef(dt.Attribute(), false, true) } - resultRef = scope.GoFullTypeRef(m.Result, resultLoc.PackageName()) + resultRef = resultScope.GoFullTypeRef(m.Result, resultLoc.PackageName()) resultDesc = m.Result.Description if resultDesc == "" { resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index a673bce1c6..5b94d90d5e 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -64,9 +64,15 @@ func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location) map[string]string { scope := codegen.NewNameScope() + scopes := &serviceNameScopes{ + local: scope, + packages: &packageScopes{ + scopes: make(map[string]*codegen.NameScope), + }, + } seen := make(map[string]struct{}) unionByHash := make(map[string]*UnionTypeData) - collectUnionTypes(att, scope, loc, unionByHash, seen, false) + collectUnionTypes(att, scopes, loc, unionByHash, seen, false) names := make(map[string]string, len(unionByHash)) for hash, data := range unionByHash { @@ -85,8 +91,5 @@ func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { }, } } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return &expr.Union{TypeName: typeName, Values: values} } diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 2e0b23a17e..6ab281fca8 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -12,6 +12,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" ) func TestService(t *testing.T) { @@ -177,6 +178,58 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { require.Contains(t, code, "\"encoding/json\"", "expected encoding/json import in generated file:\n%s", code) } +func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + render := func(servicesToRender []*expr.ServiceExpr) string { + services := NewServicesData(root) + userTypePkgs := make(map[string][]string) + var generated strings.Builder + for _, service := range servicesToRender { + files := Files("goa.design/goa/example", service, services, userTypePkgs) + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + } + return generated.String() + } + + code := render(root.Services) + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) + firstUsesValue := unionFieldType(code, "FirstValue") + secondUsesValue := unionFieldType(code, "SecondValue") + thirdUsesValue := unionFieldType(code, "ThirdValue") + require.Equal(t, []string{"Value", "Value", "Value"}, []string{firstUsesValue, secondUsesValue, thirdUsesValue}) + + reversed := render([]*expr.ServiceExpr{root.Services[2], root.Services[1], root.Services[0]}) + require.Equal(t, firstUsesValue, unionFieldType(reversed, "FirstValue"), reversed) + require.Equal(t, secondUsesValue, unionFieldType(reversed, "SecondValue"), reversed) + require.Equal(t, thirdUsesValue, unionFieldType(reversed, "ThirdValue"), reversed) + + selective := render([]*expr.ServiceExpr{root.Services[1]}) + require.Equal(t, 1, strings.Count(selective, "type Value struct {"), selective) + require.Equal(t, "Value", unionFieldType(selective, "SecondValue"), selective) +} + +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} + func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionJSONFieldDSL) services := NewServicesData(root) diff --git a/codegen/service/testdata/service_dsls.go b/codegen/service/testdata/service_dsls.go index cd0bc6ff91..fba5189de0 100644 --- a/codegen/service/testdata/service_dsls.go +++ b/codegen/service/testdata/service_dsls.go @@ -159,6 +159,53 @@ var PkgPathUnionDSL = func() { }) } +// PkgPathUnionNameScopeDSL exercises services that independently declare the +// same structural union in relocated files that compile in one Go package. +var PkgPathUnionNameScopeDSL = func() { + var FirstValue = Type("FirstValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var SecondValue = Type("SecondValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var ThirdValue = Type("ThirdValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + Service("FirstValueService", func() { + Method("Read", func() { + Payload(FirstValue) + }) + }) + Service("SecondValueService", func() { + Method("Read", func() { + Payload(SecondValue) + }) + }) + Service("ThirdValueService", func() { + Method("Read", func() { + Payload(ThirdValue) + }) + }) +} + // PkgPathUnionJSONFieldDSL tests OneOf branches declared with JSONField in a // struct:pkg:path type. var PkgPathUnionJSONFieldDSL = func() { diff --git a/codegen/service/views.go b/codegen/service/views.go index 3c074edbb6..0c14f18fbc 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -31,8 +31,9 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod unionByHash := make(map[string]*UnionTypeData) seenUnions := make(map[string]struct{}) viewLoc := &codegen.Location{RelImportPath: "views"} + viewScopes := &serviceNameScopes{local: svc.ViewScope} for _, t := range svc.projectedTypes { - collectUnionTypes(&expr.AttributeExpr{Type: t.Type}, svc.ViewScope, viewLoc, unionByHash, seenUnions, true) + collectUnionTypes(&expr.AttributeExpr{Type: t.Type}, viewScopes, viewLoc, unionByHash, seenUnions, true) } unions := make([]*UnionTypeData, 0, len(unionByHash)) for _, u := range unionByHash { diff --git a/codegen/union.go b/codegen/union.go new file mode 100644 index 0000000000..7418f229ba --- /dev/null +++ b/codegen/union.go @@ -0,0 +1,106 @@ +// This file defines the emitted Go and JSON identity used to name and emit Goa +// unions consistently. It is separate from expression-type compatibility. +package codegen + +import ( + "strconv" + "strings" + + "goa.design/goa/v3/expr" +) + +// UnionTypeHash returns a stable identity for the Go and JSON definition +// generated for u. Unlike expr.Union.Hash, which describes design-type +// compatibility, UnionTypeHash includes the effective JSON envelope keys and +// details that change generated Go branch types, such as package locations, +// field type metadata, and nilability. +func UnionTypeHash(u *expr.Union) string { + var key strings.Builder + writeUnionTypeHash(&key, u, make(map[*expr.Object]int), make(map[*expr.Union]int)) + return key.String() +} + +// writeUnionTypeHash appends one union definition using length-prefixed values +// so different inputs cannot produce an ambiguous concatenation. +func writeUnionTypeHash(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int) { + if index, ok := unions[union]; ok { + writeUnionHashPart(key, "union-ref") + writeUnionHashPart(key, strconv.Itoa(index)) + return + } + unions[union] = len(unions) + defer delete(unions, union) + writeUnionHashPart(key, "union") + writeUnionHashPart(key, union.TypeName) + writeUnionHashPart(key, union.GetTypeKey()) + writeUnionHashPart(key, union.GetValueKey()) + for _, value := range union.Values { + writeUnionHashPart(key, value.Name) + writeUnionAttributeHash(key, value.Attribute, objects, unions) + } +} + +// writeUnionAttributeHash appends the generated Go identity of an attribute. +func writeUnionAttributeHash(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int) { + writeUnionHashPart(key, strconv.FormatBool(IsNilable(att.Type))) + if metaType, ok := att.Meta["struct:field:type"]; ok { + writeUnionHashPart(key, "meta-type") + for _, value := range metaType { + writeUnionHashPart(key, value) + } + } + switch actual := att.Type.(type) { + case expr.Primitive: + writeUnionHashPart(key, "primitive") + writeUnionHashPart(key, GoNativeTypeName(actual)) + case expr.UserType: + writeUnionHashPart(key, "user") + writeUnionHashPart(key, Goify(actual.Name(), true)) + writeUnionHashPart(key, actual.Hash()) + if loc := UserTypeLocation(actual); loc != nil { + writeUnionHashPart(key, loc.RelImportPath) + } else { + writeUnionHashPart(key, "") + } + case *expr.Array: + writeUnionHashPart(key, "array") + writeUnionAttributeHash(key, actual.ElemType, objects, unions) + case *expr.Map: + writeUnionHashPart(key, "map") + writeUnionAttributeHash(key, actual.KeyType, objects, unions) + writeUnionAttributeHash(key, actual.ElemType, objects, unions) + case *expr.Object: + writeUnionObjectHash(key, att, actual, objects, unions) + case *expr.Union: + writeUnionTypeHash(key, actual, objects, unions) + case expr.CompositeExpr: + writeUnionAttributeHash(key, actual.Attribute(), objects, unions) + default: + panic("unknown union branch data type") + } +} + +// writeUnionObjectHash appends the inline Go struct emitted for an object. +func writeUnionObjectHash(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int) { + if index, ok := objects[object]; ok { + writeUnionHashPart(key, "object-ref") + writeUnionHashPart(key, strconv.Itoa(index)) + return + } + objects[object] = len(objects) + defer delete(objects, object) + writeUnionHashPart(key, "object") + for _, field := range *object { + writeUnionHashPart(key, GoifyAtt(field.Attribute, field.Name, true)) + writeUnionHashPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) + writeUnionHashPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, false))) + writeUnionAttributeHash(key, field.Attribute, objects, unions) + } +} + +// writeUnionHashPart appends one unambiguous string component to key. +func writeUnionHashPart(key *strings.Builder, value string) { + key.WriteString(strconv.Itoa(len(value))) + key.WriteByte(':') + key.WriteString(value) +} diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 32d034980a..174a588134 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -2718,7 +2718,7 @@ func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, un collectHTTPUnionTypes(dt.KeyType, scope, unions, seen) collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) case *expr.Union: - hash := dt.Hash() + hash := codegen.UnionTypeHash(dt) if _, ok := unions[hash]; !ok { unions[hash] = buildHTTPUnionTypeData(dt, scope) } diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index b4fadfcc48..2bc0d50405 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -7,6 +7,7 @@ import ( cg "goa.design/goa/v3/codegen" svc "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -60,6 +61,76 @@ func TestCollectHTTPUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { require.Equal(t, forwardNames, reverseNames) } +func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testing.T) { + first := makeUnionForOrderTest("Value", "bool", "number") + second := makeUnionForOrderTest("Value", "bool", "number") + bodies := &expr.AttributeExpr{ + Type: &expr.Object{ + { + Name: "first", + Attribute: &expr.AttributeExpr{Type: first}, + }, + { + Name: "second", + Attribute: &expr.AttributeExpr{Type: second}, + }, + }, + } + + scope := cg.NewNameScope() + unions := make(map[string]*svc.UnionTypeData) + collectHTTPUnionTypes(bodies, scope, unions, make(map[string]struct{})) + + emitted := make([]string, 0, len(unions)) + for _, union := range unions { + emitted = append(emitted, union.Name) + } + references := []string{ + scope.GoTypeName(&expr.AttributeExpr{Type: first}), + scope.GoTypeName(&expr.AttributeExpr{Type: second}), + } + require.Equal(t, []string{"Value"}, emitted) + require.Equal(t, []string{"Value", "Value"}, references) +} + +func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("values", func() { + dsl.Method("first", func() { + dsl.Payload(func() { + dsl.OneOf("Value", sameShapedValueUnionDSL) + }) + dsl.HTTP(func() { + dsl.POST("/first") + }) + }) + dsl.Method("second", func() { + dsl.Payload(func() { + dsl.OneOf("Value", sameShapedValueUnionDSL) + }) + dsl.HTTP(func() { + dsl.POST("/second") + }) + }) + }) + }) + + data := CreateHTTPServices(root).Get("values") + require.NotNil(t, data) + emitted := make([]string, len(data.UnionTypes)) + for i, union := range data.UnionTypes { + emitted[i] = union.Name + } + require.Equal(t, []string{"Value"}, emitted) + require.Contains(t, data.Endpoint("first").Payload.Request.ServerBody.Def, "Value *Value ") + require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value ") +} + +func sameShapedValueUnionDSL() { + dsl.Attribute("bool", dsl.Boolean) + dsl.Attribute("number", dsl.Float64) +} + func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { scope := cg.NewNameScope() seen := make(map[string]struct{}) @@ -83,8 +154,5 @@ func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { }, } } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return &expr.Union{TypeName: typeName, Values: values} } From 21d0e99da5ae6b20d7c8abc44391583a4e3715dd Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 21:52:21 -0700 Subject: [PATCH 02/43] docs(codegen): define generated package ownership --- AGENTS.md | 15 + codegen/ARCHITECTURE.md | 118 ++++++ .../2026-08-20-generated-package-ownership.md | 358 ++++++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 codegen/ARCHITECTURE.md create mode 100644 docs/superpowers/plans/2026-08-20-generated-package-ownership.md diff --git a/AGENTS.md b/AGENTS.md index 9d7e4efd67..2ec70bcc1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,21 @@ No commented-out code—delete dead code. - Let Goa decide pointer/value semantics. Do not force `pointer=true` except in transport validation. - **Keep helper visibility minimal**: If logic is shared only inside one codegen area, keep it package-private or move it under an `internal` package. Do not export helpers from a parent package just to share them across sibling generators. - **Avoid pass-through wrappers**: When two helper functions differ only by forwarding arguments or hard-coding `nil`, collapse them into a single implementation instead of adding an extra layer. +- **Generated packages own names**: When declarations from multiple services + compile into one Go package, that package owns their `NameScope`, canonical + declaration records, and emission. Definitions and HTTP/gRPC/JSON-RPC + references must consume the same package-owned record; independently primed + service scopes are invalid. +- **Keep identity typed and explicit**: Do not encode declaration kind, package, + scope, or lifetime in decorated names or synthetic string map keys. Do not + change an expression's `Hash` semantics to satisfy code generation; pass an + explicit code-generation identity at the naming site. +- **Trace the complete lifecycle**: Before changing relocated types, union + naming, generation roots, plugins, or file merging, follow the declaration + from the one evaluated design root through service analysis, package + ownership, service emission, HTTP and gRPC references, post-generation + plugins, and final path merging. A service-only rendering test is not enough. + See [`codegen/ARCHITECTURE.md`](codegen/ARCHITECTURE.md). ### Documentation diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md new file mode 100644 index 0000000000..53549ae4b4 --- /dev/null +++ b/codegen/ARCHITECTURE.md @@ -0,0 +1,118 @@ +# Code Generation Architecture + +This document defines how Goa turns one evaluated design into generated files. +It focuses on ownership rules that are easy to violate when a declaration is +written outside its service package. + +## Generation lifecycle + +The `goa` command compiles and runs a temporary generator for one design. The +evaluation package registers exactly one core `*expr.RootExpr`; its evaluation +name is `design`, and duplicate evaluation names are rejected. A generation run +then follows this order: + +1. Evaluate and validate the design. +2. Let preparation plugins amend the evaluated expression roots. +3. Normalize the roots. +4. Analyze the design into service and transport rendering data. +5. Let the core service, HTTP, gRPC, JSON-RPC, and OpenAPI generators return + files. +6. Let post-generation plugins return additional files. +7. Merge contributions with the same output path and render the files. + +The core service generator therefore reasons about one real design root. A +temporary root created by a plugin is a separate analysis unless the plugin is +explicitly given the active generation context. + +## Ownership + +| Concern | Owner | Consumers | +| --- | --- | --- | +| Design identity and structural equality | `expr` | validation and code generation | +| Go identifiers in a generated package | the generated-package record for its output path | service and transport rendering | +| Relocated user-type and union declarations | the same generated-package record | file rendering | +| HTTP, gRPC, and JSON-RPC wire types | each transport generator | transport templates | +| Output-path merging | the generator | all file-producing plugins | + +`expr.Union.Hash()` describes expression identity. It must not change merely +because generated Go source needs a different notion of equality. Code +generation uses a separate typed union identity containing every property that +changes the emitted union declaration, including discriminator keys, branch +order, branch type shape, and relocated branch packages. + +## Generated packages + +The service analysis owns a catalog keyed by the actual generated import path. +Each catalog entry represents one Go package and owns: + +- one `codegen.NameScope` for every package-level identifier; +- the relocated user types rendered into that package; +- the structurally distinct unions rendered into that package; and +- the final names of each union type, discriminator type, constants, and + constructors. + +Registering a declaration returns its canonical record. Code that declares the +type and code that refers to it must consume that record or the package-aware +attribute scope backed by it. A transport must never recreate a union name from +a service-local `NameScope`. + +For example, suppose two services place types in `gen/types`. Both contain a +nested union whose natural Go name is `Value`, but the unions have different +branches. The package catalog may assign `Value` and `Value2`. The user-type +definitions, service methods, HTTP transforms, and gRPC transforms must all read +those exact assignments from the `gen/types` catalog. + +Relocated declared user types keep the Go form of their declared name. If two +declared names in one output package become the same Go identifier, such as +`foo-bar` and `foo_bar` both becoming `FooBar`, generation rejects the design +before rendering. Silently assigning `FooBar2` would make a public declaration +depend on unrelated traversal order. + +Relocated user types are emitted in their metadata-selected files. Relocated +unions are emitted once in `unions.go` in the owning package, independent of +which service first referred to them. + +## Attribute naming during transforms + +`codegen.AttributeContext` asks an `Attributor` for names and references. A +service-local context uses the service package scope. A context that transforms +a relocated type uses a package-aware attributor: + +- a declared user type selects the package named by its `struct:pkg:path`; +- a nested union selects the package of the enclosing generated declaration; +- a local type selects the service package scope. + +This is the only supported route for resolving generated service types inside +HTTP, gRPC, JSON-RPC, conversion, and validation helpers. Transport-specific +scopes still own transport-only wire declarations. + +## Plugin and file assembly contracts + +A plugin that can emit declarations into a package already used by core +generation must receive and use the active generated-package catalog. A plugin +that analyzes a temporary root independently must emit to packages isolated +from the core root. Independent analyses may not coordinate through package +names, process-global maps, decorated strings, or render-order assumptions. + +`codegen.SectionTemplate.Name` labels a template for diagnostics. It is not a +declaration identity. When multiple generators contribute to one output file, +the file owner must supply explicit declaration identity or combine the +sections before returning the file. Output merging must not discard sections +merely because their diagnostic labels match. + +## Review gate + +Before changing type naming, relocated declarations, generation roots, plugin +files, or file merging, trace one declaration through all of these stages: + +1. the single evaluated root; +2. service analysis; +3. the owning generated-package record; +4. service declaration rendering; +5. HTTP and gRPC references; +6. post-generation plugin contributions; and +7. final files after output-path merging. + +A service-only render test is insufficient. The regression must compile a real +generated module with both HTTP and gRPC enabled whenever those transports can +refer to the declaration. diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md new file mode 100644 index 0000000000..ae33acd657 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -0,0 +1,358 @@ +# Generated Package Ownership Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every relocated Goa declaration and reference use one name assigned by its generated Go package. + +**Architecture:** `service.ServicesData` owns a generation-lifetime catalog keyed by generated import path. Each package record owns its `codegen.NameScope`, relocated user types, and canonical union rendering records; package-aware attribute scopes route service and transport transforms to those records. `service.Files` renders the complete analyzed root, including one `unions.go` per shared package. + +**Tech Stack:** Go 1.25, Goa expression evaluation, Goa service/HTTP/gRPC code generation, `testify/require` + +**Spec:** `codegen/ARCHITECTURE.md` + +## Global Constraints + +- Never edit generated output; regenerate it from the owning design. +- Keep `expr.Union.Hash()` unchanged and use a typed code-generation identity for emitted unions. +- Reject relocated declared user types whose names become the same Go identifier in one output package. +- Definitions and references must consume the same package-owned declaration record. +- Do not use decorated strings, synthetic map keys, global registries, fallbacks, or traversal-order heuristics to coordinate ownership. +- Every exported construct needs GoDoc; non-trivial files need a concrete header comment. + +--- + +### Task 1: Regression contracts + +**Files:** +- Modify: `codegen/generator/service_union_package_scope_test.go` +- Create: `codegen/service/generated_package_test.go` + +**Interfaces:** +- Consumes: existing `generator.Generate`, HTTP/gRPC DSL, and `codegen.Goify` +- Produces: a compile regression for nested relocated unions and a validation regression for colliding relocated declared names + +- [ ] **Step 1: Write the generated-module regression** + +Create one real design root with two services. Put distinct user types in the +same `struct:pkg:path` package and give each a different nested union whose +natural name is `Value`. Enable HTTP and gRPC and run the complete generated +module's tests. + +- [ ] **Step 2: Run the generated-module regression and record the failure** + +Run: + +```bash +go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 +``` + +Expected: FAIL because a transport reference names a different union than the +shared package declares. + +- [ ] **Step 3: Write the collision regression** + +Construct relocated declared types named `foo-bar` and `foo_bar` in the same +package and call `NewServicesData`: + +```go +_, err := NewServicesData(root) +require.ErrorContains(t, err, `"foo-bar" and "foo_bar" both generate Go type "FooBar" in package "types"`) +``` + +- [ ] **Step 4: Run the collision regression and record the failure** + +Run: + +```bash +go test ./codegen/service -run TestGeneratedPackagesRejectUserTypeNameCollision -count=1 +``` + +Expected: FAIL because `NewServicesData` does not yet return an error. + +### Task 2: Explicit code-generation identity + +**Files:** +- Modify: `codegen/union.go` +- Modify: `codegen/scope.go` +- Modify: `codegen/scope_test.go` + +**Interfaces:** +- Consumes: `expr.Union`, `NameScope.HashedUnique(Hasher, string, ...string)` +- Produces: `type UnionTypeID string`, `func NewUnionTypeID(*expr.Union) UnionTypeID`, and an explicit private `Hasher` used at union-generation sites + +- [ ] **Step 1: Preserve the public scope contract in a failing test** + +Add a test whose custom `Hasher.Hash()` value must be the exact key used by +`HashedUnique`, including when the concrete value wraps a union. + +- [ ] **Step 2: Run the scope test and confirm the hidden union special case fails** + +Run: + +```bash +go test ./codegen -run 'TestNameScope_HashedUnique|TestUnionTypeID' -count=1 +``` + +- [ ] **Step 3: Introduce the typed identity and remove the hidden special case** + +Use these contracts: + +```go +type UnionTypeID string + +func NewUnionTypeID(union *expr.Union) UnionTypeID + +type unionNameKey struct { + id UnionTypeID + role unionNameRole +} + +func (k unionNameKey) Hash() string +``` + +`NameScope.HashedUnique` calls `key.Hash()` directly. Union declaration sites +pass an explicit union name key. `expr.Union.Hash()` remains unchanged. + +- [ ] **Step 4: Run the focused identity tests** + +Run: + +```bash +go test ./codegen -run 'TestNameScope|TestUnionType' -count=1 +``` + +Expected: PASS. + +### Task 3: Package-owned service analysis + +**Files:** +- Create: `codegen/service/generated_package.go` +- Modify: `codegen/service/service_data.go` +- Modify: `codegen/service/service_data_union_order_test.go` +- Modify: `codegen/service/views.go` + +**Interfaces:** +- Consumes: `codegen.NameScope`, `codegen.UnionTypeID`, `UserTypeData`, `UnionTypeData` +- Produces: private `generatedPackages`, `generatedPackage`, `typeDeclaration`, and `packageAttributeScope`; `NewServicesData(*expr.RootExpr) (*ServicesData, error)` + +- [ ] **Step 1: Add focused package-catalog tests** + +Test that one package returns the same declaration record for the same union +identity, distinct records for different shapes, deterministic names after +declared user names are reserved, and an error for duplicate declared Go names. + +- [ ] **Step 2: Run the focused tests and confirm the catalog is absent** + +Run: + +```bash +go test ./codegen/service -run 'TestGeneratedPackage|TestUnionOrder' -count=1 +``` + +- [ ] **Step 3: Implement the package catalog and package-aware attributor** + +Use these private contracts: + +```go +type generatedPackages struct { + packages map[string]*generatedPackage +} + +type generatedPackage struct { + path string + scope *codegen.NameScope + userTypes map[string]*typeDeclaration + unions map[codegen.UnionTypeID]*typeDeclaration +} + +type typeDeclaration struct { + name string + userType *UserTypeData + union *UnionTypeData +} +``` + +`packageAttributeScope` implements `codegen.Attributor`. It selects a relocated +user type's package from `struct:pkg:path`, a union's package from the enclosing +declaration path, and the service scope otherwise. It delegates name, reference, +and field formatting to `codegen.NewAttributeScope` using the selected +`NameScope`. + +- [ ] **Step 4: Make analysis eager and single-root** + +Delete `NewServicesDataForRoots`, `serviceNameScopes`, package priming, and the +union companion sentinel. Reserve every relocated declared user type before +registering unions, analyze services in declaration order, and make `Get` a +lookup over the completed map. + +- [ ] **Step 5: Run service analysis tests** + +Run: + +```bash +go test ./codegen/service -count=1 +``` + +Expected: PASS. + +### Task 4: One owner renders shared packages + +**Files:** +- Modify: `codegen/service/service.go` +- Modify: `codegen/service/service_test.go` +- Modify: `codegen/generator/service.go` +- Modify: `codegen/generator/example.go` +- Modify: `codegen/generator/transport.go` + +**Interfaces:** +- Consumes: completed `service.ServicesData` package catalog +- Produces: `func Files(genpkg string, services *ServicesData) []*codegen.File`, including one `unions.go` per relocated package + +- [ ] **Step 1: Update rendering tests to assert package ownership** + +Assert that relocated user-type files contain only their declared types, the +package has exactly one `unions.go`, and repeated service references do not +duplicate declarations. + +- [ ] **Step 2: Run focused rendering tests and record the failure** + +Run: + +```bash +go test ./codegen/service -run 'TestFiles.*Union|TestFiles.*Package' -count=1 +``` + +- [ ] **Step 3: Replace per-service rendering and external deduplication** + +Change `Files` to render the whole analyzed root. Delete `userTypePkgs`, +`~union:`, `unionRegistryKey`, and `unionCompanionKey`. Render each relocated +package from its catalog, sorting package paths, user-type file paths, and union +identities for stable output. + +- [ ] **Step 4: Restore generator callers to one real root** + +`Service`, `Example`, and `Transport` each call `NewServicesData(root)` and +propagate its error. The service generator calls `service.Files(genpkg, +services)` once per root. Remove all cross-root service-data maps. + +- [ ] **Step 5: Run service and generator tests** + +Run: + +```bash +go test ./codegen/service ./codegen/generator -count=1 +``` + +Expected: PASS. + +### Task 5: Transport and conversion references + +**Files:** +- Modify: `codegen/service/convert.go` +- Modify: `http/codegen/service_data.go` +- Modify: `http/codegen/websocket.go` +- Modify: `http/codegen/sse.go` +- Modify: `grpc/codegen/service_data.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: package-aware `codegen.Attributor` values from `ServicesData` +- Produces: every recursive service-type transform uses the package that owns the enclosing generated declaration + +- [ ] **Step 1: Route conversion generation through the package catalog** + +Replace fresh `NameScope` instances used for relocated conversion files with +package-aware contexts from `ServicesData`. The current package path is the +relocated user's `RelImportPath`. + +- [ ] **Step 2: Route HTTP service contexts through the package catalog** + +At each HTTP conversion, validation, SSE, and WebSocket site, derive the +enclosing service type's location and request the service attributor from +`ServicesData`. Keep `sd.Scope` for HTTP wire declarations only. + +- [ ] **Step 3: Route gRPC service contexts through the package catalog** + +Use the same service attributor for payload, result, error, and streaming +transforms. Keep protobuf scopes responsible only for protobuf declarations. + +- [ ] **Step 4: Run the real generated-module regression** + +Run: + +```bash +go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 +``` + +Expected: PASS with HTTP and gRPC enabled. + +- [ ] **Step 5: Run core generator tests** + +Run: + +```bash +go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/... -count=1 +``` + +Expected: PASS. + +### Task 6: Plugins, full proof, and publication + +**Files:** +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/data.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/ir/build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/generate.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/generate_test.go` +- Modify: `codegen/ARCHITECTURE.md` if implementation evidence changes its contract +- Modify: `AGENTS.md` if review finds a missing durable gate + +**Interfaces:** +- Consumes: generated-package ownership contract +- Produces: verified plugin isolation or shared-context participation, regenerated AURA, and an updated pull request + +- [ ] **Step 1: Audit plugin callers** + +Update the goa-ai agent and intermediate-representation builders to propagate +the `NewServicesData` error. Update the MCP generator to call the whole-root +`service.Files` contract for its temporary root. Prove in +`codegen/mcp/generate_test.go` that the temporary root emits only beneath its +MCP service package and cannot contribute declarations to a core service's +relocated package. + +- [ ] **Step 2: Run Goa verification** + +Run: + +```bash +go fmt ./... +go test ./... -count=1 +make lint +``` + +Expected: all commands pass. + +- [ ] **Step 3: Regenerate and test AURA from scratch** + +Run in `/Users/raphael/src/aura`: + +```bash +./scripts/gen goa +cd gen && go test ./... -count=1 +``` + +If AURA's full `./scripts/gen` is required by the repository workflow, run it +before the generated-module test. Never patch files under `gen/`. + +- [ ] **Step 4: Remove obsolete mechanisms** + +Search for and remove `NewServicesDataForRoots`, `~union:`, +`unionRegistryKey`, `unionCompanionKey`, `userTypePkgs`, union-specific behavior +inside `NameScope.HashedUnique`, and fresh scopes used for relocated service +types. Confirm `SectionTemplate.Name` is not used as declaration identity. + +- [ ] **Step 5: Review and publish the pull request** + +Run an independent code review, address each confirmed finding, and update the +PR with a plain-language explanation of the failure, package ownership rule, +generated-source change, newly rejected ambiguous design, and exact proof. +Push only after every verification command passes. From 2863cc9f76ff4fac4f00bf1c599485936104589d Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 21:57:51 -0700 Subject: [PATCH 03/43] docs(codegen): widen ownership to generation lifetime --- AGENTS.md | 9 +- codegen/ARCHITECTURE.md | 67 +-- .../2026-08-20-generated-package-ownership.md | 384 +++++++++++------- 3 files changed, 271 insertions(+), 189 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ec70bcc1d..75a1eee6a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,10 +60,11 @@ No commented-out code—delete dead code. - **Keep helper visibility minimal**: If logic is shared only inside one codegen area, keep it package-private or move it under an `internal` package. Do not export helpers from a parent package just to share them across sibling generators. - **Avoid pass-through wrappers**: When two helper functions differ only by forwarding arguments or hard-coding `nil`, collapse them into a single implementation instead of adding an extra layer. - **Generated packages own names**: When declarations from multiple services - compile into one Go package, that package owns their `NameScope`, canonical - declaration records, and emission. Definitions and HTTP/gRPC/JSON-RPC - references must consume the same package-owned record; independently primed - service scopes are invalid. + or plugins compile into one Go package, the generation context plans and + freezes that package's `NameScope` and canonical declaration records before + rendering. Definitions and HTTP/gRPC/JSON-RPC references must consume the + same package-owned record; independently primed service or plugin scopes and + declarations added after freeze are invalid. - **Keep identity typed and explicit**: Do not encode declaration kind, package, scope, or lifetime in decorated names or synthetic string map keys. Do not change an expression's `Hash` semantics to satisfy code generation; pass an diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 53549ae4b4..e50a8a9219 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -14,11 +14,13 @@ then follows this order: 1. Evaluate and validate the design. 2. Let preparation plugins amend the evaluated expression roots. 3. Normalize the roots. -4. Analyze the design into service and transport rendering data. -5. Let the core service, HTTP, gRPC, JSON-RPC, and OpenAPI generators return - files. -6. Let post-generation plugins return additional files. -7. Merge contributions with the same output path and render the files. +4. Create one generation context for the normalized roots. +5. Let every selected core generator and plugin declare the generated service + types it may emit, then freeze their package names. +6. Let the core service, HTTP, gRPC, JSON-RPC, and OpenAPI generators render + files using the frozen declarations. +7. Let post-generation plugins render additional files using the same context. +8. Merge contributions with the same output path and render the files. The core service generator therefore reasons about one real design root. A temporary root created by a plugin is a separate analysis unless the plugin is @@ -42,8 +44,8 @@ order, branch type shape, and relocated branch packages. ## Generated packages -The service analysis owns a catalog keyed by the actual generated import path. -Each catalog entry represents one Go package and owns: +The generation context owns a catalog keyed by the actual generated import +path. Each catalog entry represents one Go package and owns: - one `codegen.NameScope` for every package-level identifier; - the relocated user types rendered into that package; @@ -51,10 +53,12 @@ Each catalog entry represents one Go package and owns: - the final names of each union type, discriminator type, constants, and constructors. -Registering a declaration returns its canonical record. Code that declares the -type and code that refers to it must consume that record or the package-aware -attribute scope backed by it. A transport must never recreate a union name from -a service-local `NameScope`. +Planning a declaration returns its canonical record. Once every selected +generator and plugin has planned its output, the context freezes the catalog. +Rendering may only look up those records; a late attempt to add a declaration +is an error. Code that declares a type and code that refers to it must consume +the same record or the package-aware attribute scope backed by it. A transport +must never recreate a union name from a service-local `NameScope`. For example, suppose two services place types in `gen/types`. Both contain a nested union whose natural Go name is `Value`, but the unions have different @@ -75,8 +79,9 @@ which service first referred to them. ## Attribute naming during transforms `codegen.AttributeContext` asks an `Attributor` for names and references. A -service-local context uses the service package scope. A context that transforms -a relocated type uses a package-aware attributor: +service-local context uses the service package record. A context that transforms +a relocated type uses a package-aware attributor backed by the generation +catalog: - a declared user type selects the package named by its `struct:pkg:path`; - a nested union selects the package of the enclosing generated declaration; @@ -88,17 +93,24 @@ scopes still own transport-only wire declarations. ## Plugin and file assembly contracts -A plugin that can emit declarations into a package already used by core -generation must receive and use the active generated-package catalog. A plugin -that analyzes a temporary root independently must emit to packages isolated -from the core root. Independent analyses may not coordinate through package -names, process-global maps, decorated strings, or render-order assumptions. +A plugin that can emit generated service types plans them before the catalog is +frozen and renders them with the active generation context. A plugin that +analyzes a temporary root must either plan those types in the active context or +emit to packages isolated from every other participant. Independent analyses +may not coordinate through package names, process-global maps, decorated +strings, or render-order assumptions. + +Standalone or selective generation creates a fresh context containing exactly +the roots, generators, and plugins selected for that output. It runs the same +plan, freeze, and render phases. An API that accepts only roots and reconstructs +its own scope cannot safely contribute to a larger generation. `codegen.SectionTemplate.Name` labels a template for diagnostics. It is not a -declaration identity. When multiple generators contribute to one output file, -the file owner must supply explicit declaration identity or combine the -sections before returning the file. Output merging must not discard sections -merely because their diagnostic labels match. +declaration identity. Output merging appends same-path sections and merges +imports; it must not discard sections merely because their diagnostic labels +match. Package owners remove identical declaration contributions before they +become file sections. Conflicting declarations remain visible and fail with a +generation or Go compilation error instead of disappearing silently. ## Review gate @@ -107,11 +119,12 @@ files, or file merging, trace one declaration through all of these stages: 1. the single evaluated root; 2. service analysis; -3. the owning generated-package record; -4. service declaration rendering; -5. HTTP and gRPC references; -6. post-generation plugin contributions; and -7. final files after output-path merging. +3. planning and catalog freeze; +4. the owning generated-package record; +5. service declaration rendering; +6. HTTP and gRPC references; +7. post-generation plugin contributions; and +8. final files after output-path merging. A service-only render test is insufficient. The regression must compile a real generated module with both HTTP and gRPC enabled whenever those transports can diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index ae33acd657..4e105607c1 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -2,11 +2,11 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Make every relocated Goa declaration and reference use one name assigned by its generated Go package. +**Goal:** Make every generated service-type declaration and reference use one name frozen by the generation that produces the output. -**Architecture:** `service.ServicesData` owns a generation-lifetime catalog keyed by generated import path. Each package record owns its `codegen.NameScope`, relocated user types, and canonical union rendering records; package-aware attribute scopes route service and transport transforms to those records. `service.Files` renders the complete analyzed root, including one `unions.go` per shared package. +**Architecture:** After prepare plugins and root normalization, one `codegen.Generation` runs all selected core generators and plugins through plan, freeze, and render phases. Its generated-package catalog owns package scopes and canonical user-type and union records. Service, HTTP, gRPC, JSON-RPC, standalone callers, and goa-ai plugins resolve names through those records; package owners render each declaration once. -**Tech Stack:** Go 1.25, Goa expression evaluation, Goa service/HTTP/gRPC code generation, `testify/require` +**Tech Stack:** Go 1.25, Goa evaluation and code generation, goa-ai plugins, `testify/require` **Spec:** `codegen/ARCHITECTURE.md` @@ -15,30 +15,33 @@ - Never edit generated output; regenerate it from the owning design. - Keep `expr.Union.Hash()` unchanged and use a typed code-generation identity for emitted unions. - Reject relocated declared user types whose names become the same Go identifier in one output package. -- Definitions and references must consume the same package-owned declaration record. -- Do not use decorated strings, synthetic map keys, global registries, fallbacks, or traversal-order heuristics to coordinate ownership. +- Definitions and references must consume the same frozen package-owned declaration record. +- Rendering cannot add declarations; standalone generation runs the same plan, freeze, and render phases. +- Do not use decorated strings, synthetic map keys, process-global registries, fallbacks, or traversal-order heuristics to coordinate ownership. +- `SectionTemplate.Name` is diagnostic metadata, not declaration identity. - Every exported construct needs GoDoc; non-trivial files need a concrete header comment. --- -### Task 1: Regression contracts +### Task 1: Executable failure contracts **Files:** - Modify: `codegen/generator/service_union_package_scope_test.go` -- Create: `codegen/service/generated_package_test.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Create: `codegen/generated_types_test.go` **Interfaces:** -- Consumes: existing `generator.Generate`, HTTP/gRPC DSL, and `codegen.Goify` -- Produces: a compile regression for nested relocated unions and a validation regression for colliding relocated declared names +- Consumes: current full generator, HTTP/gRPC DSL, and same-path file merging +- Produces: red tests for relocated nested-union references, relocated declared-name collisions, and same-label section preservation -- [ ] **Step 1: Write the generated-module regression** +- [ ] **Step 1: Add the real generated-module regression** -Create one real design root with two services. Put distinct user types in the +Use one evaluated design root with two services. Put distinct user types in the same `struct:pkg:path` package and give each a different nested union whose -natural name is `Value`. Enable HTTP and gRPC and run the complete generated -module's tests. +natural name is `Value`. Enable HTTP and gRPC, generate the module, and run `go +test ./...` inside it. -- [ ] **Step 2: Run the generated-module regression and record the failure** +- [ ] **Step 2: Prove the nested-union regression fails for the intended reason** Run: @@ -46,30 +49,28 @@ Run: go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 ``` -Expected: FAIL because a transport reference names a different union than the -shared package declares. +Expected: FAIL with a generated Go reference to a union name different from the +declaration in the shared package. -- [ ] **Step 3: Write the collision regression** +- [ ] **Step 3: Add the collision and merge regressions** -Construct relocated declared types named `foo-bar` and `foo_bar` in the same -package and call `NewServicesData`: +The collision test plans `foo-bar` and `foo_bar` into package `types` and +expects an error naming both inputs and `FooBar`. The merge test contributes two +different sections with the same `SectionTemplate.Name` and expects both +rendered bodies to remain. -```go -_, err := NewServicesData(root) -require.ErrorContains(t, err, `"foo-bar" and "foo_bar" both generate Go type "FooBar" in package "types"`) -``` - -- [ ] **Step 4: Run the collision regression and record the failure** +- [ ] **Step 4: Prove both contracts fail before implementation** Run: ```bash -go test ./codegen/service -run TestGeneratedPackagesRejectUserTypeNameCollision -count=1 +go test ./codegen ./codegen/generator -run 'TestGeneratedTypesRejectRelocatedNameCollision|TestMergeFilesPreservesSameLabelSections' -count=1 ``` -Expected: FAIL because `NewServicesData` does not yet return an error. +Expected: FAIL because the generation-owned type catalog does not exist and the +merger drops the second same-label section. -### Task 2: Explicit code-generation identity +### Task 2: Typed emitted-union identity **Files:** - Modify: `codegen/union.go` @@ -78,14 +79,15 @@ Expected: FAIL because `NewServicesData` does not yet return an error. **Interfaces:** - Consumes: `expr.Union`, `NameScope.HashedUnique(Hasher, string, ...string)` -- Produces: `type UnionTypeID string`, `func NewUnionTypeID(*expr.Union) UnionTypeID`, and an explicit private `Hasher` used at union-generation sites +- Produces: `type UnionTypeID string`, `func NewUnionTypeID(*expr.Union) UnionTypeID`, and generic `Hasher.Hash()` behavior -- [ ] **Step 1: Preserve the public scope contract in a failing test** +- [ ] **Step 1: Add a focused `HashedUnique` contract test** -Add a test whose custom `Hasher.Hash()` value must be the exact key used by -`HashedUnique`, including when the concrete value wraps a union. +Use a custom `Hasher` and assert that `HashedUnique` keys only on its exact +`Hash()` result. Keep the existing tests that distinguish emitted unions by +wire keys, branch order, branch Go shape, and relocated package. -- [ ] **Step 2: Run the scope test and confirm the hidden union special case fails** +- [ ] **Step 2: Run the focused test and record the hidden special-case failure** Run: @@ -93,27 +95,22 @@ Run: go test ./codegen -run 'TestNameScope_HashedUnique|TestUnionTypeID' -count=1 ``` -- [ ] **Step 3: Introduce the typed identity and remove the hidden special case** +- [ ] **Step 3: Introduce the typed identity and restore `HashedUnique`** -Use these contracts: +Use this public contract: ```go type UnionTypeID string func NewUnionTypeID(union *expr.Union) UnionTypeID - -type unionNameKey struct { - id UnionTypeID - role unionNameRole -} - -func (k unionNameKey) Hash() string ``` -`NameScope.HashedUnique` calls `key.Hash()` directly. Union declaration sites -pass an explicit union name key. `expr.Union.Hash()` remains unchanged. +`NewUnionTypeID` contains the current emitted-definition hashing algorithm. +`NameScope.HashedUnique` calls `key.Hash()` directly. `GoFullTypeName` may look +up an already planned union through an explicit emitted-union key, but the +generic scope API must not reinterpret arbitrary hashers. -- [ ] **Step 4: Run the focused identity tests** +- [ ] **Step 4: Run all scope and union tests** Run: @@ -123,117 +120,141 @@ go test ./codegen -run 'TestNameScope|TestUnionType' -count=1 Expected: PASS. -### Task 3: Package-owned service analysis +### Task 3: Generation plan, freeze, and render contract **Files:** -- Create: `codegen/service/generated_package.go` -- Modify: `codegen/service/service_data.go` -- Modify: `codegen/service/service_data_union_order_test.go` -- Modify: `codegen/service/views.go` +- Create: `codegen/generation.go` +- Create: `codegen/generated_types.go` +- Modify: `codegen/plugin.go` +- Modify: `codegen/plugin_test.go` +- Modify: `codegen/generator/generators.go` +- Modify: `codegen/generator/generate.go` +- Create: `codegen/generator/generation_test.go` **Interfaces:** -- Consumes: `codegen.NameScope`, `codegen.UnionTypeID`, `UserTypeData`, `UnionTypeData` -- Produces: private `generatedPackages`, `generatedPackage`, `typeDeclaration`, and `packageAttributeScope`; `NewServicesData(*expr.RootExpr) (*ServicesData, error)` - -- [ ] **Step 1: Add focused package-catalog tests** - -Test that one package returns the same declaration record for the same union -identity, distinct records for different shapes, deterministic names after -declared user names are reserved, and an error for duplicate declared Go names. +- Consumes: normalized `[]eval.Root`, `codegen.NameScope`, `codegen.UnionTypeID` +- Produces: `Generation`, generated-package records, plan-aware core generator and plugin APIs -- [ ] **Step 2: Run the focused tests and confirm the catalog is absent** +- [ ] **Step 1: Add phase and declaration tests** -Run: - -```bash -go test ./codegen/service -run 'TestGeneratedPackage|TestUnionOrder' -count=1 -``` +Test user-type idempotency, union idempotency, different same-base unions, +exact relocated-name rejection, lookup before and after freeze, declaration +after freeze rejection, and isolation between two standalone generations. -- [ ] **Step 3: Implement the package catalog and package-aware attributor** +- [ ] **Step 2: Implement generation-owned package records** -Use these private contracts: +Use these public contracts: ```go -type generatedPackages struct { - packages map[string]*generatedPackage +type Generation struct { + GenPkg string + Roots []eval.Root } -type generatedPackage struct { - path string - scope *codegen.NameScope - userTypes map[string]*typeDeclaration - unions map[codegen.UnionTypeID]*typeDeclaration -} +func NewGeneration(genpkg string, roots []eval.Root) *Generation +func (g *Generation) GeneratedPackage(path string) *GeneratedPackage +func (g *Generation) Freeze() error + +type GeneratedPackage struct{} -type typeDeclaration struct { - name string - userType *UserTypeData - union *UnionTypeData +func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) +func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) +func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) +func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) +func (p *GeneratedPackage) Scope() *NameScope + +type TypeDeclaration struct { + Name string + PackagePath string } ``` -`packageAttributeScope` implements `codegen.Attributor`. It selects a relocated -user type's package from `struct:pkg:path`, a union's package from the enclosing -declaration path, and the service scope otherwise. It delegates name, reference, -and field formatting to `codegen.NewAttributeScope` using the selected -`NameScope`. +Declaration methods allocate only during planning. Lookup methods never +allocate. `Freeze` makes every package immutable. User types reserve the exact +`Goify(Name(), true)` name and report collisions; unions allocate through their +typed emitted identity. + +- [ ] **Step 3: Change core and plugin lifecycle APIs** + +Use these contracts: + +```go +type PlanFunc func(*Generation) error +type GenerateFunc func(*Generation, []*File) ([]*File, error) -- [ ] **Step 4: Make analysis eager and single-root** +type Genfunc struct { + Plan codegen.PlanFunc + Generate func(*codegen.Generation) ([]*codegen.File, error) +} +``` -Delete `NewServicesDataForRoots`, `serviceNameScopes`, package priming, and the -union companion sentinel. Reserve every relocated declared user type before -registering unions, analyze services in declaration order, and make `Get` a -lookup over the completed map. +`RegisterPlugin`, `RegisterPluginFirst`, and `RegisterPluginLast` accept +prepare, plan, and generate functions. `Generate` runs prepare, normalization, +every core/plugin plan, `Freeze`, every core render, then every plugin render. +No render callback may declare a new type. -- [ ] **Step 5: Run service analysis tests** +- [ ] **Step 4: Run lifecycle tests** Run: ```bash -go test ./codegen/service -count=1 +go test ./codegen ./codegen/generator -run 'TestGeneration|TestGeneratedPackage|TestRegisterPlugin|TestGeneratePhases' -count=1 ``` Expected: PASS. -### Task 4: One owner renders shared packages +### Task 4: Package-owned service analysis and emission **Files:** +- Create: `codegen/service/generated_package.go` +- Modify: `codegen/service/service_data.go` - Modify: `codegen/service/service.go` +- Modify: `codegen/service/convert.go` +- Modify: `codegen/service/views.go` - Modify: `codegen/service/service_test.go` +- Modify: `codegen/service/service_data_union_order_test.go` - Modify: `codegen/generator/service.go` - Modify: `codegen/generator/example.go` -- Modify: `codegen/generator/transport.go` +- Modify: `codegen/generator/openapi.go` **Interfaces:** -- Consumes: completed `service.ServicesData` package catalog -- Produces: `func Files(genpkg string, services *ServicesData) []*codegen.File`, including one `unions.go` per relocated package +- Consumes: frozen or planning `*codegen.Generation`, `*codegen.TypeDeclaration` +- Produces: `NewServicesData(*expr.RootExpr, *codegen.Generation) (*ServicesData, error)` and root-level package-owned service files -- [ ] **Step 1: Update rendering tests to assert package ownership** +- [ ] **Step 1: Add package analysis and emission tests** -Assert that relocated user-type files contain only their declared types, the -package has exactly one `unions.go`, and repeated service references do not -duplicate declarations. +Test that all services in one root bind to the same declaration records, +identical unions emit once, different same-base unions receive distinct frozen +names, relocated user types emit once at their metadata paths, and each owning +package emits one `unions.go`. -- [ ] **Step 2: Run focused rendering tests and record the failure** +- [ ] **Step 2: Replace local package priming with frozen declarations** -Run: +Delete `NewServicesDataForRoots`, `packageScopes`, `serviceNameScopes`, +`unionCompanionKey`, and every decorated union key. During planning, +`NewServicesData` declares every relocated user type before unions. During +rendering, it looks up the same records. `UserTypeData` and `UnionTypeData` +retain their `*codegen.TypeDeclaration`; `buildUnionTypeData` allocates the kind +name once from the owning package scope and stores it in the union render data. -```bash -go test ./codegen/service -run 'TestFiles.*Union|TestFiles.*Package' -count=1 -``` +- [ ] **Step 3: Make the package owner render all service types** + +Change the public renderer to: -- [ ] **Step 3: Replace per-service rendering and external deduplication** +```go +func Files(genpkg string, services *ServicesData) []*codegen.File +``` -Change `Files` to render the whole analyzed root. Delete `userTypePkgs`, -`~union:`, `unionRegistryKey`, and `unionCompanionKey`. Render each relocated -package from its catalog, sorting package paths, user-type file paths, and union -identities for stable output. +It renders service-local files, each relocated user type at its configured +file, and one sorted `unions.go` per package. Remove `userTypePkgs`, `~union:`, +and `unionRegistryKey`. `ConvertFiles` uses the owning package scope rather than +a fresh one. -- [ ] **Step 4: Restore generator callers to one real root** +- [ ] **Step 4: Migrate core service, example, and OpenAPI generators** -`Service`, `Example`, and `Transport` each call `NewServicesData(root)` and -propagate its error. The service generator calls `service.Files(genpkg, -services)` once per root. Remove all cross-root service-data maps. +Their plan callback analyzes each design root with the active generation. Their +render callback repeats analysis against frozen records and propagates errors. +The Service renderer calls the root-level `service.Files` once. - [ ] **Step 5: Run service and generator tests** @@ -245,38 +266,46 @@ go test ./codegen/service ./codegen/generator -count=1 Expected: PASS. -### Task 5: Transport and conversion references +### Task 5: Frozen service names in HTTP, gRPC, and JSON-RPC **Files:** -- Modify: `codegen/service/convert.go` +- Modify: `codegen/transformer.go` - Modify: `http/codegen/service_data.go` - Modify: `http/codegen/websocket.go` - Modify: `http/codegen/sse.go` +- Modify: `http/codegen/client.go` +- Modify: `http/codegen/server.go` - Modify: `grpc/codegen/service_data.go` +- Modify: `grpc/codegen/types.go` +- Modify: `grpc/codegen/server.go` +- Modify: `codegen/generator/transport.go` - Test: `codegen/generator/service_union_package_scope_test.go` **Interfaces:** -- Consumes: package-aware `codegen.Attributor` values from `ServicesData` -- Produces: every recursive service-type transform uses the package that owns the enclosing generated declaration +- Consumes: service data bound to frozen `TypeDeclaration` records and package scopes +- Produces: package-aware `Attributor` contexts for every recursive service-type transform -- [ ] **Step 1: Route conversion generation through the package catalog** +- [ ] **Step 1: Add focused transport reference assertions** -Replace fresh `NameScope` instances used for relocated conversion files with -package-aware contexts from `ServicesData`. The current package path is the -relocated user's `RelImportPath`. +For the two-service nested-union design, assert that HTTP and gRPC conversion +helpers refer to the exact union names declared in the relocated package. Keep +transport wire-type scopes independent. -- [ ] **Step 2: Route HTTP service contexts through the package catalog** +- [ ] **Step 2: Make attribute contexts carry package ownership** -At each HTTP conversion, validation, SSE, and WebSocket site, derive the -enclosing service type's location and request the service attributor from -`ServicesData`. Keep `sd.Scope` for HTTP wire declarations only. +Add the generated package path or frozen declaration resolver required for an +`Attributor` to select the enclosing service package while recursion enters a +relocated user type. `AttributeContext.Dup` must preserve it, and helper +generation must update it when `struct:pkg:path` changes the enclosing package. -- [ ] **Step 3: Route gRPC service contexts through the package catalog** +- [ ] **Step 3: Replace direct service-scope recomputation** -Use the same service attributor for payload, result, error, and streaming -transforms. Keep protobuf scopes responsible only for protobuf declarations. +HTTP, WebSocket, SSE, client/server callbacks, gRPC conversions, gRPC +`fullTypeName`, and transport generator setup resolve service types through the +frozen service attributor or existing canonical method declaration. `sd.Scope` +continues to name only HTTP/protobuf wire declarations. -- [ ] **Step 4: Run the real generated-module regression** +- [ ] **Step 4: Run the generated-module regression** Run: @@ -286,7 +315,7 @@ go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 Expected: PASS with HTTP and gRPC enabled. -- [ ] **Step 5: Run core generator tests** +- [ ] **Step 5: Run all core codegen tests** Run: @@ -296,30 +325,75 @@ go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/.. Expected: PASS. -### Task 6: Plugins, full proof, and publication +### Task 6: Goa-ai plugin participation **Files:** +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/init.go` - Modify: `/Users/raphael/src/goa-ai/codegen/agent/data.go` - Modify: `/Users/raphael/src/goa-ai/codegen/ir/build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/init.go` - Modify: `/Users/raphael/src/goa-ai/codegen/mcp/generate.go` -- Test: `/Users/raphael/src/goa-ai/codegen/mcp/generate_test.go` -- Modify: `codegen/ARCHITECTURE.md` if implementation evidence changes its contract -- Modify: `AGENTS.md` if review finds a missing durable gate +- Modify: `/Users/raphael/src/goa-ai/eval/codegen/codegen.go` +- Create: `/Users/raphael/src/goa-ai/codegen/mcp/generate_test.go` + +**Interfaces:** +- Consumes: Goa's plan-aware plugin API, active `*codegen.Generation`, root-level `service.Files` +- Produces: agent, MCP, and eval plugins that plan generated service types before freeze and render from the same records + +- [ ] **Step 1: Add MCP planning tests** + +Create an MCP temporary service whose relocated type package overlaps a core +service package. Assert that identical declarations share a record and +incompatible declared names or shapes return an error during planning, before +any core or MCP file renders. + +- [ ] **Step 2: Migrate plugin registration and builders** + +Add plan callbacks to agent, MCP, and eval registration. Builders that need +service data accept the active generation and propagate `NewServicesData` +errors. MCP plans its temporary root into the active context and calls +root-level `service.Files` during render. Delete its `userTypePkgs` map. + +- [ ] **Step 3: Run goa-ai tests against local Goa** + +Run in `/Users/raphael/src/goa-ai` with its existing local Goa replacement: + +```bash +go test ./codegen/... ./eval/codegen/... -count=1 +``` + +Expected: PASS. + +### Task 7: File assembly, full regeneration, and publication + +**Files:** +- Modify: `codegen/generator/generate.go` +- Modify: `codegen/generator/generate_merge_test.go` **Interfaces:** -- Consumes: generated-package ownership contract -- Produces: verified plugin isolation or shared-context participation, regenerated AURA, and an updated pull request +- Consumes: package-owned emission and same-path file contributions +- Produces: lossless same-path merging, verified Goa/goa-ai/AURA, and updated pull requests -- [ ] **Step 1: Audit plugin callers** +- [ ] **Step 1: Make same-path merging lossless** -Update the goa-ai agent and intermediate-representation builders to propagate -the `NewServicesData` error. Update the MCP generator to call the whole-root -`service.Files` contract for its temporary root. Prove in -`codegen/mcp/generate_test.go` that the temporary root emits only beneath its -MCP service package and cannot contribute declarations to a core service's -relocated package. +Merge header imports and append every non-header section in generator order. +Do not deduplicate by `SectionTemplate.Name`. Package owners already remove +identical type declarations; conflicting output must remain visible as an +explicit generation or Go compilation failure. -- [ ] **Step 2: Run Goa verification** +- [ ] **Step 2: Remove obsolete mechanisms** + +Confirm these searches return no production hits: + +```bash +rg -n 'NewServicesDataForRoots|~union:|unionRegistryKey|unionCompanionKey|userTypePkgs|scopedTypeHash' --glob '*.go' +rg -n 'NewNameScope\(\)' codegen/service http/codegen grpc/codegen --glob '*.go' +``` + +Inspect every remaining fresh scope and retain it only for a package whose +declarations it exclusively owns. + +- [ ] **Step 3: Verify Goa** Run: @@ -331,28 +405,22 @@ make lint Expected: all commands pass. -- [ ] **Step 3: Regenerate and test AURA from scratch** +- [ ] **Step 4: Regenerate and verify AURA from scratch** Run in `/Users/raphael/src/aura`: ```bash ./scripts/gen goa +./scripts/gen cd gen && go test ./... -count=1 ``` -If AURA's full `./scripts/gen` is required by the repository workflow, run it -before the generated-module test. Never patch files under `gen/`. - -- [ ] **Step 4: Remove obsolete mechanisms** - -Search for and remove `NewServicesDataForRoots`, `~union:`, -`unionRegistryKey`, `unionCompanionKey`, `userTypePkgs`, union-specific behavior -inside `NameScope.HashedUnique`, and fresh scopes used for relocated service -types. Confirm `SectionTemplate.Name` is not used as declaration identity. +Never patch files under `gen/`; each generation command owns deletion and +recreation. -- [ ] **Step 5: Review and publish the pull request** +- [ ] **Step 5: Review and publish** -Run an independent code review, address each confirmed finding, and update the -PR with a plain-language explanation of the failure, package ownership rule, -generated-source change, newly rejected ambiguous design, and exact proof. -Push only after every verification command passes. +Run independent whole-branch reviews in Goa and goa-ai, address every confirmed +finding, and update the Goa PR with the concrete failure, generation ownership +rule, breaking API, generated-source change, rejected ambiguous design, plugin +behavior, and exact verification commands. Push only after all proof passes. From 0103f6ab324aaa537d88dec859a6d98274e50b16 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:08:32 -0700 Subject: [PATCH 04/43] test(codegen): pin generated package ownership contracts --- codegen/generated_types_test.go | 36 ++++++++++++ codegen/generator/generate_merge_test.go | 36 ++++++++++++ .../service_union_package_scope_test.go | 56 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 codegen/generated_types_test.go diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go new file mode 100644 index 0000000000..79cbb4903e --- /dev/null +++ b/codegen/generated_types_test.go @@ -0,0 +1,36 @@ +// This file verifies that one generated package owns the public names of every +// relocated declaration planned into it. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { + var first, second expr.UserType + root := RunDSL(t, func() { + first = dsl.Type("foo-bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("first", dsl.String) + }) + second = dsl.Type("foo_bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("second", dsl.String) + }) + }) + + generation := NewGeneration("generated.local/gen", []eval.Root{root}) + types := generation.GeneratedPackage("generated.local/gen/types") + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(second) + require.ErrorContains(t, err, "foo-bar") + require.ErrorContains(t, err, "foo_bar") + require.ErrorContains(t, err, "FooBar") +} diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index e4659adf66..34b73ea0d6 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -7,11 +7,47 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" goa "goa.design/goa/v3/pkg" ) +func TestMergeFilesPreservesSameLabelSections(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type First struct{}\n"}, + }, + }}, nil + }, + func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type Second struct{}\n"}, + }, + }}, nil + }, + }, nil + } + + dir := t.TempDir() + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "types", "same_label.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type First struct{}") + require.Contains(t, string(content), "type Second struct{}") +} + // TestGenerateMergesSamePathFiles verifies that when two generators emit content // targeting the same output path, Generate merges the sections into a single // file rather than overwriting earlier content. This is a regression test for diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index 6ff24e43e6..ab820d2c02 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -16,6 +16,62 @@ import ( "goa.design/goa/v3/expr" ) +func TestRelocatedUnionPackageNamesCompile(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{Service, Transport}, nil + } + + root := func() { + dsl.API("relocated union package names", func() {}) + + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Field(1, "text", dsl.String) + }) + dsl.Required("Value") + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Field(1, "number", dsl.Int) + }) + dsl.Required("Value") + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + }) + } + codegen.RunDSL(t, root) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { roots := []eval.Root{ codegen.RunDSL(t, unusedRelocatedValueRoot()), From 050ead6a8de51279b7140a621e7b251461ff91ee Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:09:23 -0700 Subject: [PATCH 05/43] docs(codegen): distinguish reproduced union failures --- .../plans/2026-08-20-generated-package-ownership.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 4e105607c1..c540466eed 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -32,7 +32,7 @@ **Interfaces:** - Consumes: current full generator, HTTP/gRPC DSL, and same-path file merging -- Produces: red tests for relocated nested-union references, relocated declared-name collisions, and same-label section preservation +- Produces: a positive transport preservation test plus red tests for relocated declared-name collisions and same-label section preservation - [ ] **Step 1: Add the real generated-module regression** @@ -41,7 +41,7 @@ same `struct:pkg:path` package and give each a different nested union whose natural name is `Value`. Enable HTTP and gRPC, generate the module, and run `go test ./...` inside it. -- [ ] **Step 2: Prove the nested-union regression fails for the intended reason** +- [ ] **Step 2: Prove the nested-union transport case remains valid** Run: @@ -49,8 +49,9 @@ Run: go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 ``` -Expected: FAIL with a generated Go reference to a union name different from the -declaration in the shared package. +Expected on the current branch: PASS. This test preserves the valid two-service +HTTP/gRPC case while the generation-owned catalog removes the independent name +allocation that could make later generators diverge. - [ ] **Step 3: Add the collision and merge regressions** From dac45fe72ead0a882e817dbba0447f34cb32f598 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:13:01 -0700 Subject: [PATCH 06/43] test(codegen): verify generated transport coverage --- codegen/generated_types_test.go | 2 ++ codegen/generator/generate_merge_test.go | 2 ++ .../generator/service_union_package_scope_test.go | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 79cbb4903e..4d47edebd1 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -12,6 +12,8 @@ import ( "goa.design/goa/v3/expr" ) +// TestGeneratedTypesRejectRelocatedNameCollision verifies that one generated +// package rejects distinct DSL names that produce the same exported Go name. func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { var first, second expr.UserType root := RunDSL(t, func() { diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index 34b73ea0d6..8846c3c7ec 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -14,6 +14,8 @@ import ( goa "goa.design/goa/v3/pkg" ) +// TestMergeFilesPreservesSameLabelSections verifies that diagnostic section +// labels do not cause the merger to discard different generated bodies. func TestMergeFilesPreservesSameLabelSections(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(_ string) ([]Genfunc, error) { diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index ab820d2c02..6f9c499af9 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -16,6 +16,8 @@ import ( "goa.design/goa/v3/expr" ) +// TestRelocatedUnionPackageNamesCompile verifies that two services and their +// HTTP and gRPC transports compile against distinct unions in one shared package. func TestRelocatedUnionPackageNamesCompile(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(_ string) ([]Genfunc, error) { @@ -69,6 +71,16 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { writeGeneratedModule(t, genDir, "gen") _, err := Generate(dir, "gen", false) require.NoError(t, err) + for _, path := range []string{ + filepath.Join("types", "first_input.go"), + filepath.Join("types", "second_input.go"), + filepath.Join("http", "first", "server", "server.go"), + filepath.Join("http", "second", "server", "server.go"), + filepath.Join("grpc", "first", "server", "server.go"), + filepath.Join("grpc", "second", "server", "server.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } runGeneratedTests(t, genDir) } From 055b842671a2cdbfe2302014276e8ee02115066c Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:15:48 -0700 Subject: [PATCH 07/43] docs(codegen): order catalog before identity refactor --- .../2026-08-20-generated-package-ownership.md | 127 +++++++++--------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index c540466eed..f9d9629e13 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -34,14 +34,14 @@ - Consumes: current full generator, HTTP/gRPC DSL, and same-path file merging - Produces: a positive transport preservation test plus red tests for relocated declared-name collisions and same-label section preservation -- [ ] **Step 1: Add the real generated-module regression** +- [x] **Step 1: Add the real generated-module regression** Use one evaluated design root with two services. Put distinct user types in the same `struct:pkg:path` package and give each a different nested union whose natural name is `Value`. Enable HTTP and gRPC, generate the module, and run `go test ./...` inside it. -- [ ] **Step 2: Prove the nested-union transport case remains valid** +- [x] **Step 2: Prove the nested-union transport case remains valid** Run: @@ -53,14 +53,14 @@ Expected on the current branch: PASS. This test preserves the valid two-service HTTP/gRPC case while the generation-owned catalog removes the independent name allocation that could make later generators diverge. -- [ ] **Step 3: Add the collision and merge regressions** +- [x] **Step 3: Add the collision and merge regressions** The collision test plans `foo-bar` and `foo_bar` into package `types` and expects an error naming both inputs and `FooBar`. The merge test contributes two different sections with the same `SectionTemplate.Name` and expects both rendered bodies to remain. -- [ ] **Step 4: Prove both contracts fail before implementation** +- [x] **Step 4: Prove both contracts fail before implementation** Run: @@ -71,61 +71,82 @@ go test ./codegen ./codegen/generator -run 'TestGeneratedTypesRejectRelocatedNam Expected: FAIL because the generation-owned type catalog does not exist and the merger drops the second same-label section. -### Task 2: Typed emitted-union identity +### Task 2: Generation-owned type catalog **Files:** -- Modify: `codegen/union.go` -- Modify: `codegen/scope.go` -- Modify: `codegen/scope_test.go` +- Create: `codegen/generation.go` +- Create: `codegen/generated_types.go` +- Modify: `codegen/generated_types_test.go` **Interfaces:** -- Consumes: `expr.Union`, `NameScope.HashedUnique(Hasher, string, ...string)` -- Produces: `type UnionTypeID string`, `func NewUnionTypeID(*expr.Union) UnionTypeID`, and generic `Hasher.Hash()` behavior +- Consumes: `[]eval.Root`, `codegen.NameScope`, and the existing `UnionTypeHash` +- Produces: `Generation`, generated-package records, collision errors, and immutable lookup after freeze -- [ ] **Step 1: Add a focused `HashedUnique` contract test** +- [ ] **Step 1: Extend the catalog contract tests** -Use a custom `Hasher` and assert that `HashedUnique` keys only on its exact -`Hash()` result. Keep the existing tests that distinguish emitted unions by -wire keys, branch order, branch Go shape, and relocated package. +Alongside the Task 1 collision test, cover user-type idempotency, union +idempotency, different same-base unions, lookup before and after freeze, +declaration after freeze rejection, and isolation between standalone +generations. -- [ ] **Step 2: Run the focused test and record the hidden special-case failure** +- [ ] **Step 2: Run the catalog tests and preserve the Task 1 RED evidence** Run: ```bash -go test ./codegen -run 'TestNameScope_HashedUnique|TestUnionTypeID' -count=1 +go test ./codegen -run 'TestGeneration|TestGeneratedPackage|TestGeneratedTypes' -count=1 ``` -- [ ] **Step 3: Introduce the typed identity and restore `HashedUnique`** +- [ ] **Step 3: Implement package records and freeze** Use this public contract: ```go -type UnionTypeID string +type Generation struct { + GenPkg string + Roots []eval.Root +} -func NewUnionTypeID(union *expr.Union) UnionTypeID +func NewGeneration(genpkg string, roots []eval.Root) *Generation +func (g *Generation) GeneratedPackage(path string) *GeneratedPackage +func (g *Generation) Freeze() error + +type GeneratedPackage struct{} + +func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) +func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) +func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) +func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) +func (p *GeneratedPackage) Scope() *NameScope + +type TypeDeclaration struct { + Name string + PackagePath string +} ``` -`NewUnionTypeID` contains the current emitted-definition hashing algorithm. -`NameScope.HashedUnique` calls `key.Hash()` directly. `GoFullTypeName` may look -up an already planned union through an explicit emitted-union key, but the -generic scope API must not reinterpret arbitrary hashers. +Declaration methods allocate only before freeze. Lookup methods never allocate. +User types reserve the exact `Goify(Name(), true)` name and report collisions; +unions temporarily use the existing emitted-definition hash until Task 3 gives +that identity a distinct type. -- [ ] **Step 4: Run all scope and union tests** +- [ ] **Step 4: Run the catalog tests green** Run: ```bash -go test ./codegen -run 'TestNameScope|TestUnionType' -count=1 +go test ./codegen -run 'TestGeneration|TestGeneratedPackage|TestGeneratedTypes' -count=1 ``` Expected: PASS. -### Task 3: Generation plan, freeze, and render contract +### Task 3: Typed union identity and generation lifecycle **Files:** -- Create: `codegen/generation.go` -- Create: `codegen/generated_types.go` +- Modify: `codegen/union.go` +- Modify: `codegen/scope.go` +- Modify: `codegen/scope_test.go` +- Modify: `codegen/generated_types.go` - Modify: `codegen/plugin.go` - Modify: `codegen/plugin_test.go` - Modify: `codegen/generator/generators.go` @@ -133,47 +154,29 @@ Expected: PASS. - Create: `codegen/generator/generation_test.go` **Interfaces:** -- Consumes: normalized `[]eval.Root`, `codegen.NameScope`, `codegen.UnionTypeID` -- Produces: `Generation`, generated-package records, plan-aware core generator and plugin APIs +- Consumes: Task 2 `Generation` and package records, `expr.Union`, `NameScope.HashedUnique` +- Produces: `UnionTypeID`, generic `Hasher.Hash()` behavior, and plan-aware core generator and plugin APIs -- [ ] **Step 1: Add phase and declaration tests** +- [ ] **Step 1: Add union identity and lifecycle tests** -Test user-type idempotency, union idempotency, different same-base unions, -exact relocated-name rejection, lookup before and after freeze, declaration -after freeze rejection, and isolation between two standalone generations. +Use a custom `Hasher` to prove `HashedUnique` keys only on its exact `Hash()`. +Keep emitted-union distinctions for wire keys, branch order, branch Go shape, +and relocated package. Add generator/plugin tests that record plan, freeze, and +render order and reject a render-time declaration. -- [ ] **Step 2: Implement generation-owned package records** +- [ ] **Step 2: Introduce the typed emitted-union identity** -Use these public contracts: +Use this public contract: ```go -type Generation struct { - GenPkg string - Roots []eval.Root -} - -func NewGeneration(genpkg string, roots []eval.Root) *Generation -func (g *Generation) GeneratedPackage(path string) *GeneratedPackage -func (g *Generation) Freeze() error - -type GeneratedPackage struct{} - -func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) -func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) -func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) -func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) -func (p *GeneratedPackage) Scope() *NameScope +type UnionTypeID string -type TypeDeclaration struct { - Name string - PackagePath string -} +func NewUnionTypeID(union *expr.Union) UnionTypeID ``` -Declaration methods allocate only during planning. Lookup methods never -allocate. `Freeze` makes every package immutable. User types reserve the exact -`Goify(Name(), true)` name and report collisions; unions allocate through their -typed emitted identity. +Move the emitted-definition algorithm behind `NewUnionTypeID`, update Task 2's +package records to key unions by it, and restore `HashedUnique` to direct +`key.Hash()` behavior. `expr.Union.Hash()` remains unchanged. - [ ] **Step 3: Change core and plugin lifecycle APIs** @@ -194,12 +197,12 @@ prepare, plan, and generate functions. `Generate` runs prepare, normalization, every core/plugin plan, `Freeze`, every core render, then every plugin render. No render callback may declare a new type. -- [ ] **Step 4: Run lifecycle tests** +- [ ] **Step 4: Run identity and lifecycle tests** Run: ```bash -go test ./codegen ./codegen/generator -run 'TestGeneration|TestGeneratedPackage|TestRegisterPlugin|TestGeneratePhases' -count=1 +go test ./codegen ./codegen/generator -run 'TestNameScope|TestUnionType|TestRegisterPlugin|TestGeneratePhases' -count=1 ``` Expected: PASS. From 15f86ce758bff9b560af0892c98f74de37123cae Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:21:10 -0700 Subject: [PATCH 08/43] feat(codegen): add generation-owned type catalog --- codegen/generated_types.go | 133 ++++++++++++++++++++++++++++++ codegen/generated_types_test.go | 139 ++++++++++++++++++++++++++++++++ codegen/generation.go | 60 ++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 codegen/generated_types.go create mode 100644 codegen/generation.go diff --git a/codegen/generated_types.go b/codegen/generated_types.go new file mode 100644 index 0000000000..2709a0f2da --- /dev/null +++ b/codegen/generated_types.go @@ -0,0 +1,133 @@ +// This file defines the declaration catalog owned by one generated Go +// package. Planning reserves every public type name here before generators use +// the frozen records to render declarations and references. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/expr" +) + +type ( + // GeneratedPackage owns type declarations and their shared naming scope for + // one generated Go package. + GeneratedPackage struct { + path string + scope *NameScope + userTypes map[expr.UserType]*TypeDeclaration + unions map[string]*TypeDeclaration + declarations map[string]declarationOwner + frozen bool + } + + // TypeDeclaration records the canonical name and package path of one + // generated type declaration. + TypeDeclaration struct { + // Name is the unqualified Go declaration name. + Name string + // PackagePath is the import path of the package that owns the declaration. + PackagePath string + } + + // declarationOwner describes the design expression that reserved a public + // name so collision errors can identify both declarations. + declarationOwner struct { + kind string + name string + } +) + +// DeclareUserType reserves userType's exact exported Go name and returns its +// canonical package declaration. Repeated calls return the same declaration. +func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + if declaration, ok := p.userTypes[userType]; ok { + return declaration, nil + } + + name := Goify(userType.Name(), true) + if owner, ok := p.declarations[name]; ok { + return nil, fmt.Errorf( + "generated package %q cannot declare user type %q as %q: already declared by %s %q", + p.path, + userType.Name(), + name, + owner.kind, + owner.name, + ) + } + if p.scope.PeekUnique(name) != name { + return nil, fmt.Errorf( + "generated package %q cannot declare user type %q as %q: name is already reserved", + p.path, + userType.Name(), + name, + ) + } + p.scope.HashedUnique(userType, name, "") + declaration := &TypeDeclaration{Name: name, PackagePath: p.path} + p.userTypes[userType] = declaration + p.declarations[name] = declarationOwner{kind: "user type", name: userType.Name()} + return declaration, nil +} + +// DeclareUnion reserves a canonical name for union's emitted definition and +// returns the same declaration for unions with the same emitted identity. +func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + identity := UnionTypeHash(union) + if declaration, ok := p.unions[identity]; ok { + return declaration, nil + } + + name := p.scope.HashedUnique(union, Goify(union.Name(), true), "") + declaration := &TypeDeclaration{Name: name, PackagePath: p.path} + p.unions[identity] = declaration + p.declarations[name] = declarationOwner{kind: "union", name: union.Name()} + return declaration, nil +} + +// UserType returns userType's existing package declaration without allocating +// a name or declaration record. +func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.userTypes[userType]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q is not declared in generated package %q", userType.Name(), p.path) +} + +// Union returns union's existing package declaration without allocating a +// name or declaration record. +func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) { + if declaration, ok := p.unions[UnionTypeHash(union)]; ok { + return declaration, nil + } + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) +} + +// Scope returns the package-owned name scope shared by declaration planning +// and generated references. +func (p *GeneratedPackage) Scope() *NameScope { + return p.scope +} + +// newGeneratedPackage creates an empty mutable declaration catalog for path. +func newGeneratedPackage(path string) *GeneratedPackage { + return &GeneratedPackage{ + path: path, + scope: NewNameScope(), + userTypes: make(map[expr.UserType]*TypeDeclaration), + unions: make(map[string]*TypeDeclaration), + declarations: make(map[string]declarationOwner), + } +} + +// freeze ends declaration planning while preserving read-only lookups. +func (p *GeneratedPackage) freeze() { + p.frozen = true +} diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 4d47edebd1..770535717c 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -36,3 +36,142 @@ func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { require.ErrorContains(t, err, "foo_bar") require.ErrorContains(t, err, "FooBar") } + +// TestGenerationOwnsPackageRecords verifies that one generation returns one +// stable package record and scope for each output path. +func TestGenerationOwnsPackageRecords(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + + first := generation.GeneratedPackage("generated.local/gen/types") + second := generation.GeneratedPackage("generated.local/gen/types") + other := generation.GeneratedPackage("generated.local/gen/other") + require.Same(t, first, second) + require.Same(t, first.Scope(), second.Scope()) + require.NotSame(t, first, other) + require.NotSame(t, first.Scope(), other.Scope()) +} + +// TestGeneratedPackageUserTypes verifies that a generated package records one +// declaration per user type and that lookups do not reserve names. +func TestGeneratedPackageUserTypes(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + missing := generatedUserType("Missing", "missing") + + _, err := types.UserType(missing) + require.ErrorContains(t, err, "not declared") + + first, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Equal(t, &TypeDeclaration{ + Name: "Widget", + PackagePath: "generated.local/gen/types", + }, first) + second, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Same(t, first, second) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) + + lookedUp, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, first, lookedUp) + declaredMissing, err := types.DeclareUserType(missing) + require.NoError(t, err) + require.Equal(t, "Missing", declaredMissing.Name) +} + +// TestGeneratedPackageUnions verifies that emitted-definition identity makes +// equivalent unions idempotent while different unions with the same base name +// receive distinct declarations. +func TestGeneratedPackageUnions(t *testing.T) { + types := NewGeneration("generated.local/gen", nil). + GeneratedPackage("generated.local/gen/types") + first := generatedUnion("Value", "type", "value") + equivalent := generatedUnion("Value", "type", "value") + different := generatedUnion("Value", "kind", "data") + + firstDeclaration, err := types.DeclareUnion(first) + require.NoError(t, err) + require.Equal(t, &TypeDeclaration{ + Name: "Value", + PackagePath: "generated.local/gen/types", + }, firstDeclaration) + equivalentDeclaration, err := types.DeclareUnion(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, equivalentDeclaration) + + differentDeclaration, err := types.DeclareUnion(different) + require.NoError(t, err) + require.Equal(t, "Value2", differentDeclaration.Name) + require.NotSame(t, firstDeclaration, differentDeclaration) + + lookedUp, err := types.Union(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) +} + +// TestGeneratedPackageLookupAcrossFreeze verifies that freeze keeps existing +// declarations readable and rejects every later declaration attempt. +func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + union := generatedUnion("Value", "type", "value") + userDeclaration, err := types.DeclareUserType(widget) + require.NoError(t, err) + unionDeclaration, err := types.DeclareUnion(union) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + lookedUpUser, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, userDeclaration, lookedUpUser) + lookedUpUnion, err := types.Union(union) + require.NoError(t, err) + require.Same(t, unionDeclaration, lookedUpUnion) + + _, err = types.DeclareUserType(widget) + require.ErrorContains(t, err, "frozen") + _, err = types.DeclareUnion(union) + require.ErrorContains(t, err, "frozen") +} + +// TestGenerationCatalogsAreIsolated verifies that standalone generation runs +// do not share declaration records or name reservations. +func TestGenerationCatalogsAreIsolated(t *testing.T) { + first := NewGeneration("generated.local/gen", nil). + GeneratedPackage("generated.local/gen/types") + second := NewGeneration("generated.local/gen", nil). + GeneratedPackage("generated.local/gen/types") + firstUnion := generatedUnion("Value", "type", "value") + secondUnion := generatedUnion("Value", "type", "value") + + firstDeclaration, err := first.DeclareUnion(firstUnion) + require.NoError(t, err) + secondDeclaration, err := second.DeclareUnion(secondUnion) + require.NoError(t, err) + require.Equal(t, "Value", firstDeclaration.Name) + require.Equal(t, "Value", secondDeclaration.Name) + require.NotSame(t, firstDeclaration, secondDeclaration) + require.NotSame(t, first.Scope(), second.Scope()) +} + +// generatedUserType builds a distinct user type for catalog tests. +func generatedUserType(name, id string) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: name, + UID: id, + } +} + +// generatedUnion builds a union whose emitted identity includes the supplied +// JSON envelope keys. +func generatedUnion(name, typeKey, valueKey string) *expr.Union { + return &expr.Union{ + TypeName: name, + TypeKey: typeKey, + ValueKey: valueKey, + } +} diff --git a/codegen/generation.go b/codegen/generation.go new file mode 100644 index 0000000000..3b029da4f3 --- /dev/null +++ b/codegen/generation.go @@ -0,0 +1,60 @@ +// This file defines the state shared by every generator contributing files to +// one generation run. The generation creates one naming catalog per output Go +// package and freezes all catalogs before rendering begins. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/eval" +) + +type ( + // Generation owns the evaluated design roots and generated-package naming + // catalogs for one standalone code generation run. + Generation struct { + // GenPkg is the import path of the generated module root. + GenPkg string + // Roots contains the evaluated DSL roots participating in the run. + Roots []eval.Root + + packages map[string]*GeneratedPackage + frozen bool + } +) + +// NewGeneration creates an independent generation catalog for roots. +func NewGeneration(genpkg string, roots []eval.Root) *Generation { + return &Generation{ + GenPkg: genpkg, + Roots: append([]eval.Root(nil), roots...), + packages: make(map[string]*GeneratedPackage), + } +} + +// GeneratedPackage returns the naming catalog for path, creating it before +// the generation is frozen. It panics if path was not planned before freeze. +func (g *Generation) GeneratedPackage(path string) *GeneratedPackage { + if generatedPackage, ok := g.packages[path]; ok { + return generatedPackage + } + if g.frozen { + panic(fmt.Sprintf("generated package %q requested after generation freeze", path)) + } + generatedPackage := newGeneratedPackage(path) + g.packages[path] = generatedPackage + return generatedPackage +} + +// Freeze prevents every generated package in the generation from accepting +// more declarations. Existing declarations remain available through lookup. +func (g *Generation) Freeze() error { + if g.frozen { + return nil + } + for _, generatedPackage := range g.packages { + generatedPackage.freeze() + } + g.frozen = true + return nil +} From 7353f34b8fe10d7e1330cb09136e5c4e14969dbd Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:30:28 -0700 Subject: [PATCH 09/43] fix(codegen): freeze generated package names --- codegen/generated_types.go | 81 +++++++++++++++++++-------------- codegen/generated_types_test.go | 78 +++++++++++++++++++++++++++---- codegen/generation.go | 5 +- codegen/scope.go | 13 ++++++ codegen/scope_test.go | 34 ++++++++++++++ 5 files changed, 168 insertions(+), 43 deletions(-) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 2709a0f2da..bcecfe1aea 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -5,6 +5,7 @@ package codegen import ( "fmt" + "sort" "goa.design/goa/v3/expr" ) @@ -13,28 +14,29 @@ type ( // GeneratedPackage owns type declarations and their shared naming scope for // one generated Go package. GeneratedPackage struct { - path string - scope *NameScope - userTypes map[expr.UserType]*TypeDeclaration - unions map[string]*TypeDeclaration - declarations map[string]declarationOwner - frozen bool + path string + scope *NameScope + userTypes map[expr.UserType]*TypeDeclaration + unions map[string]*unionDeclaration + userTypeNames map[string]string + frozen bool } // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { - // Name is the unqualified Go declaration name. + // Name is the unqualified Go declaration name. Union declarations keep + // Name empty until the owning generation is frozen. Name string // PackagePath is the import path of the package that owns the declaration. PackagePath string } - // declarationOwner describes the design expression that reserved a public - // name so collision errors can identify both declarations. - declarationOwner struct { - kind string - name string + // unionDeclaration retains the expression needed to allocate the public + // union name deterministically when the generation freezes. + unionDeclaration struct { + union *expr.Union + declaration *TypeDeclaration } ) @@ -49,14 +51,13 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara } name := Goify(userType.Name(), true) - if owner, ok := p.declarations[name]; ok { + if declaredName, ok := p.userTypeNames[name]; ok { return nil, fmt.Errorf( - "generated package %q cannot declare user type %q as %q: already declared by %s %q", + "generated package %q cannot declare user type %q as %q: already declared by user type %q", p.path, userType.Name(), name, - owner.kind, - owner.name, + declaredName, ) } if p.scope.PeekUnique(name) != name { @@ -70,25 +71,27 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara p.scope.HashedUnique(userType, name, "") declaration := &TypeDeclaration{Name: name, PackagePath: p.path} p.userTypes[userType] = declaration - p.declarations[name] = declarationOwner{kind: "user type", name: userType.Name()} + p.userTypeNames[name] = userType.Name() return declaration, nil } -// DeclareUnion reserves a canonical name for union's emitted definition and -// returns the same declaration for unions with the same emitted identity. +// DeclareUnion records union's emitted definition and returns the same +// declaration for unions with the same emitted identity. The declaration name +// remains empty until the owning generation freezes its package catalogs. func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } identity := UnionTypeHash(union) - if declaration, ok := p.unions[identity]; ok { - return declaration, nil + if planned, ok := p.unions[identity]; ok { + return planned.declaration, nil } - name := p.scope.HashedUnique(union, Goify(union.Name(), true), "") - declaration := &TypeDeclaration{Name: name, PackagePath: p.path} - p.unions[identity] = declaration - p.declarations[name] = declarationOwner{kind: "union", name: union.Name()} + declaration := &TypeDeclaration{PackagePath: p.path} + p.unions[identity] = &unionDeclaration{ + union: union, + declaration: declaration, + } return declaration, nil } @@ -104,8 +107,8 @@ func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, e // Union returns union's existing package declaration without allocating a // name or declaration record. func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) { - if declaration, ok := p.unions[UnionTypeHash(union)]; ok { - return declaration, nil + if planned, ok := p.unions[UnionTypeHash(union)]; ok { + return planned.declaration, nil } return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) } @@ -119,15 +122,27 @@ func (p *GeneratedPackage) Scope() *NameScope { // newGeneratedPackage creates an empty mutable declaration catalog for path. func newGeneratedPackage(path string) *GeneratedPackage { return &GeneratedPackage{ - path: path, - scope: NewNameScope(), - userTypes: make(map[expr.UserType]*TypeDeclaration), - unions: make(map[string]*TypeDeclaration), - declarations: make(map[string]declarationOwner), + path: path, + scope: NewNameScope(), + userTypes: make(map[expr.UserType]*TypeDeclaration), + unions: make(map[string]*unionDeclaration), + userTypeNames: make(map[string]string), } } -// freeze ends declaration planning while preserving read-only lookups. +// freeze assigns pending union names in structural-identity order, then ends +// declaration and scope mutation while preserving read-only lookups. func (p *GeneratedPackage) freeze() { + identities := make([]string, 0, len(p.unions)) + for identity := range p.unions { + identities = append(identities, identity) + } + sort.Strings(identities) + for _, identity := range identities { + planned := p.unions[identity] + name := p.scope.HashedUnique(planned.union, Goify(planned.union.Name(), true), "") + planned.declaration.Name = name + } + p.scope.Freeze() p.frozen = true } diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 770535717c..5ea9472545 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -3,6 +3,7 @@ package codegen import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -85,8 +86,8 @@ func TestGeneratedPackageUserTypes(t *testing.T) { // equivalent unions idempotent while different unions with the same base name // receive distinct declarations. func TestGeneratedPackageUnions(t *testing.T) { - types := NewGeneration("generated.local/gen", nil). - GeneratedPackage("generated.local/gen/types") + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") first := generatedUnion("Value", "type", "value") equivalent := generatedUnion("Value", "type", "value") different := generatedUnion("Value", "kind", "data") @@ -94,7 +95,6 @@ func TestGeneratedPackageUnions(t *testing.T) { firstDeclaration, err := types.DeclareUnion(first) require.NoError(t, err) require.Equal(t, &TypeDeclaration{ - Name: "Value", PackagePath: "generated.local/gen/types", }, firstDeclaration) equivalentDeclaration, err := types.DeclareUnion(equivalent) @@ -103,12 +103,62 @@ func TestGeneratedPackageUnions(t *testing.T) { differentDeclaration, err := types.DeclareUnion(different) require.NoError(t, err) - require.Equal(t, "Value2", differentDeclaration.Name) + require.Empty(t, differentDeclaration.Name) require.NotSame(t, firstDeclaration, differentDeclaration) lookedUp, err := types.Union(equivalent) require.NoError(t, err) require.Same(t, firstDeclaration, lookedUp) + require.NoError(t, generation.Freeze()) + require.ElementsMatch(t, []string{"Value", "Value2"}, []string{ + firstDeclaration.Name, + differentDeclaration.Name, + }) + + reversedGeneration := NewGeneration("generated.local/gen", nil) + reversedTypes := reversedGeneration.GeneratedPackage("generated.local/gen/types") + reversedDifferent, err := reversedTypes.DeclareUnion(generatedUnion("Value", "kind", "data")) + require.NoError(t, err) + reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("Value", "type", "value")) + require.NoError(t, err) + require.NoError(t, reversedGeneration.Freeze()) + require.Equal(t, firstDeclaration.Name, reversedFirst.Name) + require.Equal(t, differentDeclaration.Name, reversedDifferent.Name) +} + +// TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder verifies that +// pending unions cannot take an exact user-type name based on traversal order. +func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { + for _, unionFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("union first %t", unionFirst), func(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + userType := generatedUserType("Value", "value") + union := generatedUnion("Value", "type", "value") + var ( + userDeclaration *TypeDeclaration + unionDeclaration *TypeDeclaration + err error + ) + if unionFirst { + unionDeclaration, err = types.DeclareUnion(union) + require.NoError(t, err) + userDeclaration, err = types.DeclareUserType(userType) + require.NoError(t, err) + } else { + userDeclaration, err = types.DeclareUserType(userType) + require.NoError(t, err) + unionDeclaration, err = types.DeclareUnion(union) + require.NoError(t, err) + } + + require.Equal(t, "Value", userDeclaration.Name) + require.Empty(t, unionDeclaration.Name) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Value", userDeclaration.Name) + require.Equal(t, "Value2", unionDeclaration.Name) + }) + } } // TestGeneratedPackageLookupAcrossFreeze verifies that freeze keeps existing @@ -122,6 +172,7 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { require.NoError(t, err) unionDeclaration, err := types.DeclareUnion(union) require.NoError(t, err) + require.Empty(t, unionDeclaration.Name) require.NoError(t, generation.Freeze()) lookedUpUser, err := types.UserType(widget) @@ -130,6 +181,15 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { lookedUpUnion, err := types.Union(union) require.NoError(t, err) require.Same(t, unionDeclaration, lookedUpUnion) + require.Equal(t, "Value", lookedUpUnion.Name) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) + require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) + require.Panics(t, func() { + types.Scope().Unique("Late") + }) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: generatedUserType("Late", "late")}) + }) _, err = types.DeclareUserType(widget) require.ErrorContains(t, err, "frozen") @@ -140,10 +200,10 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { // TestGenerationCatalogsAreIsolated verifies that standalone generation runs // do not share declaration records or name reservations. func TestGenerationCatalogsAreIsolated(t *testing.T) { - first := NewGeneration("generated.local/gen", nil). - GeneratedPackage("generated.local/gen/types") - second := NewGeneration("generated.local/gen", nil). - GeneratedPackage("generated.local/gen/types") + firstGeneration := NewGeneration("generated.local/gen", nil) + first := firstGeneration.GeneratedPackage("generated.local/gen/types") + secondGeneration := NewGeneration("generated.local/gen", nil) + second := secondGeneration.GeneratedPackage("generated.local/gen/types") firstUnion := generatedUnion("Value", "type", "value") secondUnion := generatedUnion("Value", "type", "value") @@ -151,6 +211,8 @@ func TestGenerationCatalogsAreIsolated(t *testing.T) { require.NoError(t, err) secondDeclaration, err := second.DeclareUnion(secondUnion) require.NoError(t, err) + require.NoError(t, firstGeneration.Freeze()) + require.NoError(t, secondGeneration.Freeze()) require.Equal(t, "Value", firstDeclaration.Name) require.Equal(t, "Value", secondDeclaration.Name) require.NotSame(t, firstDeclaration, secondDeclaration) diff --git a/codegen/generation.go b/codegen/generation.go index 3b029da4f3..b4a561c4ef 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -46,8 +46,9 @@ func (g *Generation) GeneratedPackage(path string) *GeneratedPackage { return generatedPackage } -// Freeze prevents every generated package in the generation from accepting -// more declarations. Existing declarations remain available through lookup. +// Freeze assigns deterministic names to pending unions, then prevents every +// generated package and its name scope from accepting more declarations or +// name reservations. Existing declarations remain available through lookup. func (g *Generation) Freeze() error { if g.frozen { return nil diff --git a/codegen/scope.go b/codegen/scope.go index 225bbec573..c391c3a66e 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -14,6 +14,7 @@ type ( NameScope struct { names map[string]string // type hash to unique name counts map[string]int // raw type name to occurrence count + frozen bool // whether new names may be reserved } // Hasher is the interface implemented by the objects that must be @@ -46,6 +47,9 @@ func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) stri if n, ok := s.names[hash]; ok { return n } + if s.frozen { + panic("cannot reserve a new hashed name in a frozen name scope") + } name = s.Unique(name, suffix...) s.names[hash] = name return name @@ -56,11 +60,20 @@ func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) stri // counter value is added to the suffixed name until unique. The returned name // is reserved in the scope. func (s *NameScope) Unique(name string, suffix ...string) string { + if s.frozen { + panic("cannot reserve a name in a frozen name scope") + } ret := s.PeekUnique(name, suffix...) s.counts[ret]++ return ret } +// Freeze prevents the scope from reserving new names. Names already associated +// with hashes remain readable through HashedUnique and type-reference methods. +func (s *NameScope) Freeze() { + s.frozen = true +} + // PeekUnique returns the name that Unique would return for the same inputs, // without mutating the scope. // diff --git a/codegen/scope_test.go b/codegen/scope_test.go index 393d5ad65c..722c899ae2 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -4,10 +4,44 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" ) +func TestNameScope_Freeze(t *testing.T) { + scope := NewNameScope() + existing := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Existing", + UID: "existing", + } + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + + scope.Freeze() + scope.Freeze() + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + require.Equal(t, "Next", scope.PeekUnique("Next")) + require.Equal(t, "Next", scope.Name("Next")) + require.Panics(t, func() { + scope.Unique("Next") + }) + require.Panics(t, func() { + scope.HashedUnique(&expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Next", + UID: "next", + }, "Next") + }) + require.Panics(t, func() { + scope.GoTypeName(&expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Indirect", + UID: "indirect", + }}) + }) +} + func TestNameScope_Unique(t *testing.T) { sequence := []struct { Input string From 8bda1ae411018fe8ed615ecf6d966c173df35914 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:34:50 -0700 Subject: [PATCH 10/43] fix(codegen): hide package scope until freeze --- codegen/generated_types.go | 7 +++++-- codegen/generated_types_test.go | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index bcecfe1aea..451beffb9b 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -113,9 +113,12 @@ func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) { return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) } -// Scope returns the package-owned name scope shared by declaration planning -// and generated references. +// Scope returns the frozen package-owned name scope used to render generated +// references. It panics before declaration planning has been frozen. func (p *GeneratedPackage) Scope() *NameScope { + if !p.frozen { + panic(fmt.Sprintf("generated package %q scope requested before freeze", p.path)) + } return p.scope } diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 5ea9472545..18ad354dae 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -47,6 +47,10 @@ func TestGenerationOwnsPackageRecords(t *testing.T) { second := generation.GeneratedPackage("generated.local/gen/types") other := generation.GeneratedPackage("generated.local/gen/other") require.Same(t, first, second) + require.Panics(t, func() { + first.Scope() + }) + require.NoError(t, generation.Freeze()) require.Same(t, first.Scope(), second.Scope()) require.NotSame(t, first, other) require.NotSame(t, first.Scope(), other.Scope()) @@ -72,7 +76,6 @@ func TestGeneratedPackageUserTypes(t *testing.T) { second, err := types.DeclareUserType(widget) require.NoError(t, err) require.Same(t, first, second) - require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) lookedUp, err := types.UserType(widget) require.NoError(t, err) @@ -80,6 +83,8 @@ func TestGeneratedPackageUserTypes(t *testing.T) { declaredMissing, err := types.DeclareUserType(missing) require.NoError(t, err) require.Equal(t, "Missing", declaredMissing.Name) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) } // TestGeneratedPackageUnions verifies that emitted-definition identity makes @@ -143,11 +148,17 @@ func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { if unionFirst { unionDeclaration, err = types.DeclareUnion(union) require.NoError(t, err) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: union}) + }) userDeclaration, err = types.DeclareUserType(userType) require.NoError(t, err) } else { userDeclaration, err = types.DeclareUserType(userType) require.NoError(t, err) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType}) + }) unionDeclaration, err = types.DeclareUnion(union) require.NoError(t, err) } @@ -157,6 +168,8 @@ func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { require.NoError(t, generation.Freeze()) require.Equal(t, "Value", userDeclaration.Name) require.Equal(t, "Value2", unionDeclaration.Name) + require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType})) + require.Equal(t, "Value2", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) }) } } From bec606037424a05f3df1c91d7fa617d6046f3201 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:36:41 -0700 Subject: [PATCH 11/43] docs(codegen): record frozen catalog contract --- .../plans/2026-08-20-generated-package-ownership.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index f9d9629e13..960e011b1e 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -82,14 +82,14 @@ merger drops the second same-label section. - Consumes: `[]eval.Root`, `codegen.NameScope`, and the existing `UnionTypeHash` - Produces: `Generation`, generated-package records, collision errors, and immutable lookup after freeze -- [ ] **Step 1: Extend the catalog contract tests** +- [x] **Step 1: Extend the catalog contract tests** Alongside the Task 1 collision test, cover user-type idempotency, union idempotency, different same-base unions, lookup before and after freeze, declaration after freeze rejection, and isolation between standalone generations. -- [ ] **Step 2: Run the catalog tests and preserve the Task 1 RED evidence** +- [x] **Step 2: Run the catalog tests and preserve the Task 1 RED evidence** Run: @@ -97,7 +97,7 @@ Run: go test ./codegen -run 'TestGeneration|TestGeneratedPackage|TestGeneratedTypes' -count=1 ``` -- [ ] **Step 3: Implement package records and freeze** +- [x] **Step 3: Implement package records and freeze** Use this public contract: @@ -128,9 +128,11 @@ type TypeDeclaration struct { Declaration methods allocate only before freeze. Lookup methods never allocate. User types reserve the exact `Goify(Name(), true)` name and report collisions; unions temporarily use the existing emitted-definition hash until Task 3 gives -that identity a distinct type. +that identity a distinct type. `GeneratedPackage.Scope()` is available only +after freeze and returns the already-frozen scope; planning uses declaration +methods instead of direct name reservations. -- [ ] **Step 4: Run the catalog tests green** +- [x] **Step 4: Run the catalog tests green** Run: From 4957ddef28c00683578544654f32dc99ba1a70f3 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:48:34 -0700 Subject: [PATCH 12/43] feat(codegen): add generation planning lifecycle --- codegen/generated_types.go | 16 +-- codegen/generator/generate.go | 52 +++++--- ...erate_http_union_shape_integration_test.go | 2 +- codegen/generator/generate_merge_test.go | 40 +++--- .../generate_union_merge_integration_test.go | 4 +- codegen/generator/generation_test.go | 113 +++++++++++++++++ codegen/generator/generators.go | 32 +++-- codegen/generator/purity_test.go | 9 +- .../service_union_package_scope_test.go | 2 +- codegen/plugin.go | 44 ++++--- codegen/plugin_test.go | 53 +++++++- codegen/scope.go | 30 +++-- codegen/scope_test.go | 84 ++++++++++++- codegen/union.go | 116 ++++++++++-------- 14 files changed, 458 insertions(+), 139 deletions(-) create mode 100644 codegen/generator/generation_test.go diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 451beffb9b..6059f6e881 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -5,7 +5,7 @@ package codegen import ( "fmt" - "sort" + "slices" "goa.design/goa/v3/expr" ) @@ -17,7 +17,7 @@ type ( path string scope *NameScope userTypes map[expr.UserType]*TypeDeclaration - unions map[string]*unionDeclaration + unions map[UnionTypeID]*unionDeclaration userTypeNames map[string]string frozen bool } @@ -82,7 +82,7 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, er if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } - identity := UnionTypeHash(union) + identity := NewUnionTypeID(union) if planned, ok := p.unions[identity]; ok { return planned.declaration, nil } @@ -107,7 +107,7 @@ func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, e // Union returns union's existing package declaration without allocating a // name or declaration record. func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) { - if planned, ok := p.unions[UnionTypeHash(union)]; ok { + if planned, ok := p.unions[NewUnionTypeID(union)]; ok { return planned.declaration, nil } return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) @@ -128,7 +128,7 @@ func newGeneratedPackage(path string) *GeneratedPackage { path: path, scope: NewNameScope(), userTypes: make(map[expr.UserType]*TypeDeclaration), - unions: make(map[string]*unionDeclaration), + unions: make(map[UnionTypeID]*unionDeclaration), userTypeNames: make(map[string]string), } } @@ -136,14 +136,14 @@ func newGeneratedPackage(path string) *GeneratedPackage { // freeze assigns pending union names in structural-identity order, then ends // declaration and scope mutation while preserving read-only lookups. func (p *GeneratedPackage) freeze() { - identities := make([]string, 0, len(p.unions)) + identities := make([]UnionTypeID, 0, len(p.unions)) for identity := range p.unions { identities = append(identities, identity) } - sort.Strings(identities) + slices.Sort(identities) for _, identity := range identities { planned := p.unions[identity] - name := p.scope.HashedUnique(planned.union, Goify(planned.union.Name(), true), "") + name := p.scope.HashedUnique(identity, Goify(planned.union.Name(), true), "") planned.declaration.Name = name } p.scope.Freeze() diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 1d34d30a5b..4d074a6a32 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -122,14 +122,38 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } } - // 5. Generate initial set of files produced by goa code generators. + // 5. Create one generation context, plan every core and plugin declaration, + // then freeze all generated package names before rendering begins. + generation := codegen.NewGeneration(genpkg, roots) + { + start := time.Now() + for _, gen := range genfuncs { + if gen.Plan == nil { + continue + } + if err := gen.Plan(generation); err != nil { + return nil, err + } + } + if err := codegen.RunPluginsPlan(cmd, generation); err != nil { + return nil, err + } + if err := generation.Freeze(); err != nil { + return nil, err + } + if debug { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 5: Plan and freeze declarations took %v\n", time.Since(start)) + } + } + + // 6. Generate the initial files produced by the core generators. // NOTE: Parallelization causes infinite recursion in AsObject() for circular type references var genfiles []*codegen.File { start := time.Now() for i, gen := range genfuncs { genStart := time.Now() - fs, err := gen(genpkg, roots) + fs, err := gen.Generate(generation) if err != nil { return nil, err } @@ -139,45 +163,45 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 5: Generate initial files took %v (total %d files)\n", time.Since(start), len(genfiles)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 6: Generate initial files took %v (total %d files)\n", time.Since(start), len(genfiles)) } } - // 6. Run the code generation plugins. + // 7. Run the code generation plugins with the same frozen generation. { start := time.Now() var err error - genfiles, err = codegen.RunPlugins(cmd, genpkg, roots, genfiles) + genfiles, err = codegen.RunPlugins(cmd, generation, genfiles) if err != nil { return nil, err } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 6: Run post-generation plugins took %v (now %d files)\n", time.Since(start), len(genfiles)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 7: Run post-generation plugins took %v (now %d files)\n", time.Since(start), len(genfiles)) } } - // 7. Merge files that target the same path to avoid overwriting content when + // 8. Merge files that target the same path to avoid overwriting content when // multiple generators (or services) emit sections for the same file. { start := time.Now() genfiles = mergeFilesByPath(genfiles) if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 7: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 8: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) } } - // 8. Emit goa.json version file (gen command only). + // 9. Emit goa.json version file (gen command only). if cmd == "gen" { genfiles = append(genfiles, codegen.VersionFile()) } - // 9. Write the files (in parallel). + // 10. Write the files (in parallel). written := make(map[string]struct{}) { start := time.Now() numWorkers := runtime.NumCPU() if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Starting parallel file writing with %d workers\n", numWorkers) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Starting parallel file writing with %d workers\n", numWorkers) } // Channel for work items @@ -249,11 +273,11 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) } } - // 10. Compute all output filenames. + // 11. Compute all output filenames. { start := time.Now() outputs = make([]string, len(written)) @@ -271,7 +295,7 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { i++ } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Compute output filenames took %v\n", time.Since(start)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 11: Compute output filenames took %v\n", time.Since(start)) } } sort.Strings(outputs) diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index 2deee2b310..a3710e2d5c 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -18,7 +18,7 @@ import ( func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{Service, Transport}, nil + return []Genfunc{renderOnly(Service), renderOnly(Transport)}, nil } dsl := func() { diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index 8846c3c7ec..413052529d 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -20,7 +20,7 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(_ string) ([]Genfunc, error) { return []Genfunc{ - func(_ string, _ []eval.Root) ([]*codegen.File, error) { + renderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { return []*codegen.File{{ Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), SectionTemplates: []*codegen.SectionTemplate{ @@ -28,8 +28,8 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { {Name: "type-def", Source: "type First struct{}\n"}, }, }}, nil - }, - func(_ string, _ []eval.Root) ([]*codegen.File, error) { + }), + renderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { return []*codegen.File{{ Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), SectionTemplates: []*codegen.SectionTemplate{ @@ -37,7 +37,7 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { {Name: "type-def", Source: "type Second struct{}\n"}, }, }}, nil - }, + }), }, nil } @@ -63,7 +63,7 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { // second write would overwrite the first. Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} f.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("User types", "types", nil), @@ -73,8 +73,8 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { }, } return []*codegen.File{f}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + }), + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} f.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("User types", "types", nil), @@ -84,7 +84,7 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { }, } return []*codegen.File{f}, nil - }, + }), }, nil } @@ -121,7 +121,7 @@ func TestGenerateParallelManyFiles(t *testing.T) { const numFiles = 20 Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { files := make([]*codegen.File, numFiles) for i := 0; i < numFiles; i++ { f := &codegen.File{ @@ -137,7 +137,7 @@ func TestGenerateParallelManyFiles(t *testing.T) { files[i] = f } return files, nil - }, + }), }, nil } @@ -178,30 +178,30 @@ func TestGenerateParallelWithMerge(t *testing.T) { // This exercises both merging and parallel writing with NumCPU workers. Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} f1.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("Types", "types", nil), {Name: "type1", Source: "type Type1 struct{}\n"}, } return []*codegen.File{f1}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + }), + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} f2.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("Types", "types", nil), {Name: "type2", Source: "type Type2 struct{}\n"}, } return []*codegen.File{f2}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + }), + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} f3.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("Types", "types", nil), {Name: "type3", Source: "type Type3 struct{}\n"}, } return []*codegen.File{f3}, nil - }, + }), }, nil } @@ -252,7 +252,7 @@ func TestGenerateParallelErrorHandling(t *testing.T) { // Worker pool should capture first error but continue processing other files. Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { files := make([]*codegen.File, 5) for i := 0; i < 5; i++ { f := &codegen.File{ @@ -272,7 +272,7 @@ func TestGenerateParallelErrorHandling(t *testing.T) { files[i] = f } return files, nil - }, + }), }, nil } @@ -294,14 +294,14 @@ func TestGenerateParallelSingleFile(t *testing.T) { Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} f.SectionTemplates = []*codegen.SectionTemplate{ codegen.Header("Types", "types", nil), {Name: "type", Source: "type Single struct{}\n"}, } return []*codegen.File{f}, nil - }, + }), }, nil } diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index b4bfc7dbcf..7c4357e47a 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -17,7 +17,9 @@ import ( // failure mode where only the union method remained and the struct was lost. func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{Service, Transport, OpenAPI}, nil } + Generators = func(cmd string) ([]Genfunc, error) { + return []Genfunc{renderOnly(Service), renderOnly(Transport), renderOnly(OpenAPI)}, nil + } dsl := func() { d.API("test", func() {}) diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go new file mode 100644 index 0000000000..c41a600a43 --- /dev/null +++ b/codegen/generator/generation_test.go @@ -0,0 +1,113 @@ +// This file verifies that the generator plans every declaration before any +// core generator or plugin renders files from the frozen generation catalog. +package generator + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestGeneratePhasesShareOneGeneration(t *testing.T) { + command := fmt.Sprintf("test-generation-phases-%p", t) + codegen.RunDSL(t, func() {}) + t.Cleanup(func() { + Generators = generators + }) + + var ( + events []string + planned *codegen.Generation + lateDeclare error + preparedRoots []eval.Root + ) + typesPath := "generated.local/gen/types" + union := &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"} + lateUnion := &expr.Union{TypeName: "Late", TypeKey: "kind", ValueKey: "data"} + + assertGeneration := func(generation *codegen.Generation) error { + if planned != generation { + return fmt.Errorf("generation changed between plan and render") + } + if len(generation.Roots) != len(preparedRoots) { + return fmt.Errorf("generation roots changed after plugin preparation") + } + return nil + } + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + { + Plan: func(generation *codegen.Generation) error { + events = append(events, "core-plan-first") + planned = generation + typesPath = generation.GenPkg + "/types" + _, err := generation.GeneratedPackage(typesPath).DeclareUnion(union) + return err + }, + Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + events = append(events, "core-render-first") + if err := assertGeneration(generation); err != nil { + return nil, err + } + declaration, err := generation.GeneratedPackage(typesPath).Union(union) + if err != nil { + return nil, err + } + if declaration.Name == "" { + return nil, fmt.Errorf("union name is empty during render") + } + _, lateDeclare = generation.GeneratedPackage(typesPath).DeclareUnion(lateUnion) + if lateDeclare == nil { + return nil, fmt.Errorf("render declared a new union after freeze") + } + return nil, nil + }, + }, + { + Plan: func(generation *codegen.Generation) error { + events = append(events, "core-plan-second") + return assertGeneration(generation) + }, + Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + events = append(events, "core-render-second") + return nil, assertGeneration(generation) + }, + }, + }, nil + } + codegen.RegisterPlugin( + "lifecycle", + command, + func(_ string, roots []eval.Root) error { + events = append(events, "plugin-prepare") + preparedRoots = roots + return nil + }, + func(generation *codegen.Generation) error { + events = append(events, "plugin-plan") + return assertGeneration(generation) + }, + func(generation *codegen.Generation, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "plugin-render") + return files, assertGeneration(generation) + }, + ) + + _, err := Generate(t.TempDir(), command, false) + require.NoError(t, err) + require.ErrorContains(t, lateDeclare, "frozen") + require.Equal(t, []string{ + "plugin-prepare", + "core-plan-first", + "core-plan-second", + "plugin-plan", + "core-render-first", + "core-render-second", + "plugin-render", + }, events) +} diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index e949d35391..a2787d35c3 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -7,13 +7,19 @@ import ( "goa.design/goa/v3/eval" ) -// Genfunc is the type of the functions invoked to generate code. -type Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) +type ( + // Genfunc plans declarations and renders files for one generation run. + Genfunc struct { + // Plan declares generated package types before any generator renders files. + Plan codegen.PlanFunc + // Generate renders files from the frozen generation catalog. + Generate func(*codegen.Generation) ([]*codegen.File, error) + } +) -// Generators returns the qualified paths (including the package name) to the -// code generator functions for the given command, an error if the command is -// not supported. Generators is a public variable so that external code (e.g. -// plugins) may override the default generators. +// Generators returns the generation lifecycle callbacks for the given command, +// or an error if the command is not supported. Generators is a public variable +// so external code may replace the default generators. var Generators = generators // generators returns the generator functions exposed by the generator package @@ -21,10 +27,20 @@ var Generators = generators func generators(cmd string) ([]Genfunc, error) { switch cmd { case "gen": - return []Genfunc{Service, Transport, OpenAPI}, nil + return []Genfunc{renderOnly(Service), renderOnly(Transport), renderOnly(OpenAPI)}, nil case "example": - return []Genfunc{Example}, nil + return []Genfunc{renderOnly(Example)}, nil default: return nil, fmt.Errorf("unknown command %q", cmd) } } + +// renderOnly adapts a generator that does not yet plan package declarations to +// render with the generation context selected by the top-level lifecycle. +func renderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) Genfunc { + return Genfunc{ + Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + return generate(generation.GenPkg, generation.Roots) + }, + } +} diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index aa99e2ed31..53001db9cf 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -81,8 +81,15 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { for _, cmd := range []string{"gen", "example"} { genfuncs, err := Generators(cmd) require.NoError(t, err) + generation := codegen.NewGeneration("gen", []eval.Root{root}) for _, gen := range genfuncs { - _, err := gen("gen", []eval.Root{root}) + if gen.Plan != nil { + require.NoError(t, gen.Plan(generation)) + } + } + require.NoError(t, generation.Freeze()) + for _, gen := range genfuncs { + _, err := gen.Generate(generation) require.NoError(t, err) } } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index 6f9c499af9..31a4150403 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -21,7 +21,7 @@ import ( func TestRelocatedUnionPackageNamesCompile(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{Service, Transport}, nil + return []Genfunc{renderOnly(Service), renderOnly(Transport)}, nil } root := func() { diff --git a/codegen/plugin.go b/codegen/plugin.go index 57ba30d7a1..e2c1cd0404 100644 --- a/codegen/plugin.go +++ b/codegen/plugin.go @@ -3,13 +3,14 @@ package codegen import "goa.design/goa/v3/eval" type ( + // PlanFunc declares generated package types before the generation is frozen. + // Planning functions must not render files. + PlanFunc func(*Generation) error + // GenerateFunc makes it possible to modify the files generated by the - // goa code generators and other plugins. A GenerateFunc accepts the Go - // import path of the "gen" package, the design roots as well as the - // currently generated files (produced initially by the goa generators - // and potentially modified by previously run plugins) and returns a new - // set of files. - GenerateFunc func(genpkg string, roots []eval.Root, files []*File) ([]*File, error) + // goa code generators and other plugins. It receives the frozen generation + // used by core generators and the files produced by preceding callbacks. + GenerateFunc func(*Generation, []*File) ([]*File, error) // PrepareFunc makes it possible to modify the design roots before // the files being generated by the goa code generators or other plugins. @@ -19,6 +20,8 @@ type ( plugin struct { // PrepareFunc is the plugin preparation function. PrepareFunc + // PlanFunc is the plugin declaration planning function. + PlanFunc // GenerateFunc is the plugin generator function. GenerateFunc // name is the plugin name. @@ -38,8 +41,8 @@ var plugins []*plugin // RegisterPlugin adds the plugin to the list of plugins to be invoked with the // given command. -func RegisterPlugin(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd} +func RegisterPlugin(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { + np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd} var inserted bool for i, plgn := range plugins { if plgn.last || (!plgn.first && np.name < plgn.name) { @@ -57,8 +60,8 @@ func RegisterPlugin(name string, cmd string, pre PrepareFunc, p GenerateFunc) { // to be invoked with the given command. If more than one plugins are registered // using this, the plugins will be sorted alphabetically by their names. If two // plugins have same names, then they are sorted by registration order. -func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, first: true} +func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { + np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd, first: true} var inserted bool for i, plgn := range plugins { if !plgn.first || np.name < plgn.name { @@ -76,8 +79,8 @@ func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, p GenerateFun // to be invoked with the given command. If more than one plugins are registered // using this, the plugins will be sorted alphabetically by their names. If two // plugins have same names, then they are sorted by registration order. -func RegisterPluginLast(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, last: true} +func RegisterPluginLast(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { + np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd, last: true} var inserted bool for i := len(plugins) - 1; i >= 0; i-- { plgn := plugins[i] @@ -109,14 +112,27 @@ func RunPluginsPrepare(cmd, genpkg string, roots []eval.Root) error { return nil } +// RunPluginsPlan executes plugin planning functions in registration order. +func RunPluginsPlan(cmd string, generation *Generation) error { + for _, plugin := range plugins { + if plugin.cmd != cmd || plugin.PlanFunc == nil { + continue + } + if err := plugin.PlanFunc(generation); err != nil { + return err + } + } + return nil +} + // RunPlugins executes the plugins registered with the given command in the order // they were registered. -func RunPlugins(cmd, genpkg string, roots []eval.Root, genfiles []*File) ([]*File, error) { +func RunPlugins(cmd string, generation *Generation, genfiles []*File) ([]*File, error) { for _, plugin := range plugins { if plugin.cmd != cmd { continue } - gs, err := plugin.GenerateFunc(genpkg, roots, genfiles) + gs, err := plugin.GenerateFunc(generation, genfiles) if err != nil { return nil, err } diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go index 7cec4e091e..0b0f15262c 100644 --- a/codegen/plugin_test.go +++ b/codegen/plugin_test.go @@ -3,6 +3,11 @@ package codegen import ( "reflect" "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) func TestRegisterPlugin(t *testing.T) { @@ -31,7 +36,7 @@ func TestRegisterPlugin(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { plugins = tc.existingPs - RegisterPlugin(pIns.name, "", nil, nil) + RegisterPlugin(pIns.name, "", nil, nil, nil) if !reflect.DeepEqual(plugins, tc.expectedPs) { t.Errorf("invalid plugin registration order") } @@ -66,7 +71,7 @@ func TestRegisterPluginFirst(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { plugins = tc.existingPs - RegisterPluginFirst(pIns.name, "", nil, nil) + RegisterPluginFirst(pIns.name, "", nil, nil, nil) if !reflect.DeepEqual(plugins, tc.expectedPs) { t.Errorf("invalid plugin registration order") } @@ -101,10 +106,52 @@ func TestRegisterPluginLast(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { plugins = tc.existingPs - RegisterPluginLast(pIns.name, "", nil, nil) + RegisterPluginLast(pIns.name, "", nil, nil, nil) if !reflect.DeepEqual(plugins, tc.expectedPs) { t.Errorf("invalid plugin registration order") } }) } } + +func TestRegisterPluginLifecycleCallbacksUseGeneration(t *testing.T) { + existing := plugins + plugins = nil + t.Cleanup(func() { + plugins = existing + }) + + var ( + events []string + plannedGen *Generation + ) + RegisterPlugin( + "lifecycle", + "test", + func(_ string, _ []eval.Root) error { + events = append(events, "prepare") + return nil + }, + func(generation *Generation) error { + events = append(events, "plan") + plannedGen = generation + _, err := generation.GeneratedPackage("generated.local/gen/types").DeclareUnion( + &expr.Union{TypeName: "Value"}, + ) + return err + }, + func(generation *Generation, files []*File) ([]*File, error) { + events = append(events, "render") + require.Same(t, plannedGen, generation) + return files, nil + }, + ) + + generation := NewGeneration("generated.local/gen", nil) + require.NoError(t, RunPluginsPrepare("test", generation.GenPkg, generation.Roots)) + require.NoError(t, RunPluginsPlan("test", generation)) + require.NoError(t, generation.Freeze()) + _, err := RunPlugins("test", generation, nil) + require.NoError(t, err) + require.Equal(t, []string{"prepare", "plan", "render"}, events) +} diff --git a/codegen/scope.go b/codegen/scope.go index c391c3a66e..bf73943923 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -43,7 +43,7 @@ func NewNameScope() *NameScope { // appending suffix and - if still not unique - a counter value. It returns // the same value when called multiple times for a key returning the same hash. func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) string { - hash := scopedTypeHash(key) + hash := key.Hash() if n, ok := s.names[hash]; ok { return n } @@ -286,7 +286,7 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { s.GoFullTypeRef(actual.ElemType, pkgWithDefault(actual.ElemType.Type, pkg))) case *expr.Object: return s.GoTypeDef(att, false, false) - case expr.UserType, *expr.Union: + case expr.UserType: if actual == expr.ErrorResult { return "goa.ServiceError" } @@ -303,14 +303,9 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { // consistent across packages. This is critical for transport packages that // refer to types defined in the service package (e.g., grpc referencing a // payload type defined as Request2). - base := Goify(actual.Name(), true) - if pkg == "" { - return s.HashedUnique(actual, base, "") - } - if n, ok := s.names[scopedTypeHash(actual)]; ok { - return pkg + "." + n - } - return pkg + "." + base + return s.scopedTypeName(actual, Goify(actual.Name(), true), pkg) + case *expr.Union: + return s.scopedTypeName(NewUnionTypeID(actual), Goify(actual.Name(), true), pkg) case expr.CompositeExpr: return s.GoFullTypeName(actual.Attribute(), pkgWithDefault(actual.Attribute().Type, pkg)) default: @@ -318,13 +313,16 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { } } -// scopedTypeHash returns the emitted-definition identity for unions and the -// existing type hash for every other scoped declaration. -func scopedTypeHash(key Hasher) string { - if union, ok := key.(*expr.Union); ok { - return UnionTypeHash(union) +// scopedTypeName returns a local or package-qualified generated declaration +// name. The caller supplies the exact identity owned by the target package. +func (s *NameScope) scopedTypeName(key Hasher, base, pkg string) string { + if pkg == "" { + return s.HashedUnique(key, base, "") + } + if name, ok := s.names[key.Hash()]; ok { + return pkg + "." + name } - return key.Hash() + return pkg + "." + base } // pkgWithDefault returns the package defining the given type. If the types is a diff --git a/codegen/scope_test.go b/codegen/scope_test.go index 722c899ae2..19680e19b9 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -9,6 +9,13 @@ import ( "goa.design/goa/v3/expr" ) +type exactHasher string + +// Hash returns the exact map identity supplied by the test. +func (h exactHasher) Hash() string { + return string(h) +} + func TestNameScope_Freeze(t *testing.T) { scope := NewNameScope() existing := &expr.UserTypeExpr{ @@ -75,6 +82,13 @@ func TestNameScope_Unique(t *testing.T) { } } +func TestNameScope_HashedUniqueUsesExactHash(t *testing.T) { + scope := NewNameScope() + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "First")) + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "Ignored")) + require.Equal(t, "First2", scope.HashedUnique(exactHasher("distinct"), "First")) +} + func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { scope := NewNameScope() @@ -216,7 +230,71 @@ func TestNameScope_GoTypeNameDistinguishesGoifiedBranchTypeCollisions(t *testing assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) } -func TestUnionTypeHashIgnoresNonEmittedPointerSharing(t *testing.T) { +func TestUnionTypeID(t *testing.T) { + branch := func(name string, dataType expr.DataType) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: dataType}} + } + userType := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + TypeName: "Entry", + UID: "entry", + } + } + tests := []struct { + name string + first *expr.Union + second *expr.Union + }{ + { + name: "wire keys", + first: &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"}, + second: &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"}, + }, + { + name: "branch order", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("left", expr.String), + branch("right", expr.Int), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("right", expr.Int), + branch("left", expr.String), + }}, + }, + { + name: "branch Go shape", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", expr.String), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"CustomString"}}, + }}, + }}, + }, + { + name: "relocated branch package", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/first")), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/second")), + }}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotEqual(t, NewUnionTypeID(test.first), NewUnionTypeID(test.second)) + }) + } +} + +func TestUnionTypeIDIgnoresNonEmittedPointerSharing(t *testing.T) { object := func() *expr.Object { fields := expr.Object{ {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, @@ -243,11 +321,11 @@ func TestUnionTypeHashIgnoresNonEmittedPointerSharing(t *testing.T) { t.Run("inline object", func(t *testing.T) { shared := object() - assert.Equal(t, UnionTypeHash(outerUnion(shared, shared)), UnionTypeHash(outerUnion(object(), object()))) + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(object(), object()))) }) t.Run("nested union", func(t *testing.T) { shared := innerUnion() - assert.Equal(t, UnionTypeHash(outerUnion(shared, shared)), UnionTypeHash(outerUnion(innerUnion(), innerUnion()))) + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(innerUnion(), innerUnion()))) }) } diff --git a/codegen/union.go b/codegen/union.go index 7418f229ba..f8e6e10f46 100644 --- a/codegen/union.go +++ b/codegen/union.go @@ -9,97 +9,115 @@ import ( "goa.design/goa/v3/expr" ) -// UnionTypeHash returns a stable identity for the Go and JSON definition -// generated for u. Unlike expr.Union.Hash, which describes design-type -// compatibility, UnionTypeHash includes the effective JSON envelope keys and -// details that change generated Go branch types, such as package locations, -// field type metadata, and nilability. -func UnionTypeHash(u *expr.Union) string { +type ( + // UnionTypeID identifies the Go and JSON definition emitted for a union. + // It is distinct from expr.Union.Hash, which describes design compatibility. + UnionTypeID string +) + +// NewUnionTypeID returns the generated-definition identity for union. The +// identity includes the effective JSON envelope keys and details that change +// generated Go branch types, including package locations, field type metadata, +// and nilability. +func NewUnionTypeID(union *expr.Union) UnionTypeID { var key strings.Builder - writeUnionTypeHash(&key, u, make(map[*expr.Object]int), make(map[*expr.Union]int)) - return key.String() + writeUnionTypeID(&key, union, make(map[*expr.Object]int), make(map[*expr.Union]int)) + return UnionTypeID(key.String()) +} + +// UnionTypeHash returns the string form of a generated union identity. +// +// Deprecated: use NewUnionTypeID so generated-definition identity remains +// distinct from design expression hashes at naming and package ownership sites. +func UnionTypeHash(union *expr.Union) string { + return NewUnionTypeID(union).Hash() +} + +// Hash returns the exact identity used by a generated package's name scope. +func (id UnionTypeID) Hash() string { + return string(id) } -// writeUnionTypeHash appends one union definition using length-prefixed values +// writeUnionTypeID appends one union definition using length-prefixed values // so different inputs cannot produce an ambiguous concatenation. -func writeUnionTypeHash(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int) { +func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int) { if index, ok := unions[union]; ok { - writeUnionHashPart(key, "union-ref") - writeUnionHashPart(key, strconv.Itoa(index)) + writeUnionIDPart(key, "union-ref") + writeUnionIDPart(key, strconv.Itoa(index)) return } unions[union] = len(unions) defer delete(unions, union) - writeUnionHashPart(key, "union") - writeUnionHashPart(key, union.TypeName) - writeUnionHashPart(key, union.GetTypeKey()) - writeUnionHashPart(key, union.GetValueKey()) + writeUnionIDPart(key, "union") + writeUnionIDPart(key, union.TypeName) + writeUnionIDPart(key, union.GetTypeKey()) + writeUnionIDPart(key, union.GetValueKey()) for _, value := range union.Values { - writeUnionHashPart(key, value.Name) - writeUnionAttributeHash(key, value.Attribute, objects, unions) + writeUnionIDPart(key, value.Name) + writeUnionAttributeID(key, value.Attribute, objects, unions) } } -// writeUnionAttributeHash appends the generated Go identity of an attribute. -func writeUnionAttributeHash(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int) { - writeUnionHashPart(key, strconv.FormatBool(IsNilable(att.Type))) +// writeUnionAttributeID appends the generated Go identity of an attribute. +func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int) { + writeUnionIDPart(key, strconv.FormatBool(IsNilable(att.Type))) if metaType, ok := att.Meta["struct:field:type"]; ok { - writeUnionHashPart(key, "meta-type") + writeUnionIDPart(key, "meta-type") for _, value := range metaType { - writeUnionHashPart(key, value) + writeUnionIDPart(key, value) } } switch actual := att.Type.(type) { case expr.Primitive: - writeUnionHashPart(key, "primitive") - writeUnionHashPart(key, GoNativeTypeName(actual)) + writeUnionIDPart(key, "primitive") + writeUnionIDPart(key, GoNativeTypeName(actual)) case expr.UserType: - writeUnionHashPart(key, "user") - writeUnionHashPart(key, Goify(actual.Name(), true)) - writeUnionHashPart(key, actual.Hash()) + writeUnionIDPart(key, "user") + writeUnionIDPart(key, Goify(actual.Name(), true)) + writeUnionIDPart(key, actual.Hash()) if loc := UserTypeLocation(actual); loc != nil { - writeUnionHashPart(key, loc.RelImportPath) + writeUnionIDPart(key, loc.RelImportPath) } else { - writeUnionHashPart(key, "") + writeUnionIDPart(key, "") } case *expr.Array: - writeUnionHashPart(key, "array") - writeUnionAttributeHash(key, actual.ElemType, objects, unions) + writeUnionIDPart(key, "array") + writeUnionAttributeID(key, actual.ElemType, objects, unions) case *expr.Map: - writeUnionHashPart(key, "map") - writeUnionAttributeHash(key, actual.KeyType, objects, unions) - writeUnionAttributeHash(key, actual.ElemType, objects, unions) + writeUnionIDPart(key, "map") + writeUnionAttributeID(key, actual.KeyType, objects, unions) + writeUnionAttributeID(key, actual.ElemType, objects, unions) case *expr.Object: - writeUnionObjectHash(key, att, actual, objects, unions) + writeUnionObjectID(key, att, actual, objects, unions) case *expr.Union: - writeUnionTypeHash(key, actual, objects, unions) + writeUnionTypeID(key, actual, objects, unions) case expr.CompositeExpr: - writeUnionAttributeHash(key, actual.Attribute(), objects, unions) + writeUnionAttributeID(key, actual.Attribute(), objects, unions) default: panic("unknown union branch data type") } } -// writeUnionObjectHash appends the inline Go struct emitted for an object. -func writeUnionObjectHash(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int) { +// writeUnionObjectID appends the inline Go struct emitted for an object. +func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int) { if index, ok := objects[object]; ok { - writeUnionHashPart(key, "object-ref") - writeUnionHashPart(key, strconv.Itoa(index)) + writeUnionIDPart(key, "object-ref") + writeUnionIDPart(key, strconv.Itoa(index)) return } objects[object] = len(objects) defer delete(objects, object) - writeUnionHashPart(key, "object") + writeUnionIDPart(key, "object") for _, field := range *object { - writeUnionHashPart(key, GoifyAtt(field.Attribute, field.Name, true)) - writeUnionHashPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) - writeUnionHashPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, false))) - writeUnionAttributeHash(key, field.Attribute, objects, unions) + writeUnionIDPart(key, GoifyAtt(field.Attribute, field.Name, true)) + writeUnionIDPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) + writeUnionIDPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, false))) + writeUnionAttributeID(key, field.Attribute, objects, unions) } } -// writeUnionHashPart appends one unambiguous string component to key. -func writeUnionHashPart(key *strings.Builder, value string) { +// writeUnionIDPart appends one unambiguous string component to key. +func writeUnionIDPart(key *strings.Builder, value string) { key.WriteString(strconv.Itoa(len(value))) key.WriteByte(':') key.WriteString(value) From a120d8ead7166ec666137c8e539881e6ed2e298b Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:54:26 -0700 Subject: [PATCH 13/43] docs(codegen): describe generation invariants --- codegen/generator/generate.go | 4 ++++ codegen/generator/generators.go | 3 +++ codegen/plugin.go | 4 ++++ codegen/scope.go | 4 ++++ 4 files changed, 15 insertions(+) diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 4d074a6a32..17182f3e2b 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -1,3 +1,7 @@ +// The goa command calls this file with an output directory, command, and debug +// flag; it reads the evaluated design roots and returns the files it wrote. +// Every core generator and plugin plans against one Generation, which is frozen +// before any callback renders files or can add another declaration. package generator import ( diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index a2787d35c3..24058c3e9b 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -1,3 +1,6 @@ +// Generate asks this file for the core callbacks selected by the gen or example +// command. It receives Genfunc records whose Plan callbacks all run before the +// same frozen Generation is passed to their file-producing Generate callbacks. package generator import ( diff --git a/codegen/plugin.go b/codegen/plugin.go index e2c1cd0404..affc6acea1 100644 --- a/codegen/plugin.go +++ b/codegen/plugin.go @@ -1,3 +1,7 @@ +// Plugins register prepare, plan, and render callbacks in this file; the +// top-level generator invokes matching callbacks with design roots, the active +// Generation, and generated files. Preparation may change roots, planning may +// declare types, and rendering receives the same Generation only after freeze. package codegen import "goa.design/goa/v3/eval" diff --git a/codegen/scope.go b/codegen/scope.go index bf73943923..67611854c8 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -1,3 +1,7 @@ +// Code generators use this file to turn caller-supplied type identities and +// attributes into unique Go names and type references. Hashed names use exactly +// the caller's Hash value; after Freeze, existing names remain readable but no +// new name may be reserved. package codegen import ( From c1b12f8b68745cf110d0b2a492ea7be5a81fa3a9 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Thu, 20 Aug 2026 22:55:38 -0700 Subject: [PATCH 14/43] docs(codegen): record generation lifecycle completion --- .../plans/2026-08-20-generated-package-ownership.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 960e011b1e..1f4a317958 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -159,14 +159,14 @@ Expected: PASS. - Consumes: Task 2 `Generation` and package records, `expr.Union`, `NameScope.HashedUnique` - Produces: `UnionTypeID`, generic `Hasher.Hash()` behavior, and plan-aware core generator and plugin APIs -- [ ] **Step 1: Add union identity and lifecycle tests** +- [x] **Step 1: Add union identity and lifecycle tests** Use a custom `Hasher` to prove `HashedUnique` keys only on its exact `Hash()`. Keep emitted-union distinctions for wire keys, branch order, branch Go shape, and relocated package. Add generator/plugin tests that record plan, freeze, and render order and reject a render-time declaration. -- [ ] **Step 2: Introduce the typed emitted-union identity** +- [x] **Step 2: Introduce the typed emitted-union identity** Use this public contract: @@ -180,7 +180,7 @@ Move the emitted-definition algorithm behind `NewUnionTypeID`, update Task 2's package records to key unions by it, and restore `HashedUnique` to direct `key.Hash()` behavior. `expr.Union.Hash()` remains unchanged. -- [ ] **Step 3: Change core and plugin lifecycle APIs** +- [x] **Step 3: Change core and plugin lifecycle APIs** Use these contracts: @@ -199,7 +199,7 @@ prepare, plan, and generate functions. `Generate` runs prepare, normalization, every core/plugin plan, `Freeze`, every core render, then every plugin render. No render callback may declare a new type. -- [ ] **Step 4: Run identity and lifecycle tests** +- [x] **Step 4: Run identity and lifecycle tests** Run: From 81b8bc372dfacfe2a4b3aa12afc5e51bd35cf9b9 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 00:01:51 -0700 Subject: [PATCH 15/43] refactor(codegen): make service emission package-owned --- codegen/example/example_server_test.go | 6 +- codegen/generated_types.go | 127 +++- codegen/generated_types_test.go | 112 +++- codegen/generator/example.go | 33 +- ...erate_http_union_shape_integration_test.go | 5 +- .../generate_union_merge_integration_test.go | 6 +- codegen/generator/generators.go | 8 +- codegen/generator/openapi.go | 15 +- codegen/generator/service.go | 46 +- .../service_union_package_scope_test.go | 31 +- codegen/generator/transport.go | 45 +- codegen/service/client_test.go | 2 +- codegen/service/convert.go | 16 +- codegen/service/convert_test.go | 2 +- codegen/service/endpoint_test.go | 2 +- codegen/service/example_interceptors_test.go | 2 +- codegen/service/example_svc_test.go | 2 +- codegen/service/generated_package.go | 445 ++++++++++++++ codegen/service/interceptors_test.go | 2 +- codegen/service/security_test.go | 6 +- codegen/service/service.go | 284 ++++----- codegen/service/service_data.go | 559 ++++++++++++------ .../service_data_union_nilability_test.go | 5 +- .../service/service_data_union_order_test.go | 34 +- codegen/service/service_dedup_test.go | 4 +- codegen/service/service_test.go | 415 ++++++++++--- codegen/service/views.go | 15 +- codegen/service/views_test.go | 2 +- expr/dup.go | 9 +- expr/dup_test.go | 32 + expr/result_type.go | 21 + expr/types.go | 3 + expr/user_type.go | 12 + expr/user_type_test.go | 41 +- grpc/codegen/example_cli_test.go | 3 +- grpc/codegen/example_server_test.go | 3 +- grpc/codegen/testing.go | 19 +- http/codegen/example_cli_test.go | 3 +- http/codegen/example_server_test.go | 5 +- http/codegen/testing.go | 19 +- jsonrpc/codegen/kitchen_sink_test.go | 8 +- jsonrpc/codegen/testing.go | 19 +- 42 files changed, 1887 insertions(+), 541 deletions(-) create mode 100644 codegen/service/generated_package.go diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 59f2242683..79033887f1 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -61,7 +61,11 @@ func TestExampleServerFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - services := service.NewServicesData(root) + generation := codegen.NewGeneration("goa.design/goa/example", nil) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) fs := ServerFiles("", root, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 6059f6e881..011a3fea3a 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -6,6 +6,7 @@ package codegen import ( "fmt" "slices" + "strings" "goa.design/goa/v3/expr" ) @@ -25,21 +26,55 @@ type ( // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { - // Name is the unqualified Go declaration name. Union declarations keep - // Name empty until the owning generation is frozen. + // Name is the unqualified Go declaration name. Generated union branch + // aliases keep Name empty until the owning generation is frozen. Name string // PackagePath is the import path of the package that owns the declaration. PackagePath string } + // UnionDeclaration records the canonical union and discriminator names in + // the package that emits them. + UnionDeclaration struct { + // Name is the unqualified Go union name. It remains empty until the + // owning generation is frozen. + Name string + // KindName is the unqualified Go discriminator type name. It remains + // empty until the owning generation is frozen. + KindName string + // PackagePath is the import path of the package that owns both names. + PackagePath string + } + // unionDeclaration retains the expression needed to allocate the public // union name deterministically when the generation freezes. unionDeclaration struct { union *expr.Union + declaration *UnionDeclaration + branches map[unionBranchID]*unionBranchDeclaration + } + + // unionBranchID identifies one generated branch alias within its union + // declaration family. + unionBranchID struct { + name string + } + + // unionBranchDeclaration keeps every expression copy that refers to one + // branch alias while owning a single emitted declaration. + unionBranchDeclaration struct { + userTypes map[expr.UserType]struct{} declaration *TypeDeclaration + name string } ) +// Ref returns the Go reference spelling for declaration's data type, including +// Goa's pointer/value semantics for named objects, unions, and aliases. +func (d *TypeDeclaration) Ref(dataType expr.DataType) string { + return goTypeRef(d.Name, dataType) +} + // DeclareUserType reserves userType's exact exported Go name and returns its // canonical package declaration. Repeated calls return the same declaration. func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { @@ -78,7 +113,7 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara // DeclareUnion records union's emitted definition and returns the same // declaration for unions with the same emitted identity. The declaration name // remains empty until the owning generation freezes its package catalogs. -func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) { +func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } @@ -87,10 +122,50 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, er return planned.declaration, nil } - declaration := &TypeDeclaration{PackagePath: p.path} + declaration := &UnionDeclaration{PackagePath: p.path} p.unions[identity] = &unionDeclaration{ union: union, declaration: declaration, + branches: make(map[unionBranchID]*unionBranchDeclaration), + } + return declaration, nil +} + +// DeclareUnionBranchType records a generated user type that names one branch +// of union. Equivalent union expressions share the same branch declaration; +// ordinary DSL user types must instead be declared with DeclareUserType. +func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName string, userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + planned, ok := p.unions[NewUnionTypeID(union)] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + if !unionHasBranchType(union, branchName, userType) { + return nil, fmt.Errorf("user type %q is not branch %q of union %q", userType.Name(), branchName, union.Name()) + } + + identity := unionBranchID{name: branchName} + if branch, ok := planned.branches[identity]; ok { + name := Goify(userType.Name(), true) + if branch.name != name { + return nil, fmt.Errorf( + "branch %q of union %q cannot declare both %q and %q", + branchName, + union.Name(), + branch.name, + name, + ) + } + branch.userTypes[userType] = struct{}{} + return branch.declaration, nil + } + declaration := &TypeDeclaration{PackagePath: p.path} + planned.branches[identity] = &unionBranchDeclaration{ + userTypes: map[expr.UserType]struct{}{userType: {}}, + declaration: declaration, + name: Goify(userType.Name(), true), } return declaration, nil } @@ -106,13 +181,30 @@ func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, e // Union returns union's existing package declaration without allocating a // name or declaration record. -func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) { +func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { if planned, ok := p.unions[NewUnionTypeID(union)]; ok { return planned.declaration, nil } return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) } +// UnionBranchType returns the existing declaration for one generated branch +// alias without allocating a name or declaration record. +func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string, userType expr.UserType) (*TypeDeclaration, error) { + planned, ok := p.unions[NewUnionTypeID(union)] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + branch, ok := planned.branches[unionBranchID{name: branchName}] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + if _, ok := branch.userTypes[userType]; !ok { + return nil, fmt.Errorf("user type %q is not declared as branch %q of union %q", userType.Name(), branchName, union.Name()) + } + return branch.declaration, nil +} + // Scope returns the frozen package-owned name scope used to render generated // references. It panics before declaration planning has been frozen. func (p *GeneratedPackage) Scope() *NameScope { @@ -145,7 +237,32 @@ func (p *GeneratedPackage) freeze() { planned := p.unions[identity] name := p.scope.HashedUnique(identity, Goify(planned.union.Name(), true), "") planned.declaration.Name = name + planned.declaration.KindName = p.scope.Unique(name + "Kind") + + branches := make([]unionBranchID, 0, len(planned.branches)) + for branch := range planned.branches { + branches = append(branches, branch) + } + slices.SortFunc(branches, func(a, b unionBranchID) int { + return strings.Compare(a.name, b.name) + }) + for _, identity := range branches { + branch := planned.branches[identity] + branch.declaration.Name = p.scope.Unique(branch.name) + } } p.scope.Freeze() p.frozen = true } + +// unionHasBranchType verifies that userType is the branch expression supplied +// for this concrete union copy. Structural reuse is established separately by +// UnionTypeID when the owning union declaration is looked up. +func unionHasBranchType(union *expr.Union, branchName string, userType expr.UserType) bool { + for _, branch := range union.Values { + if branch.Name == branchName && branch.Attribute.Type == userType { + return true + } + } + return false +} diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 18ad354dae..e25fbedc30 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -87,6 +87,73 @@ func TestGeneratedPackageUserTypes(t *testing.T) { require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) } +// TestGeneratedPackageExactUserTypesDoNotMerge verifies that structural +// equality does not weaken the exact-name contract for DSL declarations. +func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + first := generatedUserType("ValueText", "first") + equivalent := generatedUserType("ValueText", "second") + + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(equivalent) + require.ErrorContains(t, err, "ValueText") + require.ErrorContains(t, err, "already declared") +} + +// TestGeneratedPackageUnionBranchesShareDeclaration verifies that separately +// allocated copies of one structural union reuse their generated branch alias. +func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) + secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.Same(t, firstDeclaration, secondDeclaration) + require.Empty(t, firstDeclaration.Name) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText", firstDeclaration.Name) + lookedUp, err := types.UnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) +} + +// TestGeneratedPackageUnionBranchesAreIsolatedByUnion verifies that branch +// aliases from different emitted union definitions never collapse together. +func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) + secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) + secondUnion.TypeKey = "kind" + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.NotSame(t, firstDeclaration, secondDeclaration) + + require.NoError(t, generation.Freeze()) + require.ElementsMatch(t, []string{"ValueText", "ValueText2"}, []string{ + firstDeclaration.Name, + secondDeclaration.Name, + }) +} + // TestGeneratedPackageUnions verifies that emitted-definition identity makes // equivalent unions idempotent while different unions with the same base name // receive distinct declarations. @@ -99,7 +166,7 @@ func TestGeneratedPackageUnions(t *testing.T) { firstDeclaration, err := types.DeclareUnion(first) require.NoError(t, err) - require.Equal(t, &TypeDeclaration{ + require.Equal(t, &UnionDeclaration{ PackagePath: "generated.local/gen/types", }, firstDeclaration) equivalentDeclaration, err := types.DeclareUnion(equivalent) @@ -119,6 +186,10 @@ func TestGeneratedPackageUnions(t *testing.T) { firstDeclaration.Name, differentDeclaration.Name, }) + require.ElementsMatch(t, []string{"ValueKind", "Value2Kind"}, []string{ + firstDeclaration.KindName, + differentDeclaration.KindName, + }) reversedGeneration := NewGeneration("generated.local/gen", nil) reversedTypes := reversedGeneration.GeneratedPackage("generated.local/gen/types") @@ -131,18 +202,21 @@ func TestGeneratedPackageUnions(t *testing.T) { require.Equal(t, differentDeclaration.Name, reversedDifferent.Name) } -// TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder verifies that -// pending unions cannot take an exact user-type name based on traversal order. -func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { +// TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder verifies that +// pending unions cannot take exact user-type or discriminator names based on +// traversal order. +func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { for _, unionFirst := range []bool{true, false} { t.Run(fmt.Sprintf("union first %t", unionFirst), func(t *testing.T) { generation := NewGeneration("generated.local/gen", nil) types := generation.GeneratedPackage("generated.local/gen/types") userType := generatedUserType("Value", "value") + kindUserType := generatedUserType("ValueKind", "value-kind") union := generatedUnion("Value", "type", "value") var ( userDeclaration *TypeDeclaration - unionDeclaration *TypeDeclaration + kindDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration err error ) if unionFirst { @@ -153,9 +227,13 @@ func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { }) userDeclaration, err = types.DeclareUserType(userType) require.NoError(t, err) + kindDeclaration, err = types.DeclareUserType(kindUserType) + require.NoError(t, err) } else { userDeclaration, err = types.DeclareUserType(userType) require.NoError(t, err) + kindDeclaration, err = types.DeclareUserType(kindUserType) + require.NoError(t, err) require.Panics(t, func() { types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType}) }) @@ -164,11 +242,16 @@ func TestGeneratedPackageUserTypeWinsUnionNameRegardlessOfOrder(t *testing.T) { } require.Equal(t, "Value", userDeclaration.Name) + require.Equal(t, "ValueKind", kindDeclaration.Name) require.Empty(t, unionDeclaration.Name) + require.Empty(t, unionDeclaration.KindName) require.NoError(t, generation.Freeze()) require.Equal(t, "Value", userDeclaration.Name) + require.Equal(t, "ValueKind", kindDeclaration.Name) require.Equal(t, "Value2", unionDeclaration.Name) + require.Equal(t, "Value2Kind", unionDeclaration.KindName) require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType})) + require.Equal(t, "ValueKind", types.Scope().GoTypeName(&expr.AttributeExpr{Type: kindUserType})) require.Equal(t, "Value2", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) }) } @@ -234,8 +317,13 @@ func TestGenerationCatalogsAreIsolated(t *testing.T) { // generatedUserType builds a distinct user type for catalog tests. func generatedUserType(name, id string) expr.UserType { + return generatedUserTypeOf(name, id, expr.String) +} + +// generatedUserTypeOf builds a distinct user type with the supplied shape. +func generatedUserTypeOf(name, id string, dataType expr.DataType) expr.UserType { return &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + AttributeExpr: &expr.AttributeExpr{Type: dataType}, TypeName: name, UID: id, } @@ -250,3 +338,15 @@ func generatedUnion(name, typeKey, valueKey string) *expr.Union { ValueKey: valueKey, } } + +// generatedUnionWithBranch builds a union with one generated branch alias. +func generatedUnionWithBranch(unionName, branchName, aliasID string, dataType expr.DataType) (*expr.Union, expr.UserType) { + alias := generatedUserTypeOf(unionName+expr.Title(branchName), aliasID, dataType) + return &expr.Union{ + TypeName: unionName, + Values: []*expr.NamedAttributeExpr{{ + Name: branchName, + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + }, alias +} diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 97effe960c..6856075b26 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -4,7 +4,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" @@ -12,43 +11,45 @@ import ( // Example iterates through the roots and returns files that implement an // example service, server, and client. -func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { +func Example(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - designRoots := serviceRoots(roots) - servicesByRoot := service.NewServicesDataForRoots(designRoots) + designRoots := serviceRoots(generation.Roots) for _, r := range designRoots { - services := servicesByRoot[r] + services, err := service.NewServicesData(r, generation) + if err != nil { + return nil, err + } for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) + service.SetUserTypeImports(generation.GenPkg, services.Get(s.Name)) } // example service implementation - if fs := service.ExampleServiceFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleServiceFiles(generation.GenPkg, r, services); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(generation.GenPkg, r, services); len(fs) != 0 { files = append(files, fs...) } // server main - if fs := example.ServerFiles(genpkg, r, services); len(fs) != 0 { + if fs := example.ServerFiles(generation.GenPkg, r, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(genpkg, r); len(fs) != 0 { + if fs := example.CLIFiles(generation.GenPkg, r); len(fs) != 0 { files = append(files, fs...) } // HTTP if len(r.API.HTTP.Services) > 0 { httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(genpkg, httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleServerFiles(generation.GenPkg, httpServices); len(fs) != 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(genpkg, httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg, httpServices); len(fs) != 0 { files = append(files, fs...) } } @@ -56,10 +57,10 @@ func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { // JSON-RPC if len(r.API.JSONRPC.Services) > 0 { jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(genpkg, jsonrpcServices, files); len(fs) > 0 { + if fs := jsonrpccodegen.ExampleServerFiles(generation.GenPkg, jsonrpcServices, files); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(genpkg, jsonrpcServices); len(fs) > 0 { + if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg, jsonrpcServices); len(fs) > 0 { files = append(files, fs...) } } @@ -67,10 +68,10 @@ func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { // GRPC if len(r.API.GRPC.Services) > 0 { grpcServices := grpccodegen.NewServicesData(services) - if fs := grpccodegen.ExampleServerFiles(genpkg, grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleServerFiles(generation.GenPkg, grpcServices); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(genpkg, grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleCLIFiles(generation.GenPkg, grpcServices); len(fs) > 0 { files = append(files, fs...) } } diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index a3710e2d5c..ac006bf16b 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -18,7 +18,10 @@ import ( func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{renderOnly(Service), renderOnly(Transport)}, nil + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + }, nil } dsl := func() { diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index 7c4357e47a..cee67652ad 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -18,7 +18,11 @@ import ( func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{renderOnly(Service), renderOnly(Transport), renderOnly(OpenAPI)}, nil + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + {Plan: planServiceData, Generate: OpenAPI}, + }, nil } dsl := func() { diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 24058c3e9b..0925d5e692 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -30,9 +30,13 @@ var Generators = generators func generators(cmd string) ([]Genfunc, error) { switch cmd { case "gen": - return []Genfunc{renderOnly(Service), renderOnly(Transport), renderOnly(OpenAPI)}, nil + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + {Plan: planServiceData, Generate: OpenAPI}, + }, nil case "example": - return []Genfunc{renderOnly(Example)}, nil + return []Genfunc{{Plan: planServiceData, Generate: Example}}, nil default: return nil, fmt.Errorf("unknown command %q", cmd) } diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 0381d2231a..28bbc31b29 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -2,19 +2,22 @@ package generator import ( "goa.design/goa/v3/codegen" - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/codegen/service" httpcodegen "goa.design/goa/v3/http/codegen" ) // OpenAPI iterates through the roots and returns the files needed to render // the service OpenAPI spec. It produces OpenAPI specifications only if the // roots define a HTTP service. -func OpenAPI(_ string, roots []eval.Root) ([]*codegen.File, error) { - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - return httpcodegen.OpenAPIFiles(r) +func OpenAPI(generation *codegen.Generation) ([]*codegen.File, error) { + designRoots := serviceRoots(generation.Roots) + for _, root := range designRoots { + if _, err := service.NewServicesData(root, generation); err != nil { + return nil, err } } + if len(designRoots) > 0 { + return httpcodegen.OpenAPIFiles(designRoots[0]) + } return nil, nil } diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 1f9c93a79f..ac9958c79a 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -10,32 +10,29 @@ import ( // Service iterates through the roots and returns the files needed to render // the service code. It returns an error if the roots slice does not include // a goa design. -func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { +func Service(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - var userTypePkgs = make(map[string][]string) - designRoots := serviceRoots(roots) - servicesByRoot := service.NewServicesDataForRoots(designRoots) - for _, r := range designRoots { - services := servicesByRoot[r] + designRoots := serviceRoots(generation.Roots) + analyses := make([]*service.ServicesData, len(designRoots)) + for i, r := range designRoots { + services, err := service.NewServicesData(r, generation) + if err != nil { + return nil, err + } + analyses[i] = services for _, s := range r.Services { d := services.Get(s.Name) - service.SetUserTypeImports(genpkg, d) - - // Make sure service is first so name scope is - // properly initialized. - svcFiles := service.Files(genpkg, s, services, userTypePkgs) - addServiceImports(svcFiles, d) - files = append(files, svcFiles...) + service.SetUserTypeImports(generation.GenPkg, d) endpointFiles := []*codegen.File{ - service.EndpointFile(genpkg, s, services), - service.ClientFile(genpkg, s, services), + service.EndpointFile(generation.GenPkg, s, services), + service.ClientFile(generation.GenPkg, s, services), } addServiceImports(endpointFiles, d) files = append(files, endpointFiles...) - if f := service.ViewsFile(genpkg, s, services); f != nil { + if f := service.ViewsFile(generation.GenPkg, s, services); f != nil { addServiceImports([]*codegen.File{f}, d) files = append(files, f) } @@ -46,7 +43,22 @@ func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { files = append(files, convFiles...) } } - return files, nil + svcFiles := service.Files(generation.GenPkg, analyses) + for i, services := range analyses { + addServicesImports(svcFiles, services, designRoots[i].Services) + } + return append(svcFiles, files...), nil +} + +// planServiceData declares service-owned generated package types for every Goa +// design root in generation. +func planServiceData(generation *codegen.Generation) error { + for _, root := range serviceRoots(generation.Roots) { + if err := service.Plan(root, generation); err != nil { + return err + } + } + return nil } // serviceRoots returns every Goa design root that emits files into the same diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index 31a4150403..e0b0cb11e9 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -21,7 +21,10 @@ import ( func TestRelocatedUnionPackageNamesCompile(t *testing.T) { t.Cleanup(func() { Generators = generators }) Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{renderOnly(Service), renderOnly(Transport)}, nil + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + }, nil } root := func() { @@ -93,7 +96,10 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { codegen.RunDSL(t, relocatedDifferentUnionRoot()), codegen.RunDSL(t, relocatedTopLevelValueRoot()), } - files, err := Service("goa.design/goa/example", roots) + generation := codegen.NewGeneration("goa.design/goa/example", roots) + require.NoError(t, planServiceData(generation)) + require.NoError(t, generation.Freeze()) + files, err := Service(generation) require.NoError(t, err) var generated strings.Builder @@ -107,13 +113,13 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { } code := generated.String() require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Contains(t, code, "type Value struct {\n\tText string", code) require.Equal(t, 1, strings.Count(code, "type Value2 struct {"), code) - require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) - require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) require.Equal(t, 1, strings.Count(code, "type Value3 struct {"), code) - require.Contains(t, code, "type Value3 struct {\n\tText string", code) + require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value3Kind string"), code) require.Equal(t, - []string{"Value", "Value", "Value", "Value2"}, + []string{"Value2", "Value2", "Value2", "Value3"}, []string{ unionFieldType(code, "ZExistingValue"), unionFieldType(code, "MExistingValue"), @@ -125,15 +131,14 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { func TestServiceSelectiveRelocatedUnionOwnerCompiles(t *testing.T) { root := codegen.RunDSL(t, selectiveRelocatedUnionRoot()) - services := servicecodegen.NewServicesData(root) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, servicecodegen.Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := servicecodegen.NewServicesData(root, generation) + require.NoError(t, err) data := services.Get(root.Services[1].Name) servicecodegen.SetUserTypeImports("generated.local/gen", data) - files := servicecodegen.Files( - "generated.local/gen", - root.Services[1], - services, - make(map[string][]string), - ) + files := servicecodegen.Files("generated.local/gen", []*servicecodegen.ServicesData{services}) addServiceImports(files, data) dir := t.TempDir() for _, file := range files { diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 3393199cc6..f8c49aac31 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -3,7 +3,6 @@ package generator import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" @@ -11,42 +10,44 @@ import ( // Transport iterates through the roots and returns the files needed to render // the transport code. -func Transport(genpkg string, roots []eval.Root) ([]*codegen.File, error) { +func Transport(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - designRoots := serviceRoots(roots) - servicesByRoot := service.NewServicesDataForRoots(designRoots) + designRoots := serviceRoots(generation.Roots) for _, r := range designRoots { - services := servicesByRoot[r] + services, err := service.NewServicesData(r, generation) + if err != nil { + return nil, err + } for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) + service.SetUserTypeImports(generation.GenPkg, services.Get(s.Name)) } // HTTP httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, httpServices)...) + files = append(files, httpcodegen.ServerFiles(generation.GenPkg, httpServices)...) + files = append(files, httpcodegen.ClientFiles(generation.GenPkg, httpServices)...) + files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg, httpServices)...) + files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg, httpServices)...) files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, httpServices)...) + files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg, httpServices)...) // GRPC grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(genpkg, grpcServices)...) + files = append(files, grpccodegen.ProtoFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ServerFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ClientFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ServerTypeFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ClientTypeFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ClientCLIFiles(generation.GenPkg, grpcServices)...) // JSON-RPC jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(genpkg, jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, jsonrpcServices)...) + files = append(files, jsonrpccodegen.ServerFiles(generation.GenPkg, jsonrpcServices)...) + files = append(files, jsonrpccodegen.ClientFiles(generation.GenPkg, jsonrpcServices)...) + files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg, jsonrpcServices)...) + files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg, jsonrpcServices)...) files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, jsonrpcServices)...) + files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg, jsonrpcServices)...) // Add service data meta type imports addServicesImports(files, services, r.Services) diff --git a/codegen/service/client_test.go b/codegen/service/client_test.go index 54a7505f5b..35e454a117 100644 --- a/codegen/service/client_test.go +++ b/codegen/service/client_test.go @@ -38,7 +38,7 @@ func TestClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := ClientFile("test/gen", root.Services[0], services) require.NotNil(t, fs) diff --git a/codegen/service/convert.go b/codegen/service/convert.go index b9f3305079..11d12af21c 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -54,6 +54,7 @@ func ConvertFiles(root *expr.RootExpr, service *expr.ServiceExpr, services *Serv creationsByPath[path], service, svc, + services, ) if err != nil { return nil, err @@ -125,6 +126,7 @@ func generateConvertFileForPath( creations []*expr.TypeMap, service *expr.ServiceExpr, svc *Data, + services *ServicesData, ) (*codegen.File, error) { if len(conversions) == 0 && len(creations) == 0 { return nil, nil @@ -193,10 +195,9 @@ func generateConvertFileForPath( // Use the correct source context based on where the conversion file will be generated var srcCtx *codegen.AttributeContext if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - srcScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - srcScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) + srcScope := services.generation.GeneratedPackage( + generatedPackagePath(services.generation.GenPkg, service, loc), + ).Scope() // Use conversion context so types in the same package are not qualified srcCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, srcScope) } else { @@ -249,10 +250,9 @@ func generateConvertFileForPath( // Use the correct target context based on where the conversion file will be generated var tgtCtx *codegen.AttributeContext if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - tgtScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - tgtScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) + tgtScope := services.generation.GeneratedPackage( + generatedPackagePath(services.generation.GenPkg, service, loc), + ).Scope() // Use conversion context so types in the same package are not qualified tgtCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, tgtScope) } else { diff --git a/codegen/service/convert_test.go b/codegen/service/convert_test.go index 0dcf39c0db..a90dae6dd0 100644 --- a/codegen/service/convert_test.go +++ b/codegen/service/convert_test.go @@ -323,7 +323,7 @@ func TestConvertFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) for _, svc := range root.Services { files, err := ConvertFiles(root, svc, services) diff --git a/codegen/service/endpoint_test.go b/codegen/service/endpoint_test.go index b42c12b235..aa07cfb44d 100644 --- a/codegen/service/endpoint_test.go +++ b/codegen/service/endpoint_test.go @@ -40,7 +40,7 @@ func TestEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := EndpointFile("goa.design/goa/example", root.Services[0], services) require.NotNil(t, fs) diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index c4e69999f5..465095d65f 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -84,7 +84,7 @@ func TestExampleInterceptorsFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { // Run DSL root := runDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.NotNil(t, root) // Generate files diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 0de9c87dad..9fd651e3a8 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -32,7 +32,7 @@ func TestExampleServiceFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 3) fs := ExampleServiceFiles("", root, services) require.Len(t, fs, 3) diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go new file mode 100644 index 0000000000..80023889b0 --- /dev/null +++ b/codegen/service/generated_package.go @@ -0,0 +1,445 @@ +// This file binds service types selected by one design root to the generated +// packages that declare them. Planning records relocated user types and unions +// before names freeze; rendering stores one canonical section per declaration +// record so each package emits that declaration once. +package service + +import ( + "path" + "path/filepath" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // plannedAttribute identifies one service attribute and the package inherited + // by nested types that do not select their own struct:pkg:path location. + plannedAttribute struct { + attribute *expr.AttributeExpr + service *expr.ServiceExpr + location *codegen.Location + } + + // plannedUserType identifies one user type emitted in one generated package. + // The same expression may be copied into two packages through Extend. + plannedUserType struct { + userType expr.UserType + packagePath string + } + + // unionBranch identifies a generated user type that exists only to name one + // branch of its owning union. + unionBranch struct { + union *expr.Union + name string + } + + // rootTypeSet maps compiler-created copies back to the exact DSL declaration + // in the same design root. Generated union aliases have different typed + // origins and are not included. + rootTypeSet struct { + byOrigin map[expr.UserType]expr.UserType + } + + // generatedPackageData owns the render data emitted into one Go package. + generatedPackageData struct { + importPath string + outputPath string + packageName string + types map[*codegen.TypeDeclaration]*generatedTypeData + unions map[codegen.UnionTypeID]*UnionTypeData + } + + // generatedTypeData owns one relocated user-type declaration and optional + // error behavior at its metadata-selected file. + generatedTypeData struct { + declaration *codegen.TypeDeclaration + location *codegen.Location + section *codegen.SectionTemplate + error *codegen.SectionTemplate + } +) + +// Plan declares every relocated user type and union reachable from root. +// User types are declared across the complete root before any union so exact +// user-authored names always take precedence over generated union names. +func Plan(root *expr.RootExpr, generation *codegen.Generation) error { + inputs := planningInputs(root) + rootTypes := newRootTypeSet(root) + for _, service := range root.Services { + // The service package record makes NewServicesData a render-only contract: + // its scope is unavailable until the generation freezes. + generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + } + + seenTypes := make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUserTypes(input.attribute, input.service, input.location, generation, rootTypes, seenTypes); err != nil { + return err + } + } + + seenTypes = make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUnions(input.attribute, input.service, input.location, generation, rootTypes, seenTypes); err != nil { + return err + } + } + return nil +} + +// planningInputs returns the service attributes that can cause service types +// to be emitted. Unused root types are deliberately excluded. +func planningInputs(root *expr.RootExpr) []plannedAttribute { + var inputs []plannedAttribute + for _, service := range root.Services { + for _, serviceError := range service.Errors { + inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: service}) + } + for _, method := range service.Methods { + inputs = append(inputs, + plannedAttribute{attribute: method.Payload, service: service}, + plannedAttribute{attribute: method.StreamingPayload, service: service}, + plannedAttribute{attribute: method.Result, service: service}, + ) + if method.HasMixedResults() { + inputs = append(inputs, plannedAttribute{attribute: method.StreamingResult, service: service}) + } + for _, methodError := range method.Errors { + inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: service}) + } + } + for _, userType := range root.Types { + services, ok := userType.Attribute().Meta["type:generate:force"] + if !ok || len(services) > 0 && !slices.Contains(services, service.Name) { + continue + } + inputs = append(inputs, plannedAttribute{ + attribute: &expr.AttributeExpr{Type: userType}, + service: service, + }) + } + } + return inputs +} + +// planUserTypes traverses attribute and declares each relocated user type in +// the package selected by its own or its enclosing type's metadata. +func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUserTypes(attribute, service, location, generation, rootTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + key := plannedUserType{ + userType: declaredType, + packagePath: generatedPackagePath(generation.GenPkg, service, typeLocation), + } + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + if typeLocation != nil { + if _, err := generation.GeneratedPackage(key.packagePath).DeclareUserType(declaredType); err != nil { + return err + } + } + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range *actual { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// planUnions traverses attribute after all user types have been declared and +// records each relocated union in its owning package. +func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUnions(attribute, service, location, generation, rootTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + key := plannedUserType{ + userType: declaredType, + packagePath: generatedPackagePath(generation.GenPkg, service, typeLocation), + } + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range sortedNamedAttributes(*actual) { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + var generatedPackage *codegen.GeneratedPackage + if location != nil { + packagePath := generatedPackagePath(generation.GenPkg, service, location) + generatedPackage = generation.GeneratedPackage(packagePath) + if _, err := generatedPackage.DeclareUnion(actual); err != nil { + return err + } + } + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if generatedPackage != nil { + if _, err := generatedPackage.DeclareUnionBranchType(actual, named.Name, userType); err != nil { + return err + } + } + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// newRootTypeSet records the exact declarations whose compiler-created copies +// share package records. Generated union aliases have independent origins +// and never enter this set. +func newRootTypeSet(root *expr.RootExpr) *rootTypeSet { + userTypes := &rootTypeSet{ + byOrigin: make(map[expr.UserType]expr.UserType, len(root.Types)+len(root.ResultTypes)+1), + } + for _, userType := range root.Types { + userTypes.add(userType) + } + for _, resultType := range root.ResultTypes { + userTypes.add(resultType) + } + userTypes.add(expr.ErrorResult) + return userTypes +} + +// generatedUnionBranch identifies the user type synthesized by OneOf around a +// branch that was not itself an exact DSL user-type declaration. +func generatedUnionBranch(branch *expr.NamedAttributeExpr, rootTypes *rootTypeSet) (expr.UserType, bool) { + userType, ok := branch.Attribute.Type.(expr.UserType) + if !ok { + return nil, false + } + return userType, !rootTypes.contains(userType) +} + +// add records one exact root declaration under its typed origin. +func (s *rootTypeSet) add(userType expr.UserType) { + s.byOrigin[userType.Origin()] = userType +} + +// canonical maps only a compiler copy whose typed origin belongs to this root +// back to its exact declaration. +func (s *rootTypeSet) canonical(userType expr.UserType) expr.UserType { + if canonical, ok := s.byOrigin[userType.Origin()]; ok { + return canonical + } + return userType +} + +// contains reports whether userType is an exact root declaration or one of +// its compiler copies. +func (s *rootTypeSet) contains(userType expr.UserType) bool { + _, ok := s.byOrigin[userType.Origin()] + return ok +} + +// generatedPackagePath returns the actual import path of the package selected +// by location, or the service package when location is nil. +func generatedPackagePath(genpkg string, service *expr.ServiceExpr, location *codegen.Location) string { + if location != nil { + return path.Join(genpkg, location.RelImportPath) + } + return servicePackagePath(genpkg, service) +} + +// servicePackagePath returns the actual import path of service's generated Go +// package. +func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { + return path.Join(genpkg, codegen.SnakeCase(service.Name)) +} + +// generatedPackage returns the root-owned render data for the package selected +// by location, creating that owner on first use. +func (d *ServicesData) generatedPackage(service *expr.ServiceExpr, location *codegen.Location) *generatedPackageData { + importPath := generatedPackagePath(d.generation.GenPkg, service, location) + if generatedPackage, ok := d.packages[importPath]; ok { + return generatedPackage + } + outputPath := filepath.Join(codegen.Gendir, codegen.SnakeCase(service.Name)) + packageName := strings.ToLower(codegen.Goify(service.Name, false)) + if location != nil { + outputPath = filepath.Join(codegen.Gendir, filepath.FromSlash(location.RelImportPath)) + packageName = location.PackageName() + } + generatedPackage := &generatedPackageData{ + importPath: importPath, + outputPath: outputPath, + packageName: packageName, + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[codegen.UnionTypeID]*UnionTypeData), + } + d.packages[importPath] = generatedPackage + return generatedPackage +} + +// registerPackageData gives each relocated user type's canonical declaration +// record one render section at its metadata-selected file. +func (d *ServicesData) registerPackageData(service *expr.ServiceExpr, data *Data) error { + for i, method := range service.Methods { + methodData := data.Methods[i] + if err := d.registerMethodType(service, method.Payload, methodData.PayloadLoc, methodData.PayloadDef, &codegen.SectionTemplate{ + Name: "service-payload", + Source: serviceTemplates.Read(payloadT), + Data: methodData, + }); err != nil { + return err + } + if method.StreamingPayload != nil { + if err := d.registerMethodType(service, method.StreamingPayload, codegen.UserTypeLocation(method.StreamingPayload.Type), methodData.StreamingPayloadDef, &codegen.SectionTemplate{ + Name: "service-streaming-payload", + Source: serviceTemplates.Read(streamingPayloadT), + Data: methodData, + }); err != nil { + return err + } + } + if err := d.registerMethodType(service, method.Result, methodData.ResultLoc, methodData.ResultDef, &codegen.SectionTemplate{ + Name: "service-result", + Source: serviceTemplates.Read(resultT), + Data: methodData, + }); err != nil { + return err + } + if method.HasMixedResults() && method.StreamingResult != nil { + if err := d.registerMethodType(service, method.StreamingResult, codegen.UserTypeLocation(method.StreamingResult.Type), methodData.StreamingResultDef, &codegen.SectionTemplate{ + Name: "service-streaming-result", + Source: serviceTemplates.Read(resultT), + Data: map[string]any{ + "Result": methodData.StreamingResult, + "ResultDef": methodData.StreamingResultDef, + "ResultDesc": methodData.StreamingResultDesc, + }, + }); err != nil { + return err + } + } + } + for _, userType := range data.userTypes { + if userType.Loc == nil { + continue + } + d.registerType(service, userType.Declaration, userType.Loc, &codegen.SectionTemplate{ + Name: "service-user-type", + Source: serviceTemplates.Read(userTypeT), + Data: userType, + }) + } + for _, errorType := range data.errorTypes { + if errorType.Loc == nil || errorType.Type == expr.ErrorResult { + continue + } + d.registerType(service, errorType.Declaration, errorType.Loc, &codegen.SectionTemplate{ + Name: "error-user-type", + Source: serviceTemplates.Read(userTypeT), + Data: errorType, + }) + generatedType := d.generatedPackage(service, errorType.Loc).types[errorType.Declaration] + if generatedType.error == nil { + generatedType.error = &codegen.SectionTemplate{ + Name: "service-error", + Source: serviceTemplates.Read(errorT), + FuncMap: map[string]any{"errorName": errorName}, + Data: errorType, + } + } + } + return nil +} + +// registerMethodType records one relocated method payload or result when it +// has a generated declaration body. +func (d *ServicesData) registerMethodType(service *expr.ServiceExpr, attribute *expr.AttributeExpr, location *codegen.Location, definition string, section *codegen.SectionTemplate) error { + if location == nil || definition == "" { + return nil + } + userType := attribute.Type.(expr.UserType) + declaration, err := d.generation.GeneratedPackage( + generatedPackagePath(d.generation.GenPkg, service, location), + ).UserType(d.rootTypes.canonical(userType)) + if err != nil { + return err + } + d.registerType(service, declaration, location, section) + return nil +} + +// registerType stores section under declaration. Repeated uses of the same +// canonical record retain the first root-order section and emit once. +func (d *ServicesData) registerType(service *expr.ServiceExpr, declaration *codegen.TypeDeclaration, location *codegen.Location, section *codegen.SectionTemplate) { + generatedPackage := d.generatedPackage(service, location) + if _, ok := generatedPackage.types[declaration]; ok { + return + } + generatedPackage.types[declaration] = &generatedTypeData{ + declaration: declaration, + location: location, + section: section, + } +} diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index 3dd2977b14..fe3ce8678d 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -47,7 +47,7 @@ func TestInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := InterceptorsFiles("goa.design/goa/example", root.Services[0], services) diff --git a/codegen/service/security_test.go b/codegen/service/security_test.go index 0ed2ab5e5b..649c286328 100644 --- a/codegen/service/security_test.go +++ b/codegen/service/security_test.go @@ -24,7 +24,7 @@ func TestSecureEndpointInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := EndpointFile("", root.Services[0], services) require.NotNil(t, fs) @@ -51,7 +51,7 @@ func TestSecureEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := EndpointFile("", root.Services[0], services) require.NotNil(t, fs) @@ -73,7 +73,7 @@ func TestSecureWithSkipRequestBodyEncodeDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := EndpointFile("", root.Services[0], services) require.NotNil(t, fs) diff --git a/codegen/service/service.go b/codegen/service/service.go index 5dd464077f..5b79cc48c0 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -4,58 +4,62 @@ import ( "fmt" "path/filepath" "sort" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) -// Files returns the generated files for the given service as well as a map -// indexing user type names by custom path as defined by the "struct:pkg:path" -// metadata. The map is built over each invocation of Files to avoid duplicate -// type definitions. -func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, userTypePkgs map[string][]string) []*codegen.File { +// Files returns every service-local file from analyses and emits each relocated +// user type and union once across the complete generation. +func Files(genpkg string, analyses []*ServicesData) []*codegen.File { + var files []*codegen.File + for _, services := range analyses { + for _, service := range services.Root.Services { + files = append(files, serviceFiles(genpkg, service, services)...) + } + } + return append(files, generatedPackageFiles(analyses)...) +} + +// serviceFiles renders the declarations and helpers owned exclusively by one +// service package. Relocated declarations and all union definitions are +// emitted later by generatedPackageFiles. +func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { svc := services.Get(service.Name) svcName := svc.PathName svcPath := filepath.Join(codegen.Gendir, svcName, "service.go") seen := make(map[string]struct{}) - typeDefSections := make(map[string]map[string]*codegen.SectionTemplate) - typesByPath := make(map[string][]string) + typeDefSections := make(map[string]*codegen.SectionTemplate) svcSections := make([]*codegen.SectionTemplate, 0, 10) - addTypeDefSection := func(path, name string, section *codegen.SectionTemplate) { - if typeDefSections[path] == nil { - typeDefSections[path] = make(map[string]*codegen.SectionTemplate) - } - typeDefSections[path][name] = section - typesByPath[path] = append(typesByPath[path], name) + addTypeDefSection := func(name string, section *codegen.SectionTemplate) { + typeDefSections[name] = section seen[name] = struct{}{} } - for _, m := range svc.Methods { - payloadPath := pathWithDefault(m.PayloadLoc, svcPath) - resultPath := pathWithDefault(m.ResultLoc, svcPath) - if m.PayloadDef != "" { + for i, m := range svc.Methods { + method := service.Methods[i] + if m.PayloadLoc == nil && m.PayloadDef != "" { if _, ok := seen[m.Payload]; !ok { - addTypeDefSection(payloadPath, m.Payload, &codegen.SectionTemplate{ + addTypeDefSection(m.Payload, &codegen.SectionTemplate{ Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: m, }) } } - if m.StreamingPayloadDef != "" { + if method.StreamingPayload != nil && codegen.UserTypeLocation(method.StreamingPayload.Type) == nil && m.StreamingPayloadDef != "" { if _, ok := seen[m.StreamingPayload]; !ok { - addTypeDefSection(payloadPath, m.StreamingPayload, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingPayload, &codegen.SectionTemplate{ Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: m, }) } } - if m.ResultDef != "" { + if m.ResultLoc == nil && m.ResultDef != "" { if _, ok := seen[m.Result]; !ok { - addTypeDefSection(resultPath, m.Result, &codegen.SectionTemplate{ + addTypeDefSection(m.Result, &codegen.SectionTemplate{ Name: "service-result", Source: serviceTemplates.Read(resultT), Data: m, @@ -63,9 +67,9 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } // Generate streaming result type if different from result - if m.StreamingResultDef != "" && m.StreamingResult != m.Result { + if method.StreamingResult != nil && codegen.UserTypeLocation(method.StreamingResult.Type) == nil && m.StreamingResultDef != "" && m.StreamingResult != m.Result { if _, ok := seen[m.StreamingResult]; !ok { - addTypeDefSection(resultPath, m.StreamingResult, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingResult, &codegen.SectionTemplate{ Name: "service-streaming-result", Source: serviceTemplates.Read(resultT), Data: map[string]any{ @@ -78,53 +82,39 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } for _, ut := range svc.userTypes { - if _, ok := seen[ut.VarName]; !ok { - addTypeDefSection(pathWithDefault(ut.Loc, svcPath), ut.VarName, &codegen.SectionTemplate{ - Name: "service-user-type", - Source: serviceTemplates.Read(userTypeT), - Data: ut, - }) + if ut.Loc == nil { + if _, ok := seen[ut.VarName]; !ok { + addTypeDefSection(ut.VarName, &codegen.SectionTemplate{ + Name: "service-user-type", + Source: serviceTemplates.Read(userTypeT), + Data: ut, + }) + } } } - for _, u := range svc.unions { - addTypeDefSection(pathWithDefault(u.Loc, svcPath), "~union:"+u.Name, &codegen.SectionTemplate{ - Name: "service-union-type", - Source: serviceTemplates.Read(unionTypeT), - Data: u, - }) - } - var errorTypes []*UserTypeData seenErrs := make(map[string]struct{}) for _, et := range svc.errorTypes { - if et.Type == expr.ErrorResult { + if et.Type == expr.ErrorResult || et.Loc != nil { continue } if _, ok := seenErrs[et.Name]; !ok { seenErrs[et.Name] = struct{}{} if _, ok := seen[et.Name]; !ok { - addTypeDefSection(pathWithDefault(et.Loc, svcPath), et.Name, &codegen.SectionTemplate{ + addTypeDefSection(et.Name, &codegen.SectionTemplate{ Name: "error-user-type", Source: serviceTemplates.Read(userTypeT), Data: et, }) } - errorTypes = append(errorTypes, et) + typeDefSections["|"+et.Name] = &codegen.SectionTemplate{ + Name: "service-error", + Source: serviceTemplates.Read(errorT), + FuncMap: map[string]any{"errorName": errorName}, + Data: et, + } } } - - for _, et := range errorTypes { - // Don't override the section created for the error type - // declaration, make sure the key does not clash with existing - // type names, make it generated last. - key := "|" + et.Name - addTypeDefSection(pathWithDefault(et.Loc, svcPath), key, &codegen.SectionTemplate{ - Name: "service-error", - Source: serviceTemplates.Read(errorT), - FuncMap: map[string]any{"errorName": errorName}, - Data: et, - }) - } for _, er := range svc.errorInits { svcSections = append(svcSections, &codegen.SectionTemplate{ Name: "error-init-func", @@ -174,13 +164,6 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use codegen.GoaImport("security"), codegen.NewImport(svc.ViewsPkg, genpkg+"/"+svcName+"/views"), } - if len(svc.unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) - } header := codegen.Header(service.Name+" service", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "service", @@ -194,90 +177,122 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use }, } - // service.go - var sections []*codegen.SectionTemplate - { - names := make([]string, len(typeDefSections[svcPath])) - i := 0 - for n := range typeDefSections[svcPath] { - names[i] = n - i++ - } - sections = make([]*codegen.SectionTemplate, 0, 2+len(names)+len(svcSections)) - sections = append(sections, header, def) - sort.Strings(names) - for _, n := range names { - sections = append(sections, typeDefSections[svcPath][n]) - } - sections = append(sections, svcSections...) + names := make([]string, 0, len(typeDefSections)) + for name := range typeDefSections { + names = append(names, name) } + sort.Strings(names) + sections := make([]*codegen.SectionTemplate, 0, 2+len(names)+len(svcSections)) + sections = append(sections, header, def) + for _, name := range names { + sections = append(sections, typeDefSections[name]) + } + sections = append(sections, svcSections...) files := []*codegen.File{{Path: svcPath, SectionTemplates: sections}} + return append(files, InterceptorsFiles(genpkg, service, services)...) +} - // service and client interceptors - files = append(files, InterceptorsFiles(genpkg, service, services)...) +// generatedPackageFiles renders each relocated user type in its configured +// file and one sorted unions.go for every package that owns unions. +func generatedPackageFiles(analyses []*ServicesData) []*codegen.File { + packages := aggregateGeneratedPackages(analyses) + packagePaths := make([]string, 0, len(packages)) + for packagePath := range packages { + packagePaths = append(packagePaths, packagePath) + } + sort.Strings(packagePaths) - // user types - paths := make([]string, len(typeDefSections)) - i := 0 - for p := range typesByPath { - paths[i] = p - i++ - } - sort.Strings(paths) - for _, p := range paths { - if p == svcPath { - continue + var files []*codegen.File + for _, packagePath := range packagePaths { + generatedPackage := packages[packagePath] + typesByFile := make(map[string][]*generatedTypeData) + for _, generatedType := range generatedPackage.types { + filePath := filepath.Join(codegen.Gendir, generatedType.location.FilePath) + typesByFile[filePath] = append(typesByFile[filePath], generatedType) } - var secs []*codegen.SectionTemplate - hasUnion := false - ts := typesByPath[p] - sort.Strings(ts) - for _, name := range ts { - registry := p - isUnion := strings.HasPrefix(name, "~union:") - if isUnion { - registry = unionRegistryKey(p) - } - hasName := false - for _, n := range userTypePkgs[registry] { - if hasName = n == name; hasName { - break + filePaths := make([]string, 0, len(typesByFile)) + for filePath := range typesByFile { + filePaths = append(filePaths, filePath) + } + sort.Strings(filePaths) + for _, filePath := range filePaths { + generatedTypes := typesByFile[filePath] + sort.Slice(generatedTypes, func(i, j int) bool { + return generatedTypes[i].declaration.Name < generatedTypes[j].declaration.Name + }) + sections := []*codegen.SectionTemplate{codegen.Header("User types", generatedPackage.packageName, []*codegen.ImportSpec{ + codegen.SimpleImport("fmt"), + codegen.GoaImport(""), + })} + for _, generatedType := range generatedTypes { + sections = append(sections, generatedType.section) + if generatedType.error != nil { + sections = append(sections, generatedType.error) } } - if hasName { - continue - } - userTypePkgs[registry] = append(userTypePkgs[registry], name) - secs = append(secs, typeDefSections[p][name]) - hasUnion = hasUnion || isUnion - } - if len(secs) == 0 { - continue - } - fullRelPath := filepath.Join(codegen.Gendir, p) - dir, _ := filepath.Split(fullRelPath) - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("fmt"), - codegen.GoaImport(""), + files = append(files, &codegen.File{Path: filePath, SectionTemplates: sections}) } - if hasUnion { - imports = append(imports, + + if len(generatedPackage.unions) > 0 { + unions := make([]*UnionTypeData, 0, len(generatedPackage.unions)) + for _, union := range generatedPackage.unions { + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + sections := []*codegen.SectionTemplate{codegen.Header("Union types", generatedPackage.packageName, []*codegen.ImportSpec{ codegen.SimpleImport("bytes"), codegen.SimpleImport("encoding/json"), - ) + codegen.SimpleImport("fmt"), + codegen.GoaImport(""), + })} + for _, union := range unions { + sections = append(sections, &codegen.SectionTemplate{ + Name: "service-union-type", + Source: serviceTemplates.Read(unionTypeT), + Data: union, + }) + } + files = append(files, &codegen.File{ + Path: filepath.Join(generatedPackage.outputPath, "unions.go"), + SectionTemplates: sections, + }) } - h := codegen.Header("User types", codegen.Goify(filepath.Base(dir), false), imports) - sections := append([]*codegen.SectionTemplate{h}, secs...) - files = append(files, &codegen.File{Path: fullRelPath, SectionTemplates: sections}) } - return files } -// unionRegistryKey returns the render-lifetime registry key shared by every -// relocated type file in the same generated Go package. -func unionRegistryKey(path string) string { - return "\x00union-package:" + filepath.Dir(path) +// aggregateGeneratedPackages selects one render section per canonical package +// declaration across all analyzed roots without mutating generation state. +func aggregateGeneratedPackages(analyses []*ServicesData) map[string]*generatedPackageData { + packages := make(map[string]*generatedPackageData) + for _, services := range analyses { + for packagePath, analyzedPackage := range services.packages { + generatedPackage, ok := packages[packagePath] + if !ok { + generatedPackage = &generatedPackageData{ + importPath: analyzedPackage.importPath, + outputPath: analyzedPackage.outputPath, + packageName: analyzedPackage.packageName, + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[codegen.UnionTypeID]*UnionTypeData), + } + packages[packagePath] = generatedPackage + } + for declaration, generatedType := range analyzedPackage.types { + if _, exists := generatedPackage.types[declaration]; !exists { + generatedPackage.types[declaration] = generatedType + } + } + for identity, union := range analyzedPackage.unions { + if _, exists := generatedPackage.unions[identity]; !exists { + generatedPackage.unions[identity] = union + } + } + } + } + return packages } // dedupeByResult returns a slice of methods where only a single representative @@ -446,10 +461,3 @@ func streamInterfaceFor(typ string, m *MethodData, stream *StreamData) map[strin "IsViewedResult": m.ViewedResult != nil && m.ViewedResult.ViewName == "", } } - -func pathWithDefault(loc *codegen.Location, def string) string { - if loc == nil { - return def - } - return loc.FilePath -} diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index 5194bac77f..394da4336d 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -36,13 +36,9 @@ type ( Root *expr.RootExpr Services map[string]*Data - packageScopes *packageScopes - } - - // packageScopes owns generated identifiers for every relocated Go package - // across a complete set of design roots. - packageScopes struct { - scopes map[string]*codegen.NameScope + generation *codegen.Generation + packages map[string]*generatedPackageData + rootTypes *rootTypeSet } // Data contains the data used to render the code related to a single @@ -427,6 +423,9 @@ type ( // UserTypeData contains the data describing a user-defined type. UserTypeData struct { + // Declaration is the generated-package record for a relocated type. It + // is nil for a type emitted in its service package or views package. + Declaration *codegen.TypeDeclaration // Name is the type name. Name string // VarName is the corresponding Go type name. @@ -446,6 +445,9 @@ type ( // UnionTypeData describes a generated sum-type union for a service. UnionTypeData struct { + // Declaration is the generated-package record for a relocated union. It + // is nil for a union emitted in its service package or views package. + Declaration *codegen.UnionDeclaration // Name is the Go type name of the union struct. Name string // KindName is the Go type name of the discriminator kind. @@ -646,63 +648,49 @@ type ( Validate string } - // serviceNameScopes keeps service-local identifiers isolated while sharing - // the identifier namespace of every relocated Go package across services. - serviceNameScopes struct { - local *codegen.NameScope - packages *packageScopes + // unionDataKey identifies one emitted union definition in one generated Go + // package without encoding either fact into a string sentinel. + unionDataKey struct { + packagePath string + identity codegen.UnionTypeID } - // unionCompanionKey identifies a generated union companion, such as its kind - // type, by the emitted definition of its owning union and its role. - unionCompanionKey struct { - union *expr.Union - role string + // userTypeDataKey distinguishes ordinary DSL declarations by their stable + // ID and generated union branch aliases by their package declaration record. + userTypeDataKey struct { + id string + declaration *codegen.TypeDeclaration } -) -// NewServicesData creates and analyzes service data for one design root. -func NewServicesData(root *expr.RootExpr) *ServicesData { - return NewServicesDataForRoots([]*expr.RootExpr{root})[root] -} + // unionBranchLookup resolves a generated branch alias from its owning frozen + // union declaration family. + unionBranchLookup func(*expr.NamedAttributeExpr) (*codegen.TypeDeclaration, error) +) -// NewServicesDataForRoots creates service data for the complete ordered roots -// set. All services are analyzed once in root and service declaration order so -// relocated types share one package namespace and only emitted declarations -// reserve names. -func NewServicesDataForRoots(roots []*expr.RootExpr) map[*expr.RootExpr]*ServicesData { - packageScopes := &packageScopes{scopes: make(map[string]*codegen.NameScope)} - servicesByRoot := make(map[*expr.RootExpr]*ServicesData, len(roots)) - for _, root := range roots { - if _, ok := servicesByRoot[root]; ok { - panic("duplicate root in complete service generation root set") - } - servicesByRoot[root] = &ServicesData{ - Services: make(map[string]*Data), - Root: root, - packageScopes: packageScopes, - } - } - for _, root := range roots { - services := servicesByRoot[root] - for _, service := range root.Services { - services.Get(service.Name) - } - } - return servicesByRoot +// NewServicesData analyzes root using declarations frozen by generation. +// Call Plan for every participating root and freeze generation first. +func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*ServicesData, error) { + data := &ServicesData{ + Root: root, + Services: make(map[string]*Data), + generation: generation, + packages: make(map[string]*generatedPackageData), + rootTypes: newRootTypeSet(root), + } + for _, service := range root.Services { + generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)).Scope() + analyzed, err := data.analyze(service) + if err != nil { + return nil, err + } + data.Services[service.Name] = analyzed + } + return data, nil } -// Get retrieves the data for the service with the given name computing it if -// needed. It returns nil if there is no service with the given name. +// Get retrieves the analyzed data for the service with the given name. It +// returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *Data { - if data, ok := d.Services[name]; ok { - return data - } - service := d.Root.Service(name) - if service == nil { - return nil - } - d.Services[name] = d.analyze(service) return d.Services[name] } @@ -785,7 +773,7 @@ func (s SchemesData) DedupeByType() SchemesData { // analyze creates the data necessary to render the code of the given service. // It records the user types needed by the service definition in userTypes. -func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { +func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { var ( types []*UserTypeData errTypes []*UserTypeData @@ -794,52 +782,71 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { viewedRTs []*ViewedResultTypeData ) scope := codegen.NewNameScope() - scopes := &serviceNameScopes{local: scope, packages: d.packageScopes} scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket viewScope := codegen.NewNameScope() pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") viewspkg := pkgName + "views" - seen := make(map[string]struct{}) + seenTypes := make(map[userTypeDataKey]struct{}) seenErrors := make(map[string]struct{}) seenProj := make(map[string]*ProjectedTypeData) seenViewed := make(map[string]*ViewedResultTypeData) // A function to collect user types from an error expression - recordError := func(er *expr.ErrorExpr) { - errTypes = append(errTypes, collectTypes(er.AttributeExpr, scopes, seen, nil)...) + recordError := func(er *expr.ErrorExpr) error { + collected, err := d.collectTypes(er.AttributeExpr, service, scope, seenTypes, nil, nil) + if err != nil { + return err + } + errTypes = append(errTypes, collected...) if er.Type == expr.ErrorResult { if _, ok := seenErrors[er.Name]; ok { - return + return nil } seenErrors[er.Name] = struct{}{} errorInits = append(errorInits, buildErrorInitData(er, scope)) } + return nil } for _, er := range service.Errors { - recordError(er) + if err := recordError(er); err != nil { + return nil, err + } } // A function to collect inner user types from an attribute expression - collectUserTypes := func(att *expr.AttributeExpr) { + collectUserTypes := func(att *expr.AttributeExpr) error { if att == nil { - return + return nil } var loc *codegen.Location if ut, ok := att.Type.(expr.UserType); ok { loc = codegen.UserTypeLocation(ut) att = ut.Attribute() } - types = append(types, collectTypes(att, scopes, seen, loc)...) + collected, err := d.collectTypes(att, service, scope, seenTypes, loc, nil) + if err != nil { + return err + } + types = append(types, collected...) + return nil } for _, m := range service.Methods { // collect inner user types - collectUserTypes(m.Payload) - collectUserTypes(m.StreamingPayload) - collectUserTypes(m.Result) + if err := collectUserTypes(m.Payload); err != nil { + return nil, err + } + if err := collectUserTypes(m.StreamingPayload); err != nil { + return nil, err + } + if err := collectUserTypes(m.Result); err != nil { + return nil, err + } // Collect streaming result types if different from Result if m.HasMixedResults() { - collectUserTypes(m.StreamingResult) + if err := collectUserTypes(m.StreamingResult); err != nil { + return nil, err + } } // Collect projected types if hasResultType(m.Result) { @@ -848,7 +855,9 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { projTypes = append(projTypes, ptypes...) } for _, er := range m.Errors { - recordError(er) + if err := recordError(er); err != nil { + return nil, err + } } } @@ -867,7 +876,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { service.Name, m.Name)) // bug } if ut, ok := att.Type.(expr.UserType); ok { - seen[ut.ID()] = struct{}{} + seenTypes[userTypeDataKey{id: ut.ID()}] = struct{}{} } } @@ -890,12 +899,20 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { if len(svcs) > 0 { // Force generate type only in the specified services if slices.Contains(svcs, service.Name) { - types = append(types, collectTypes(att, scopes, seen, nil)...) + collected, err := d.collectTypes(att, service, scope, seenTypes, nil, nil) + if err != nil { + return nil, err + } + types = append(types, collected...) } continue } // Force generate type in all the services - types = append(types, collectTypes(att, scopes, seen, nil)...) + collected, err := d.collectTypes(att, service, scope, seenTypes, nil, nil) + if err != nil { + return nil, err + } + types = append(types, collected...) } var ( @@ -904,7 +921,10 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { ) methods = make([]*MethodData, len(service.Methods)) for i, e := range service.Methods { - m := d.buildMethodData(e, scopes) + m, err := d.buildMethodData(e, scope) + if err != nil { + return nil, err + } methods[i] = m for _, s := range m.Schemes { schemes = schemes.Append(s) @@ -949,29 +969,41 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { } // Collect union sum-type definitions for the service. - unionByPackage := make(map[string]*UnionTypeData) - seen = make(map[string]struct{}) - collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) { - collectUnionTypes(att, scopes, loc, unionByPackage, seen, false) + unionByPackage := make(map[unionDataKey]*UnionTypeData) + seen := make(map[string]struct{}) + collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) error { + return d.collectUnionTypes(att, service, scope, loc, unionByPackage, seen, false) } for _, t := range types { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) + if err := collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc); err != nil { + return nil, err + } } for _, t := range errTypes { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) + if err := collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc); err != nil { + return nil, err + } } for _, m := range service.Methods { if m.Payload != nil { - collectUnions(m.Payload, codegen.UserTypeLocation(m.Payload.Type)) + if err := collectUnions(m.Payload, codegen.UserTypeLocation(m.Payload.Type)); err != nil { + return nil, err + } } if m.StreamingPayload != nil { - collectUnions(m.StreamingPayload, codegen.UserTypeLocation(m.StreamingPayload.Type)) + if err := collectUnions(m.StreamingPayload, codegen.UserTypeLocation(m.StreamingPayload.Type)); err != nil { + return nil, err + } } if m.Result != nil { - collectUnions(m.Result, codegen.UserTypeLocation(m.Result.Type)) + if err := collectUnions(m.Result, codegen.UserTypeLocation(m.Result.Type)); err != nil { + return nil, err + } } for _, e := range m.Errors { - collectUnions(e.AttributeExpr, codegen.UserTypeLocation(e.Type)) + if err := collectUnions(e.AttributeExpr, codegen.UserTypeLocation(e.Type)); err != nil { + return nil, err + } } } unions := make([]*UnionTypeData, 0, len(unionByPackage)) @@ -982,7 +1014,14 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { if unions[i].Name != unions[j].Name { return unions[i].Name < unions[j].Name } - return unionPackageKey(unions[i].Loc, false) < unionPackageKey(unions[j].Loc, false) + var left, right string + if unions[i].Loc != nil { + left = unions[i].Loc.RelImportPath + } + if unions[j].Loc != nil { + right = unions[j].Loc.RelImportPath + } + return left < right }) desc := service.Description @@ -1016,9 +1055,10 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { } data.metaTypeImports = metaTypeImports(service, data) - d.Services[service.Name] = data - - return data + if err := d.registerPackageData(service, data); err != nil { + return nil, err + } + return data, nil } // collectInterceptors returns the set of interceptors defined on the given @@ -1070,58 +1110,111 @@ func projectedTypeContext(pkg string, ptr bool, scope *codegen.NameScope) *codeg } // collectTypes recurses through the attribute to gather all user types and -// records them in userTypes. -func collectTypes(at *expr.AttributeExpr, scopes *serviceNameScopes, seen map[string]struct{}, loc *codegen.Location) (data []*UserTypeData) { +// binds relocated types to their frozen package declarations. +func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.ServiceExpr, localScope *codegen.NameScope, seen map[userTypeDataKey]struct{}, loc *codegen.Location, branch *unionBranch) (data []*UserTypeData, err error) { if at == nil || at.Type == expr.Empty { - return data + return nil, nil } - collect := func(at *expr.AttributeExpr, loc *codegen.Location) []*UserTypeData { - return collectTypes(at, scopes, seen, loc) + collect := func(at *expr.AttributeExpr, loc *codegen.Location) error { + collected, err := d.collectTypes(at, service, localScope, seen, loc, nil) + data = append(data, collected...) + return err } switch dt := at.Type.(type) { case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return nil - } typeLoc := codegen.UserTypeLocation(dt) if typeLoc == nil { typeLoc = loc } - typeScope := scopes.forLocation(typeLoc) - if typeScope != scopes.local { - // Preserve the service-local reservations used by method, endpoint, - // and helper naming. Relocated declarations additionally use their - // owning package scope so cross-service files agree on type names. - scopes.local.GoTypeName(at) - scopes.local.GoTypeDef(dt.Attribute(), false, true) - scopes.local.GoTypeRef(at) + typeScope := localScope + var declaration *codegen.TypeDeclaration + if typeLoc != nil { + generatedPackage := d.generation.GeneratedPackage( + generatedPackagePath(d.generation.GenPkg, service, typeLoc), + ) + if branch == nil { + declaration, err = generatedPackage.UserType(d.rootTypes.canonical(dt)) + } else { + declaration, err = generatedPackage.UnionBranchType(branch.union, branch.name, dt) + } + if err != nil { + return nil, err + } + typeScope = generatedPackage.Scope() + // Keep the service-local reservations used by method and helper names; + // the relocated package supplies the public declaration names. + localScope.GoTypeName(at) + localScope.GoTypeDef(dt.Attribute(), false, true) + localScope.GoTypeRef(at) + } + key := userTypeDataKey{id: dt.ID(), declaration: declaration} + if _, ok := seen[key]; ok { + return nil, nil } data = append(data, &UserTypeData{ + Declaration: declaration, Name: dt.Name(), - VarName: typeScope.GoTypeName(at), + VarName: typeName(declaration, typeScope, at), Description: dt.Attribute().Description, Def: typeScope.GoTypeDef(dt.Attribute(), false, true), - Ref: typeScope.GoTypeRef(at), + Ref: userTypeRef(declaration, typeScope, at), Loc: typeLoc, Type: dt, }) - seen[dt.ID()] = struct{}{} - data = append(data, collect(dt.Attribute(), typeLoc)...) + seen[key] = struct{}{} + if err := collect(dt.Attribute(), typeLoc); err != nil { + return nil, err + } case *expr.Object: for _, nat := range *dt { - data = append(data, collect(nat.Attribute, loc)...) + if err := collect(nat.Attribute, loc); err != nil { + return nil, err + } } case *expr.Array: - data = append(data, collect(dt.ElemType, loc)...) + if err := collect(dt.ElemType, loc); err != nil { + return nil, err + } case *expr.Map: - data = append(data, collect(dt.KeyType, loc)...) - data = append(data, collect(dt.ElemType, loc)...) + if err := collect(dt.KeyType, loc); err != nil { + return nil, err + } + if err := collect(dt.ElemType, loc); err != nil { + return nil, err + } case *expr.Union: for _, nat := range dt.Values { - data = append(data, collect(nat.Attribute, loc)...) + if userType, ok := generatedUnionBranch(nat, d.rootTypes); ok && loc != nil { + collected, collectErr := d.collectTypes( + &expr.AttributeExpr{Type: userType}, + service, + localScope, + seen, + loc, + &unionBranch{union: dt, name: nat.Name}, + ) + data = append(data, collected...) + if collectErr != nil { + return nil, collectErr + } + continue + } + if err := collect(nat.Attribute, loc); err != nil { + return nil, err + } } } - return data + return data, nil +} + +// userTypeRef returns the reference spelling for a relocated declaration. A +// generated union branch alias is not registered as an ordinary scope type, so +// its package-owned declaration name is used directly. +func userTypeRef(declaration *codegen.TypeDeclaration, scope *codegen.NameScope, attribute *expr.AttributeExpr) string { + if declaration == nil { + return scope.GoTypeRef(attribute) + } + return declaration.Ref(attribute.Type) } // collectUnionTypes traverses the attribute to gather all union sum-type @@ -1131,76 +1224,90 @@ func collectTypes(at *expr.AttributeExpr, scopes *serviceNameScopes, seen map[st // provided location is used for all nested user types so that unions are // generated in the views package and refer to view-local types (preventing // import cycles). -func collectUnionTypes(att *expr.AttributeExpr, scopes *serviceNameScopes, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}, view bool) { +func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr.ServiceExpr, localScope *codegen.NameScope, loc *codegen.Location, unions map[unionDataKey]*UnionTypeData, seen map[string]struct{}, view bool) error { if att == nil || att.Type == expr.Empty { - return + return nil + } + recurse := func(att *expr.AttributeExpr, loc *codegen.Location) error { + return d.collectUnionTypes(att, service, localScope, loc, unions, seen, view) } switch dt := att.Type.(type) { case expr.UserType: if _, ok := seen[dt.ID()]; ok { - return + return nil } seen[dt.ID()] = struct{}{} typeLoc := loc if !view { - typeLoc = codegen.UserTypeLocation(dt) + if ownLocation := codegen.UserTypeLocation(dt); ownLocation != nil { + typeLoc = ownLocation + } } - collectUnionTypes(dt.Attribute(), scopes, typeLoc, unions, seen, view) + return recurse(dt.Attribute(), typeLoc) case *expr.Object: for _, nat := range sortedNamedAttributes(*dt) { - collectUnionTypes(nat.Attribute, scopes, loc, unions, seen, view) + if err := recurse(nat.Attribute, loc); err != nil { + return err + } } case *expr.Array: - collectUnionTypes(dt.ElemType, scopes, loc, unions, seen, view) + return recurse(dt.ElemType, loc) case *expr.Map: - collectUnionTypes(dt.KeyType, scopes, loc, unions, seen, view) - collectUnionTypes(dt.ElemType, scopes, loc, unions, seen, view) + if err := recurse(dt.KeyType, loc); err != nil { + return err + } + return recurse(dt.ElemType, loc) case *expr.Union: - key := codegen.UnionTypeHash(dt) + "\x00" + unionPackageKey(loc, view) + packagePath := servicePackagePath(d.generation.GenPkg, service) + if view { + packagePath += "/views" + } else if loc != nil { + packagePath = generatedPackagePath(d.generation.GenPkg, service, loc) + } + key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(dt)} if _, ok := unions[key]; !ok { - unionScope := scopes.local - if !view { - unionScope = scopes.forLocation(loc) + unionScope := localScope + var declaration *codegen.UnionDeclaration + var branchLookup unionBranchLookup + if !view && loc != nil { + generatedPackage := d.generation.GeneratedPackage(packagePath) + var err error + declaration, err = generatedPackage.Union(dt) + if err != nil { + return err + } + unionScope = generatedPackage.Scope() + branchLookup = func(branch *expr.NamedAttributeExpr) (*codegen.TypeDeclaration, error) { + userType, ok := generatedUnionBranch(branch, d.rootTypes) + if !ok { + return nil, nil + } + return generatedPackage.UnionBranchType(dt, branch.Name, userType) + } + } + unionData, err := buildUnionTypeData(dt, declaration, unionScope, loc, view, branchLookup) + if err != nil { + return err + } + if view { + unions[key] = unionData + } else { + owner := d.generatedPackage(service, loc) + ownedUnion, ok := owner.unions[key.identity] + if !ok { + ownedUnion = unionData + owner.unions[key.identity] = ownedUnion + } + unions[key] = ownedUnion } - unions[key] = buildUnionTypeData(dt, unionScope, loc, view) } for _, nat := range dt.Values { - collectUnionTypes(nat.Attribute, scopes, loc, unions, seen, view) + if err := recurse(nat.Attribute, loc); err != nil { + return err + } } } -} - -// forLocation returns the identifier scope for the package that owns loc. -// Relocated types from different services share a scope because their files -// compile together; a nil location belongs to the current service package. -func (s *serviceNameScopes) forLocation(loc *codegen.Location) *codegen.NameScope { - if loc == nil || loc.RelImportPath == "" { - return s.local - } - return s.packages.scope(loc.RelImportPath) -} - -// scope returns the identifier scope for path, creating it on first use. -func (s *packageScopes) scope(path string) *codegen.NameScope { - if scope, ok := s.scopes[path]; ok { - return scope - } - scope := codegen.NewNameScope() - s.scopes[path] = scope - return scope -} - -// unionPackageKey identifies the generated package that owns a union. A nil -// location is the current service package; viewed unions share the views -// package regardless of locations inherited from service types. -func unionPackageKey(loc *codegen.Location, view bool) string { - if view { - return "views" - } - if loc == nil { - return "" - } - return loc.RelImportPath + return nil } // buildUnionTypeData creates the data needed to generate a sum-type union @@ -1208,12 +1315,18 @@ func unionPackageKey(loc *codegen.Location, view bool) string { // union is generated in the views package: field types are computed using the // view scope and are always emitted unqualified so they refer to the // view-local projected types. -func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Location, view bool) *UnionTypeData { +func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, scope *codegen.NameScope, loc *codegen.Location, view bool, branchLookup unionBranchLookup) (*UnionTypeData, error) { att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.HashedUnique(&unionCompanionKey{union: u, role: "kind"}, name+"Kind") + var name, kindName string + if declaration != nil { + name = declaration.Name + kindName = declaration.KindName + } else { + name = scope.GoTypeName(att) + kindName = scope.Unique(name + "Kind") + } var unionPkg string - if !view { + if !view && loc != nil { unionPkg = loc.PackageName() } @@ -1229,7 +1342,19 @@ func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Lo } } } - fieldType := scope.GoFullTypeRef(nat.Attribute, pkg) + var fieldType string + if branchLookup != nil { + branchDeclaration, err := branchLookup(nat) + if err != nil { + return nil, err + } + if branchDeclaration != nil && pkg == "" { + fieldType = userTypeRef(branchDeclaration, scope, nat.Attribute) + } + } + if fieldType == "" { + fieldType = scope.GoFullTypeRef(nat.Attribute, pkg) + } primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) _, isUserType := nat.Attribute.Type.(expr.UserType) emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && pkg == "" @@ -1247,18 +1372,40 @@ func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Lo } return &UnionTypeData{ - Name: name, - KindName: kindName, - Fields: fields, - Loc: loc, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), + Declaration: declaration, + Name: name, + KindName: kindName, + Fields: fields, + Loc: loc, + TypeKey: u.GetTypeKey(), + ValueKey: u.GetValueKey(), + }, nil +} + +// typeName returns declaration's frozen name for a relocated type and falls +// back to the service-local scope for ordinary service and view types. +func typeName(declaration *codegen.TypeDeclaration, scope *codegen.NameScope, attribute *expr.AttributeExpr) string { + if declaration != nil { + return declaration.Name } + return scope.GoTypeName(attribute) } -// Hash returns the structural identity of one generated union companion. -func (k *unionCompanionKey) Hash() string { - return codegen.UnionTypeHash(k.union) + "\x00" + k.role +// typeScope returns the frozen package scope and declaration for a relocated +// user type, or the mutable service scope for an ordinary service type. +func (d *ServicesData) typeScope(service *expr.ServiceExpr, localScope *codegen.NameScope, attribute *expr.AttributeExpr, location *codegen.Location) (*codegen.NameScope, *codegen.TypeDeclaration, error) { + if location == nil { + return localScope, nil, nil + } + userType := attribute.Type.(expr.UserType) + generatedPackage := d.generation.GeneratedPackage( + generatedPackagePath(d.generation.GenPkg, service, location), + ) + declaration, err := generatedPackage.UserType(d.rootTypes.canonical(userType)) + if err != nil { + return nil, nil, err + } + return generatedPackage.Scope(), declaration, nil } // sortedNamedAttributes returns object fields sorted by attribute name. @@ -1314,7 +1461,7 @@ func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInit // buildMethodData creates the data needed to render the given endpoint. It // records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameScopes) *MethodData { +func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope) (*MethodData, error) { var ( vname string desc string @@ -1336,7 +1483,6 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameSc reqs = make(RequirementsData, 0, len(m.Requirements)) schemes SchemesData ) - scope := scopes.local vname = scope.Unique(codegen.Goify(m.Name, true), "Endpoint") desc = m.Description if desc == "" { @@ -1344,8 +1490,11 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameSc } if m.Payload.Type != expr.Empty { payloadLoc = codegen.UserTypeLocation(m.Payload.Type) - payloadScope := scopes.forLocation(payloadLoc) - payloadName = payloadScope.GoTypeName(m.Payload) + payloadScope, declaration, err := d.typeScope(m.Service, scope, m.Payload, payloadLoc) + if err != nil { + return nil, err + } + payloadName = typeName(declaration, payloadScope, m.Payload) if dt, ok := m.Payload.Type.(expr.UserType); ok { payloadDef = payloadScope.GoTypeDef(dt.Attribute(), false, true) } @@ -1359,8 +1508,11 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameSc } if m.Result.Type != expr.Empty { resultLoc = codegen.UserTypeLocation(m.Result.Type) - resultScope := scopes.forLocation(resultLoc) - rname = resultScope.GoTypeName(m.Result) + resultScope, declaration, err := d.typeScope(m.Service, scope, m.Result, resultLoc) + if err != nil { + return nil, err + } + rname = typeName(declaration, resultScope, m.Result) if dt, ok := m.Result.Type.(expr.UserType); ok { resultDef = resultScope.GoTypeDef(dt.Attribute(), false, true) } @@ -1462,14 +1614,16 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scopes *serviceNameSc ResponseStruct: vname + "ResponseData", } - d.initStreamData(data, m, vname, rname, resultRef, scope) - return data + if err := d.initStreamData(data, m, vname, rname, resultRef, scope); err != nil { + return nil, err + } + return data, nil } // initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope) { +func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope) error { if !m.IsStreaming() && !m.HasMixedResults() { - return + return nil } var ( spayloadName string @@ -1483,12 +1637,21 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam // If StreamingResult is different from Result, use it for streaming if m.HasMixedResults() && m.StreamingResult != nil && m.StreamingResult.Type != expr.Empty { - srname = scope.GoTypeName(m.StreamingResult) - srref = scope.GoTypeRef(m.StreamingResult) + resultScope, declaration, err := d.typeScope( + m.Service, + scope, + m.StreamingResult, + codegen.UserTypeLocation(m.StreamingResult.Type), + ) + if err != nil { + return err + } + srname = typeName(declaration, resultScope, m.StreamingResult) + srref = resultScope.GoTypeRef(m.StreamingResult) data.StreamingResult = srname data.StreamingResultRef = srref if dt, ok := m.StreamingResult.Type.(expr.UserType); ok { - data.StreamingResultDef = scope.GoTypeDef(dt.Attribute(), false, true) + data.StreamingResultDef = resultScope.GoTypeDef(dt.Attribute(), false, true) } data.StreamingResultDesc = m.StreamingResult.Description if data.StreamingResultDesc == "" { @@ -1499,10 +1662,19 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam } if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { - spayloadName = scope.GoTypeName(m.StreamingPayload) - spayloadRef = scope.GoTypeRef(m.StreamingPayload) + payloadScope, declaration, err := d.typeScope( + m.Service, + scope, + m.StreamingPayload, + codegen.UserTypeLocation(m.StreamingPayload.Type), + ) + if err != nil { + return err + } + spayloadName = typeName(declaration, payloadScope, m.StreamingPayload) + spayloadRef = payloadScope.GoTypeRef(m.StreamingPayload) if dt, ok := m.StreamingPayload.Type.(expr.UserType); ok { - spayloadDef = scope.GoTypeDef(dt.Attribute(), false, true) + spayloadDef = payloadScope.GoTypeDef(dt.Attribute(), false, true) } spayloadDesc = m.StreamingPayload.Description if spayloadDesc == "" { @@ -1597,6 +1769,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam data.StreamingPayloadRef = spayloadRef data.StreamingPayloadDesc = spayloadDesc data.StreamingPayloadEx = spayloadEx + return nil } // buildInterceptorData creates the data needed to generate interceptor code. diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index 2db6834805..89f2cc17ec 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -11,12 +11,15 @@ import ( func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildUnionTypeData( + data, err := buildUnionTypeData( union, + nil, codegen.NewNameScope(), &codegen.Location{RelImportPath: "gen/service"}, false, + nil, ) + assert.NoError(t, err) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index 5b94d90d5e..be78d9a020 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -63,20 +63,34 @@ func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { } func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location) map[string]string { - scope := codegen.NewNameScope() - scopes := &serviceNameScopes{ - local: scope, - packages: &packageScopes{ - scopes: make(map[string]*codegen.NameScope), - }, + service := &expr.ServiceExpr{Name: "test"} + generation := codegen.NewGeneration("generated.local/gen", nil) + generatedPackage := generation.GeneratedPackage( + generatedPackagePath(generation.GenPkg, service, loc), + ) + object := att.Type.(*expr.Object) + for _, named := range *object { + _, err := generatedPackage.DeclareUnion(named.Attribute.Type.(*expr.Union)) + if err != nil { + panic(err) + } + } + if err := generation.Freeze(); err != nil { + panic(err) + } + services := &ServicesData{ + generation: generation, + packages: make(map[string]*generatedPackageData), } seen := make(map[string]struct{}) - unionByHash := make(map[string]*UnionTypeData) - collectUnionTypes(att, scopes, loc, unionByHash, seen, false) + unionByHash := make(map[unionDataKey]*UnionTypeData) + if err := services.collectUnionTypes(att, service, codegen.NewNameScope(), loc, unionByHash, seen, false); err != nil { + panic(err) + } names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name + for key, data := range unionByHash { + names[string(key.identity)] = data.Name } return names } diff --git a/codegen/service/service_dedup_test.go b/codegen/service/service_dedup_test.go index 3ddf22599e..897e0f25dd 100644 --- a/codegen/service/service_dedup_test.go +++ b/codegen/service/service_dedup_test.go @@ -15,10 +15,10 @@ import ( // same result type the generated service code only emits a single event marker method. func TestService_DedupEventMarkers(t *testing.T) { root := codegen.RunDSL(t, stest.StreamingDuplicateResultTypesDSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := Files("goa.design/goa/example", []*ServicesData{services}) require.Greater(t, len(files), 0) // Generate the service.go content diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 6ab281fca8..08c326ca4b 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -12,9 +12,225 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) +func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { + var shared expr.UserType + root := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + first := dsl.Type("FirstPayload", func() { + dsl.Attribute("shared", shared) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Attribute("shared", shared) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + require.Panics(t, func() { + _, _ = NewServicesData(root, generation) + }) + require.NoError(t, generation.Freeze()) + services, err := NewServicesData(root, generation) + require.NoError(t, err) + + first := services.Get("First") + second := services.Get("Second") + firstShared := findUserTypeData(first.userTypes, shared) + secondShared := findUserTypeData(second.userTypes, shared) + require.NotNil(t, firstShared) + require.NotNil(t, secondShared) + require.Same(t, firstShared.Declaration, secondShared.Declaration) + require.Len(t, first.unions, 1) + require.Len(t, second.unions, 1) + require.Same(t, first.unions[0].Declaration, second.unions[0].Declaration) + require.Equal(t, "Value", first.unions[0].Name) + require.Equal(t, "ValueKind", first.unions[0].KindName) + + _, err = generation.GeneratedPackage("goa.design/goa/example/types").DeclareUserType(shared) + require.ErrorContains(t, err, "frozen") +} + +func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + services := mustServicesData(t, root) + files := Files("goa.design/goa/example", []*ServicesData{services}) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "third_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) +} + +func TestFilesEmitsDifferentSameBaseUnionsWithFrozenNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("number", dsl.Int) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + files := Files("goa.design/goa/example", []*ServicesData{mustServicesData(t, root)}) + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type Value2 struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) +} + +func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { + var firstType expr.UserType + firstRoot := codegen.RunDSL(t, func() { + firstType = dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(firstType) + }) + }) + }) + var secondType expr.UserType + secondRoot := codegen.RunDSL(t, func() { + secondType = dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(secondType) + }) + }) + }) + + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + require.NoError(t, Plan(firstRoot, generation)) + require.NoError(t, Plan(secondRoot, generation)) + firstUnion := expr.AsObject(firstType).Attribute("Value").Type.(*expr.Union) + secondUnion := expr.AsObject(secondType).Attribute("Value").Type.(*expr.Union) + firstAlias := firstUnion.Values[0].Attribute.Type.(expr.UserType) + secondAlias := secondUnion.Values[0].Attribute.Type.(expr.UserType) + generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") + firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.Same(t, firstBranch, secondBranch) + + require.NoError(t, generation.Freeze()) + firstServices, err := NewServicesData(firstRoot, generation) + require.NoError(t, err) + secondServices, err := NewServicesData(secondRoot, generation) + require.NoError(t, err) + files := Files("goa.design/goa/example", []*ServicesData{firstServices, secondServices}) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "value_text.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "first_service", "service.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "second_service", "service.go"))) +} + +func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T) { + var ( + exact expr.UserType + container expr.UserType + ) + root := codegen.RunDSL(t, func() { + exact = dsl.Type("Value-Text", dsl.String, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + }) + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Payload(container) + }) + }) + }) + + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + union := expr.AsObject(container).Attribute("Value").Type.(*expr.Union) + alias := union.Values[0].Attribute.Type.(expr.UserType) + generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") + exactDeclaration, err := generatedPackage.UserType(exact) + require.NoError(t, err) + branchDeclaration, err := generatedPackage.UnionBranchType(union, "text", alias) + require.NoError(t, err) + require.NotSame(t, exactDeclaration, branchDeclaration) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText", exactDeclaration.Name) + require.Equal(t, "ValueText2", branchDeclaration.Name) + services, err := NewServicesData(root, generation) + require.NoError(t, err) + typeFile := findFile( + Files("goa.design/goa/example", []*ServicesData{services}), + filepath.Join("gen", "types", "value_text.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "type ValueText string") + require.Contains(t, code, "type ValueText2 string") +} + func TestService(t *testing.T) { cases := []struct { Name string @@ -65,19 +281,12 @@ func TestService(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := Files("goa.design/goa/example", []*ServicesData{services}) require.Greater(t, len(files), 0) - // Generate the code - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err, buf.String()) - code := string(bs) + code := renderServiceGolden(t, files, files[0]) // Compare with golden file testutil.AssertGo(t, "testdata/golden/service_"+c.Name+".go.golden", code) @@ -106,28 +315,24 @@ func TestStructPkgPath(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - userTypePkgs := make(map[string][]string) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) - files := Files("goa.design/goa/example", root.Services[0], services, userTypePkgs) + services := mustServicesData(t, root) + files := Files("goa.design/goa/example", []*ServicesData{services}) // Check file count - expectedFiles := len(c.TypeFiles) + 1 + expectedFiles := len(c.TypeFiles) + len(root.Services) require.Len(t, files, expectedFiles, "unexpected number of files") - // First file is always the service file - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err) - testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", string(bs)) + serviceFile := findFile(files, filepath.Join(codegen.Gendir, services.Get(root.Services[0].Name).PathName, "service.go")) + require.NotNil(t, serviceFile) + testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", renderServiceGolden(t, files, serviceFile)) // Type files - for i, typeFile := range c.TypeFiles { + for _, typeFile := range c.TypeFiles { + file := findFile(files, typeFile) + require.NotNil(t, file) buf := new(bytes.Buffer) - for _, s := range files[i+1].SectionTemplates[1:] { + for _, s := range file.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) } bs, err := format.Source(buf.Bytes()) @@ -138,7 +343,7 @@ func TestStructPkgPath(t *testing.T) { // For dupes case, test the second service if c.Name == "dupes" && len(root.Services) > 1 { - files = Files("goa.design/goa/example", root.Services[1], services, userTypePkgs) + files = serviceFiles("goa.design/goa/example", root.Services[1], services) require.Len(t, files, 1) buf := new(bytes.Buffer) for _, s := range files[0].SectionTemplates[1:] { @@ -154,23 +359,17 @@ func TestStructPkgPath(t *testing.T) { func TestStructPkgPath_UnionImportsJSON(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionDSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := Files("goa.design/goa/example", []*ServicesData{services}) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected generated union file in struct:pkg:path package") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() @@ -181,18 +380,16 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) render := func(servicesToRender []*expr.ServiceExpr) string { - services := NewServicesData(root) - userTypePkgs := make(map[string][]string) + services := mustServicesData(t, root) var generated strings.Builder - for _, service := range servicesToRender { - files := Files("goa.design/goa/example", service, services, userTypePkgs) - for _, file := range files { - if !strings.Contains(file.Path, filepath.Join("gen", "types")) { - continue - } - for _, section := range file.SectionTemplates { - require.NoError(t, section.Write(&generated)) - } + _ = servicesToRender + files := Files("goa.design/goa/example", []*ServicesData{services}) + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) } } return generated.String() @@ -230,25 +427,105 @@ func unionFieldType(code, owner string) string { return code[start : start+end] } +// mustServicesData runs the standalone declaration lifecycle used by service +// tests and returns the frozen render analysis. +func mustServicesData(t *testing.T, root *expr.RootExpr) *ServicesData { + t.Helper() + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := NewServicesData(root, generation) + require.NoError(t, err) + return services +} + +// countFiles returns how many generated files have the given path. +func countFiles(files []*codegen.File, path string) int { + count := 0 + for _, file := range files { + if file.Path == path { + count++ + } + } + return count +} + +// findFile returns the generated file with path, or nil when no file matches. +func findFile(files []*codegen.File, path string) *codegen.File { + for _, file := range files { + if file.Path == path { + return file + } + } + return nil +} + +// findUserTypeData returns the render data for userType. +func findUserTypeData(types []*UserTypeData, userType expr.UserType) *UserTypeData { + for _, data := range types { + if data.Type == userType { + return data + } + } + return nil +} + +// renderSections renders sections without writing a generated file. +func renderSections(t *testing.T, sections []*codegen.SectionTemplate) string { + t.Helper() + var rendered strings.Builder + for _, section := range sections { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} + +// renderServiceGolden reconstructs the former single-file declaration order +// so existing service golden assertions remain unchanged after unions move to +// their package-owned unions.go file. +func renderServiceGolden(t *testing.T, files []*codegen.File, serviceFile *codegen.File) string { + t.Helper() + sections := append([]*codegen.SectionTemplate(nil), serviceFile.SectionTemplates[1:]...) + unionFile := findFile(files, filepath.Join(filepath.Dir(serviceFile.Path), "unions.go")) + if unionFile != nil { + insertAt := len(sections) + for i, section := range sections { + switch section.Name { + case "error-init-func", "viewed-result-type-to-service-result-type", + "service-result-type-to-viewed-result-type", "projected-type-to-service-type", + "service-type-to-projected-type", "transform-helpers": + insertAt = i + } + if insertAt != len(sections) { + break + } + } + sections = append(sections, make([]*codegen.SectionTemplate, len(unionFile.SectionTemplates)-1)...) + copy(sections[insertAt+len(unionFile.SectionTemplates)-1:], sections[insertAt:]) + copy(sections[insertAt:], unionFile.SectionTemplates[1:]) + } + buf := new(bytes.Buffer) + for _, section := range sections { + require.NoError(t, section.Write(buf)) + } + formatted, err := format.Source(buf.Bytes()) + require.NoError(t, err, buf.String()) + return string(formatted) +} + func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionJSONFieldDSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := Files("goa.design/goa/example", []*ServicesData{services}) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_json_field_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_json_field_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected package-owned union file") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() @@ -270,23 +547,26 @@ func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathExtendedUnionDSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := Files("goa.design/goa/example", []*ServicesData{services}) require.GreaterOrEqual(t, len(files), 2) - var serviceFile, sharedTypeFile *codegen.File + var serviceFile, localUnionFile, sharedUnionFile *codegen.File for _, f := range files { switch { case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "service.go")): serviceFile = f - case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "equipment_scope.go")): - sharedTypeFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "unions.go")): + localUnionFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "unions.go")): + sharedUnionFile = f } } require.NotNil(t, serviceFile) - require.NotNil(t, sharedTypeFile) + require.NotNil(t, localUnionFile) + require.NotNil(t, sharedUnionFile) render := func(file *codegen.File) string { buf := new(bytes.Buffer) @@ -298,8 +578,9 @@ func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { return string(code) } serviceCode := render(serviceFile) - sharedTypeCode := render(sharedTypeFile) + localUnionCode := render(localUnionFile) + sharedUnionCode := render(sharedUnionFile) require.Contains(t, serviceCode, "Scope Scope") - require.Contains(t, serviceCode, "type Scope struct") - require.Contains(t, sharedTypeCode, "type Scope struct") + require.Contains(t, localUnionCode, "type Scope struct") + require.Contains(t, sharedUnionCode, "type Scope struct") } diff --git a/codegen/service/views.go b/codegen/service/views.go index 0c14f18fbc..ce484dde40 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -28,12 +28,21 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod // View-projected types cannot import the service package (which already // depends on views), therefore unions must be generated in the views package // when referenced by projected types. - unionByHash := make(map[string]*UnionTypeData) + unionByHash := make(map[unionDataKey]*UnionTypeData) seenUnions := make(map[string]struct{}) viewLoc := &codegen.Location{RelImportPath: "views"} - viewScopes := &serviceNameScopes{local: svc.ViewScope} for _, t := range svc.projectedTypes { - collectUnionTypes(&expr.AttributeExpr{Type: t.Type}, viewScopes, viewLoc, unionByHash, seenUnions, true) + if err := services.collectUnionTypes( + &expr.AttributeExpr{Type: t.Type}, + service, + svc.ViewScope, + viewLoc, + unionByHash, + seenUnions, + true, + ); err != nil { + panic(err) // bug + } } unions := make([]*UnionTypeData, 0, len(unionByHash)) for _, u := range unionByHash { diff --git a/codegen/service/views_test.go b/codegen/service/views_test.go index 5a1b50bf8f..ec887bf09b 100644 --- a/codegen/service/views_test.go +++ b/codegen/service/views_test.go @@ -34,7 +34,7 @@ func TestViews(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + services := mustServicesData(t, root) require.Len(t, root.Services, 1) fs := ViewsFile("goa.design/goa/example", root.Services[0], services) require.NotNil(t, fs) diff --git a/expr/dup.go b/expr/dup.go index b658286526..e396adf408 100644 --- a/expr/dup.go +++ b/expr/dup.go @@ -23,14 +23,14 @@ func DupAtt(att *AttributeExpr) *AttributeExpr { // dupper implements recursive and cycle safe copy of data types. type dupper struct { - uts map[string]UserType + uts map[UserType]UserType ats map[*AttributeExpr]struct{} } // newDupper returns a new initialized dupper. func newDupper() *dupper { return &dupper{ - uts: make(map[string]UserType), + uts: make(map[UserType]UserType), ats: make(map[*AttributeExpr]struct{}), } } @@ -101,11 +101,12 @@ func (d *dupper) DupType(t DataType) DataType { } return &dp case UserType: - if u, ok := d.uts[actual.ID()]; ok { + origin := actual.Origin() + if u, ok := d.uts[origin]; ok { return u } dp := actual.Dup(nil) - d.uts[actual.ID()] = dp + d.uts[origin] = dp dupAtt := d.DupAttribute(actual.Attribute()) dp.SetAttribute(dupAtt) diff --git a/expr/dup_test.go b/expr/dup_test.go index f1295a20c8..b091f058e7 100644 --- a/expr/dup_test.go +++ b/expr/dup_test.go @@ -21,3 +21,35 @@ func TestDupPreservesNonNullableArrayElements(t *testing.T) { assert.NotSame(t, original.ElemType, duplicate.ElemType) assert.True(t, duplicate.NonNullableElems) } + +func TestDupKeepsRootTypeAndSameNameUnionAliasDistinct(t *testing.T) { + rootType := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "ValueBool", + } + unionAlias := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: Boolean}, + TypeName: "ValueBool", + } + attribute := &AttributeExpr{Type: &Object{ + {Name: "root", Attribute: &AttributeExpr{Type: rootType}}, + {Name: "choice", Attribute: &AttributeExpr{Type: &Union{ + TypeName: "Value", + Values: []*NamedAttributeExpr{{ + Name: "bool", + Attribute: &AttributeExpr{Type: unionAlias}, + }}, + }}}, + }} + + duplicate := DupAtt(attribute) + object := duplicate.Type.(*Object) + rootCopy := object.Attribute("root").Type.(UserType) + union := object.Attribute("choice").Type.(*Union) + aliasCopy := union.Values[0].Attribute.Type.(UserType) + require.NotSame(t, rootCopy, aliasCopy) + require.Same(t, rootType, rootCopy.Origin()) + require.Same(t, unionAlias, aliasCopy.Origin()) + require.Equal(t, String, rootCopy.Attribute().Type) + require.Equal(t, Boolean, aliasCopy.Attribute().Type) +} diff --git a/expr/result_type.go b/expr/result_type.go index 5876bd5a58..c5f3a98f19 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -29,6 +29,9 @@ type ( ContentType string // Views list the supported views indexed by name. Views []*ViewExpr + // origin is the earliest result type declaration copied to create this + // result type. + origin UserType } // ViewExpr defines which fields to render when building a response. The view @@ -137,9 +140,20 @@ func (rt *ResultTypeExpr) Dup(att *AttributeExpr) UserType { UserTypeExpr: rt.UserTypeExpr.Dup(att).(*UserTypeExpr), Identifier: rt.Identifier, Views: rt.Views, + origin: rt.Origin(), } } +// Origin returns the earliest result type declaration from which rt was +// copied. Result types override their embedded user-type origin so the dynamic +// result-type identity is preserved. +func (rt *ResultTypeExpr) Origin() UserType { + if rt.origin != nil { + return rt.origin + } + return rt +} + // ID returns the identifier of the result type. func (rt *ResultTypeExpr) ID() string { return rt.Identifier @@ -148,6 +162,13 @@ func (rt *ResultTypeExpr) ID() string { // Name returns the result type name. func (rt *ResultTypeExpr) Name() string { return rt.TypeName } +// Rename changes the result type name and starts a new generated declaration +// origin at rt. +func (rt *ResultTypeExpr) Rename(name string) { + rt.UserTypeExpr.Rename(name) + rt.origin = nil +} + // View returns the view with the given name. func (rt *ResultTypeExpr) View(name string) *ViewExpr { for _, v := range rt.Views { diff --git a/expr/types.go b/expr/types.go index ea789c1e6e..21b2a833fb 100644 --- a/expr/types.go +++ b/expr/types.go @@ -78,6 +78,9 @@ type ( CompositeExpr // ID returns the identifier for the user type. ID() string + // Origin returns the earliest comparable user type declaration from + // which this value was copied. + Origin() UserType // Rename changes the type name to the given value. Rename(string) // SetAttribute updates the underlying attribute. diff --git a/expr/user_type.go b/expr/user_type.go index 0d81160f7e..d9adf30c96 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -14,6 +14,8 @@ type ( TypeName string // UID of type UID string + // origin is the earliest declaration copied to create this type. + origin UserType } ) @@ -25,6 +27,14 @@ func (u *UserTypeExpr) ID() string { return u.Name() } +// Origin returns the earliest user type declaration from which u was copied. +func (u *UserTypeExpr) Origin() UserType { + if u.origin != nil { + return u.origin + } + return u +} + // Kind implements DataKind. func (*UserTypeExpr) Kind() Kind { return UserTypeKind } @@ -45,6 +55,7 @@ func (u *UserTypeExpr) Rename(n string) { u.AddMeta("name:original", u.TypeName) delete(u.Meta, "struct:type:name") u.TypeName = n + u.origin = nil } // IsCompatible returns true if u describes the (Go) type of val. @@ -72,6 +83,7 @@ func (u *UserTypeExpr) Dup(att *AttributeExpr) UserType { AttributeExpr: att, TypeName: u.TypeName, UID: u.UID, + origin: u.Origin(), } } diff --git a/expr/user_type_test.go b/expr/user_type_test.go index a373a66d91..614fb8b55e 100644 --- a/expr/user_type_test.go +++ b/expr/user_type_test.go @@ -1,6 +1,45 @@ package expr -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserTypeOrigin(t *testing.T) { + original := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Value", + } + copy := original.Dup(DupAtt(original.Attribute())).(*UserTypeExpr) + copyOfCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, original, original.Origin()) + require.Same(t, original, copy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) +} + +func TestIndependentUserTypesHaveDistinctOrigins(t *testing.T) { + first := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + second := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + require.NotSame(t, first.Origin(), second.Origin()) +} + +func TestResultTypeOriginPreservesDynamicType(t *testing.T) { + original := NewResultTypeExpr("Value", "application/vnd.value", nil) + copy := original.Dup(DupAtt(original.Attribute())).(*ResultTypeExpr) + require.Same(t, original, copy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*ResultTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) +} func TestUserTypeExprName(t *testing.T) { var ( diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index 1862de04b1..24908f2830 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -9,7 +9,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -33,7 +32,7 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) + services := NewServicesData(createServiceServices(root)) fs := ExampleCLIFiles(c.PkgPath, services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index 7d553a3b5e..fcc5940e38 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -9,7 +9,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" ) @@ -27,7 +26,7 @@ func TestExampleServerFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) + services := NewServicesData(createServiceServices(root)) fs := ExampleServerFiles("", services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index 103d5c9360..fcbfe8ee75 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -22,7 +22,24 @@ func RunGRPCDSL(t *testing.T, dsl func()) *expr.RootExpr { // generators read the design. func CreateGRPCServices(root *expr.RootExpr) *ServicesData { codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root)) + return NewServicesData(createServiceServices(root)) +} + +// createServiceServices performs the complete package declaration lifecycle +// required by transport test helpers. +func createServiceServices(root *expr.RootExpr) *service.ServicesData { + generation := codegen.NewGeneration("goa.design/goa/example", nil) + if err := service.Plan(root, generation); err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + services, err := service.NewServicesData(root, generation) + if err != nil { + panic(err) + } + return services } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 032358dfd8..45f9120c78 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -10,7 +10,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,7 +30,7 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) + httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) fs := ExampleCLIFiles("", httpServices) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index d28d4d73f5..babe1d64dd 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -11,7 +11,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/http/codegen/testdata" ) @@ -35,7 +34,7 @@ func TestExampleServerFiles(t *testing.T) { example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) + httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) fs := ExampleServerFiles("", httpServices) require.Len(t, fs, 2) for i, f := range fs { @@ -71,7 +70,7 @@ func TestExampleServerFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) + httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) fs := ExampleServerFiles("", httpServices) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 94e1b0023c..3ee78ab97f 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -11,5 +11,22 @@ import ( // generators read the design. func CreateHTTPServices(root *expr.RootExpr) *ServicesData { codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root), root.API.HTTP) + return NewServicesData(createServiceServices(root), root.API.HTTP) +} + +// createServiceServices performs the complete package declaration lifecycle +// required by transport test helpers. +func createServiceServices(root *expr.RootExpr) *service.ServicesData { + generation := codegen.NewGeneration("goa.design/goa/example", nil) + if err := service.Plan(root, generation); err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + services, err := service.NewServicesData(root, generation) + if err != nil { + panic(err) + } + return services } diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index b423926d31..e733c5a2ad 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -12,6 +12,7 @@ import ( goacodegen "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/generator" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" @@ -30,10 +31,13 @@ func TestJSONRPCKitchenSink(t *testing.T) { // design normalization generator.Generate runs before them. goacodegen.NormalizeRoot(root) roots := []eval.Root{root} + generation := goacodegen.NewGeneration("kitchensink", roots) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, generation.Freeze()) - tfiles, err := generator.Transport("kitchensink", roots) + tfiles, err := generator.Transport(generation) require.NoError(t, err) - efiles, err := generator.Example("kitchensink", roots) + efiles, err := generator.Example(generation) require.NoError(t, err) tmp := t.TempDir() diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 1da00ba7d0..790b24de70 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -12,6 +12,23 @@ import ( // does before the generators read the design. func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { codegen.NormalizeRoot(root) - services := service.NewServicesData(root) + services := createServiceServices(root) return httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) } + +// createServiceServices performs the complete package declaration lifecycle +// required by transport test helpers. +func createServiceServices(root *expr.RootExpr) *service.ServicesData { + generation := codegen.NewGeneration("goa.design/goa/example", nil) + if err := service.Plan(root, generation); err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + services, err := service.NewServicesData(root, generation) + if err != nil { + panic(err) + } + return services +} From 4ca1f70c84cd90519287e8dad074d4654038f226 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 01:28:23 -0700 Subject: [PATCH 16/43] codegen: complete generated package ownership --- codegen/generated_types.go | 260 ++++++- codegen/generated_types_test.go | 75 +- codegen/generator/example.go | 9 +- codegen/generator/service.go | 41 +- .../service_union_package_scope_test.go | 246 +++++- codegen/generator/transport.go | 9 +- codegen/go_transform.go | 35 +- codegen/import.go | 20 +- codegen/import_test.go | 41 + codegen/scope.go | 14 + codegen/scope_test.go | 70 +- codegen/service/client.go | 6 +- codegen/service/convert.go | 39 +- codegen/service/declaration_resolver.go | 303 ++++++++ codegen/service/declaration_resolver_test.go | 177 +++++ codegen/service/endpoint.go | 4 + codegen/service/example_interceptors.go | 32 +- codegen/service/example_svc.go | 18 +- codegen/service/generated_package.go | 135 +++- codegen/service/imports.go | 131 ++++ codegen/service/interceptors_test.go | 8 +- codegen/service/service.go | 136 +--- codegen/service/service_data.go | 735 +++++++++--------- .../service_data_union_nilability_test.go | 18 +- .../service/service_data_union_order_test.go | 7 +- codegen/service/service_test.go | 83 +- codegen/service/templates/union_type.go.tpl | 4 +- ...g_path_payload_attribute_service.go.golden | 2 +- ...ce-result-with-inline-validation.go.golden | 23 + codegen/service/views.go | 18 +- codegen/transformer.go | 114 +-- codegen/union.go | 40 +- codegen/validation.go | 11 +- expr/user_type.go | 10 +- grpc/codegen/client.go | 6 +- grpc/codegen/client_cli.go | 4 +- grpc/codegen/protobuf.go | 21 +- grpc/codegen/server.go | 6 +- grpc/codegen/service_imports.go | 40 + grpc/codegen/types.go | 6 +- http/codegen/client.go | 10 +- http/codegen/client_cli.go | 4 +- http/codegen/example_server.go | 10 + http/codegen/server.go | 10 +- http/codegen/service_imports.go | 65 ++ http/codegen/types.go | 6 +- http/codegen/websocket.go | 4 +- jsonrpc/codegen/client.go | 10 +- jsonrpc/codegen/server.go | 13 +- jsonrpc/codegen/service_imports.go | 62 ++ jsonrpc/codegen/sse.go | 5 +- jsonrpc/codegen/websocket_client.go | 5 +- jsonrpc/codegen/websocket_server.go | 5 +- 53 files changed, 2349 insertions(+), 817 deletions(-) create mode 100644 codegen/service/declaration_resolver.go create mode 100644 codegen/service/declaration_resolver_test.go create mode 100644 codegen/service/imports.go create mode 100644 grpc/codegen/service_imports.go create mode 100644 http/codegen/service_imports.go create mode 100644 jsonrpc/codegen/service_imports.go diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 011a3fea3a..411aa39019 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -18,16 +18,27 @@ type ( path string scope *NameScope userTypes map[expr.UserType]*TypeDeclaration + typeBindings map[expr.UserType]*TypeDeclaration + derivedTypes map[DerivedTypeID]*derivedTypeDeclaration unions map[UnionTypeID]*unionDeclaration userTypeNames map[string]string + derivedKeys map[derivedTypeOrder]DerivedTypeID frozen bool } + // DerivedTypeID identifies a generated view declaration by the exact source + // declaration and the closed transformation that produces it. + DerivedTypeID struct { + origin expr.UserType + kind derivedTypeKind + } + // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { - // Name is the unqualified Go declaration name. Generated union branch - // aliases keep Name empty until the owning generation is frozen. + // Name is the unqualified Go declaration name. Derived view types and + // generated union branch aliases keep Name empty until the owning + // generation is frozen. Name string // PackagePath is the import path of the package that owns the declaration. PackagePath string @@ -46,12 +57,25 @@ type ( PackagePath string } + // UnionBranchDeclaration records the package-level declarations emitted for + // one union branch. + UnionBranchDeclaration struct { + // KindConst is the unqualified discriminator constant name. + KindConst string + // Constructor is the unqualified constructor function name. + Constructor string + // Type is the optional generated alias declaration for the branch. + Type *TypeDeclaration + + typeName string + } + // unionDeclaration retains the expression needed to allocate the public // union name deterministically when the generation freezes. unionDeclaration struct { union *expr.Union declaration *UnionDeclaration - branches map[unionBranchID]*unionBranchDeclaration + branches map[unionBranchID]*UnionBranchDeclaration } // unionBranchID identifies one generated branch alias within its union @@ -60,15 +84,45 @@ type ( name string } - // unionBranchDeclaration keeps every expression copy that refers to one - // branch alias while owning a single emitted declaration. - unionBranchDeclaration struct { - userTypes map[expr.UserType]struct{} + // derivedTypeKind distinguishes the only two view declaration families + // rebuilt independently during planning and rendering. + derivedTypeKind uint + + // derivedTypeDeclaration retains the preferred name until package freeze. + derivedTypeDeclaration struct { declaration *TypeDeclaration name string + order derivedTypeOrder } + + // derivedTypeOrder contains only stable semantic values so view declaration + // suffixes never depend on expression pointer addresses or traversal order. + derivedTypeOrder struct { + kind derivedTypeKind + name string + sourceName string + sourceID string + sourceShape string + } +) + +const ( + projectedTypeKind derivedTypeKind = iota + 1 + viewedResultTypeKind ) +// NewProjectedTypeID returns the generated declaration identity for the +// pointer-backed projection of source emitted in a service views package. +func NewProjectedTypeID(source expr.UserType) DerivedTypeID { + return DerivedTypeID{origin: source.Origin(), kind: projectedTypeKind} +} + +// NewViewedResultTypeID returns the generated declaration identity for the +// viewed-result wrapper of source emitted in a service views package. +func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { + return DerivedTypeID{origin: source.Origin(), kind: viewedResultTypeKind} +} + // Ref returns the Go reference spelling for declaration's data type, including // Goa's pointer/value semantics for named objects, unions, and aliases. func (d *TypeDeclaration) Ref(dataType expr.DataType) string { @@ -81,7 +135,8 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } - if declaration, ok := p.userTypes[userType]; ok { + origin := userType.Origin() + if declaration, ok := p.userTypes[origin]; ok { return declaration, nil } @@ -105,11 +160,50 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara } p.scope.HashedUnique(userType, name, "") declaration := &TypeDeclaration{Name: name, PackagePath: p.path} - p.userTypes[userType] = declaration + p.userTypes[origin] = declaration + p.typeBindings[origin] = declaration p.userTypeNames[name] = userType.Name() return declaration, nil } +// DeclareDerivedType records one generated view declaration. Rebuilding the +// projected expression from a copy of the same source origin returns the same +// declaration record. +func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name string) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + if planned, ok := p.derivedTypes[identity]; ok { + if planned.name != name { + return nil, fmt.Errorf( + "derived type from %q cannot declare both %q and %q in generated package %q", + identity.origin.Name(), + planned.name, + name, + p.path, + ) + } + return planned.declaration, nil + } + order := newDerivedTypeOrder(identity, name) + if existing, ok := p.derivedKeys[order]; ok && existing != identity { + return nil, fmt.Errorf( + "generated package %q cannot deterministically order derived type %q from %q", + p.path, + name, + identity.origin.Name(), + ) + } + declaration := &TypeDeclaration{PackagePath: p.path} + p.derivedTypes[identity] = &derivedTypeDeclaration{ + declaration: declaration, + name: name, + order: order, + } + p.derivedKeys[order] = identity + return declaration, nil +} + // DeclareUnion records union's emitted definition and returns the same // declaration for unions with the same emitted identity. The declaration name // remains empty until the owning generation freezes its package catalogs. @@ -123,10 +217,18 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, e } declaration := &UnionDeclaration{PackagePath: p.path} + branches := make(map[unionBranchID]*UnionBranchDeclaration, len(union.Values)) + for _, branch := range union.Values { + identity := unionBranchID{name: branch.Name} + if _, ok := branches[identity]; ok { + return nil, fmt.Errorf("union %q declares branch %q more than once", union.Name(), branch.Name) + } + branches[identity] = &UnionBranchDeclaration{} + } p.unions[identity] = &unionDeclaration{ union: union, declaration: declaration, - branches: make(map[unionBranchID]*unionBranchDeclaration), + branches: branches, } return declaration, nil } @@ -147,50 +249,72 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName } identity := unionBranchID{name: branchName} - if branch, ok := planned.branches[identity]; ok { + branch, ok := planned.branches[identity] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + if branch.Type != nil { name := Goify(userType.Name(), true) - if branch.name != name { + if branch.typeName != name { return nil, fmt.Errorf( "branch %q of union %q cannot declare both %q and %q", branchName, union.Name(), - branch.name, + branch.typeName, name, ) } - branch.userTypes[userType] = struct{}{} - return branch.declaration, nil + origin := userType.Origin() + if existing, ok := p.typeBindings[origin]; ok && existing != branch.Type { + return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) + } + p.typeBindings[origin] = branch.Type + return branch.Type, nil } declaration := &TypeDeclaration{PackagePath: p.path} - planned.branches[identity] = &unionBranchDeclaration{ - userTypes: map[expr.UserType]struct{}{userType: {}}, - declaration: declaration, - name: Goify(userType.Name(), true), + origin := userType.Origin() + if existing, ok := p.typeBindings[origin]; ok && existing != declaration { + return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) } + branch.Type = declaration + branch.typeName = Goify(userType.Name(), true) + p.typeBindings[origin] = declaration return declaration, nil } // UserType returns userType's existing package declaration without allocating // a name or declaration record. func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) { - if declaration, ok := p.userTypes[userType]; ok { + if declaration, ok := p.userTypes[userType.Origin()]; ok { return declaration, nil } return nil, fmt.Errorf("user type %q is not declared in generated package %q", userType.Name(), p.path) } -// Union returns union's existing package declaration without allocating a -// name or declaration record. -func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { - if planned, ok := p.unions[NewUnionTypeID(union)]; ok { +// Type returns the frozen exact or generated branch declaration bound to +// userType's origin in this package. +func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.typeBindings[userType.Origin()]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q has no declaration in generated package %q", userType.Name(), p.path) +} + +// DerivedType returns a previously planned generated view declaration. +func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration, error) { + if planned, ok := p.derivedTypes[identity]; ok { return planned.declaration, nil } - return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + return nil, fmt.Errorf( + "derived type from %q is not declared in generated package %q", + identity.origin.Name(), + p.path, + ) } -// UnionBranchType returns the existing declaration for one generated branch -// alias without allocating a name or declaration record. -func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string, userType expr.UserType) (*TypeDeclaration, error) { +// UnionBranch returns the existing declaration family for one union branch +// without allocating package names. +func (p *GeneratedPackage) UnionBranch(union *expr.Union, branchName string) (*UnionBranchDeclaration, error) { planned, ok := p.unions[NewUnionTypeID(union)] if !ok { return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) @@ -199,10 +323,29 @@ func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string, if !ok { return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) } - if _, ok := branch.userTypes[userType]; !ok { - return nil, fmt.Errorf("user type %q is not declared as branch %q of union %q", userType.Name(), branchName, union.Name()) + return branch, nil +} + +// Union returns union's existing package declaration without allocating a +// name or declaration record. +func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { + if planned, ok := p.unions[NewUnionTypeID(union)]; ok { + return planned.declaration, nil + } + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) +} + +// UnionBranchType returns the existing declaration for one generated branch +// alias without allocating a name or declaration record. +func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string) (*TypeDeclaration, error) { + branch, err := p.UnionBranch(union, branchName) + if err != nil { + return nil, err + } + if branch.Type == nil { + return nil, fmt.Errorf("branch %q of union %q has no generated type in package %q", branchName, union.Name(), p.path) } - return branch.declaration, nil + return branch.Type, nil } // Scope returns the frozen package-owned name scope used to render generated @@ -220,14 +363,29 @@ func newGeneratedPackage(path string) *GeneratedPackage { path: path, scope: NewNameScope(), userTypes: make(map[expr.UserType]*TypeDeclaration), + typeBindings: make(map[expr.UserType]*TypeDeclaration), + derivedTypes: make(map[DerivedTypeID]*derivedTypeDeclaration), unions: make(map[UnionTypeID]*unionDeclaration), userTypeNames: make(map[string]string), + derivedKeys: make(map[derivedTypeOrder]DerivedTypeID), } } -// freeze assigns pending union names in structural-identity order, then ends -// declaration and scope mutation while preserving read-only lookups. +// freeze assigns derived declarations in stable source order and union +// families in structural-identity order, then ends declaration and scope +// mutation while preserving read-only lookups. func (p *GeneratedPackage) freeze() { + derived := make([]*derivedTypeDeclaration, 0, len(p.derivedTypes)) + for _, planned := range p.derivedTypes { + derived = append(derived, planned) + } + slices.SortFunc(derived, func(a, b *derivedTypeDeclaration) int { + return compareDerivedTypeOrder(a.order, b.order) + }) + for _, planned := range derived { + planned.declaration.Name = p.scope.Unique(planned.name) + } + identities := make([]UnionTypeID, 0, len(p.unions)) for identity := range p.unions { identities = append(identities, identity) @@ -248,13 +406,47 @@ func (p *GeneratedPackage) freeze() { }) for _, identity := range branches { branch := planned.branches[identity] - branch.declaration.Name = p.scope.Unique(branch.name) + if branch.Type != nil { + branch.Type.Name = p.scope.Unique(branch.typeName) + } + branch.KindConst = p.scope.Unique(planned.declaration.KindName + Goify(identity.name, true)) + branch.Constructor = p.scope.Unique("New" + planned.declaration.Name + Goify(identity.name, true)) } } p.scope.Freeze() p.frozen = true } +// newDerivedTypeOrder builds deterministic ordering data independent of +// expression pointer addresses. +func newDerivedTypeOrder(identity DerivedTypeID, name string) derivedTypeOrder { + return derivedTypeOrder{ + kind: identity.kind, + name: name, + sourceName: identity.origin.Name(), + sourceID: identity.origin.ID(), + sourceShape: expr.Hash(identity.origin, false, false, false), + } +} + +// compareDerivedTypeOrder orders view declarations by stable typed fields. +func compareDerivedTypeOrder(left, right derivedTypeOrder) int { + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + for _, values := range [][2]string{ + {left.name, right.name}, + {left.sourceName, right.sourceName}, + {left.sourceID, right.sourceID}, + {left.sourceShape, right.sourceShape}, + } { + if compared := strings.Compare(values[0], values[1]); compared != 0 { + return compared + } + } + return 0 +} + // unionHasBranchType verifies that userType is the branch expression supplied // for this concrete union copy. Structural reuse is established separately by // UnionTypeID when the owning union declaration is looked up. diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index e25fbedc30..8211aa2a64 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -102,6 +102,55 @@ func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { require.ErrorContains(t, err, "already declared") } +// TestGeneratedPackageUserTypeCopiesShareDeclaration verifies that exact +// compiler copies use their declaration origin instead of one transient copy +// pointer as package identity. +func TestGeneratedPackageUserTypeCopiesShareDeclaration(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + original := generatedUserType("ValueText", "value-text") + copy := original.Dup(expr.DupAtt(original.Attribute())) + + first, err := types.DeclareUserType(original) + require.NoError(t, err) + second, err := types.DeclareUserType(copy) + require.NoError(t, err) + require.Same(t, first, second) + require.NoError(t, generation.Freeze()) + + lookedUp, err := types.UserType(copy) + require.NoError(t, err) + require.Same(t, first, lookedUp) +} + +// TestGeneratedPackageDerivedTypesUseTypedSourceIdentity verifies that view +// declarations rebuilt in the render phase select the records planned from +// the same exact source declaration. +func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + views := generation.GeneratedPackage("generated.local/gen/service/views") + source := generatedUserType("Value", "value") + copy := source.Dup(expr.DupAtt(source.Attribute())) + projectedID := NewProjectedTypeID(source) + viewedID := NewViewedResultTypeID(source) + + projected, err := views.DeclareDerivedType(projectedID, "ValueView") + require.NoError(t, err) + viewed, err := views.DeclareDerivedType(viewedID, "Value") + require.NoError(t, err) + require.NotSame(t, projected, viewed) + require.NoError(t, generation.Freeze()) + + projectedCopy, err := views.DerivedType(NewProjectedTypeID(copy)) + require.NoError(t, err) + require.Same(t, projected, projectedCopy) + viewedCopy, err := views.DerivedType(NewViewedResultTypeID(copy)) + require.NoError(t, err) + require.Same(t, viewed, viewedCopy) + require.Equal(t, "ValueView", projected.Name) + require.Equal(t, "Value", viewed.Name) +} + // TestGeneratedPackageUnionBranchesShareDeclaration verifies that separately // allocated copies of one structural union reuse their generated branch alias. func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { @@ -123,7 +172,7 @@ func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { require.NoError(t, generation.Freeze()) require.Equal(t, "ValueText", firstDeclaration.Name) - lookedUp, err := types.UnionBranchType(secondUnion, "text", secondAlias) + lookedUp, err := types.UnionBranchType(secondUnion, "text") require.NoError(t, err) require.Same(t, firstDeclaration, lookedUp) } @@ -154,6 +203,30 @@ func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { }) } +// TestGeneratedPackageUnionFamilyAvoidsExactTypeNames verifies that union +// constants and constructors use package-owned frozen names instead of +// colliding with exact DSL type declarations. +func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + for _, name := range []string{"ValueKindText", "NewValueText"} { + _, err := types.DeclareUserType(generatedUserType(name, name)) + require.NoError(t, err) + } + union, alias := generatedUnionWithBranch("Value", "text", "text", expr.String) + _, err := types.DeclareUnion(union) + require.NoError(t, err) + aliasDeclaration, err := types.DeclareUnionBranchType(union, "text", alias) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + branch, err := types.UnionBranch(union, "text") + require.NoError(t, err) + require.Equal(t, "ValueKindText2", branch.KindConst) + require.Equal(t, "NewValueText2", branch.Constructor) + require.Same(t, aliasDeclaration, branch.Type) +} + // TestGeneratedPackageUnions verifies that emitted-definition identity makes // equivalent unions idempotent while different unions with the same base name // receive distinct declarations. diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 6856075b26..657285e9e0 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -1,3 +1,5 @@ +// This file assembles example service, server, and client files from frozen +// service analysis without mutating imports across unrelated output files. package generator import ( @@ -19,10 +21,6 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { if err != nil { return nil, err } - for _, s := range r.Services { - service.SetUserTypeImports(generation.GenPkg, services.Get(s.Name)) - } - // example service implementation if fs := service.ExampleServiceFiles(generation.GenPkg, r, services); len(fs) != 0 { files = append(files, fs...) @@ -75,9 +73,6 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { files = append(files, fs...) } } - - // Add imports defined via struct:field:type - addServicesMetaTypeImports(files, services, r.Services) } return files, nil } diff --git a/codegen/generator/service.go b/codegen/generator/service.go index ac9958c79a..9ae6cbab73 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -1,3 +1,5 @@ +// This file assembles service-owned generated files after every participating +// Goa design root has planned and frozen its package declarations. package generator import ( @@ -22,18 +24,13 @@ func Service(generation *codegen.Generation) ([]*codegen.File, error) { analyses[i] = services for _, s := range r.Services { - d := services.Get(s.Name) - service.SetUserTypeImports(generation.GenPkg, d) - endpointFiles := []*codegen.File{ service.EndpointFile(generation.GenPkg, s, services), service.ClientFile(generation.GenPkg, s, services), } - addServiceImports(endpointFiles, d) files = append(files, endpointFiles...) if f := service.ViewsFile(generation.GenPkg, s, services); f != nil { - addServiceImports([]*codegen.File{f}, d) files = append(files, f) } convFiles, err := service.ConvertFiles(r, s, services) @@ -44,9 +41,6 @@ func Service(generation *codegen.Generation) ([]*codegen.File, error) { } } svcFiles := service.Files(generation.GenPkg, analyses) - for i, services := range analyses { - addServicesImports(svcFiles, services, designRoots[i].Services) - } return append(svcFiles, files...), nil } @@ -72,34 +66,3 @@ func serviceRoots(roots []eval.Root) []*expr.RootExpr { } return designRoots } - -func addServiceImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) - service.AddUserTypeImports(f.SectionTemplates[0], d) - } -} - -func addServicesImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addServiceImports(files, services.Get(s.Name)) - } -} - -func addMetaTypeImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) - } -} - -func addServicesMetaTypeImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addMetaTypeImports(files, services.Get(s.Name)) - } -} diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index e0b0cb11e9..c8e8362191 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -3,6 +3,7 @@ package generator import ( + "os" "path/filepath" "strings" "testing" @@ -87,6 +88,236 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { runGeneratedTests(t, genDir) } +// TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch +// aliases with one natural name but different primitive shapes remain distinct. +func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + codegen.RunDSL(t, func() { + first := dsl.Type("FirstValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("SecondValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.Int) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + dsl.Result(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + unionSource, err := os.ReadFile(filepath.Join(genDir, "types", "unions.go")) + require.NoError(t, err) + require.Contains(t, string(unionSource), "type Value struct") + require.Contains(t, string(unionSource), "type Value2 struct") + runGeneratedTests(t, genDir) +} + +// TestServiceUnionFamilyNamesAvoidExactDeclarations verifies that union +// constants and constructors cannot collide with exact DSL type names. +func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + codegen.RunDSL(t, func() { + kind := dsl.Type("ValueKindText", dsl.String) + constructor := dsl.Type("NewValueText", dsl.String) + payload := dsl.Type("Payload", func() { + dsl.Attribute("kind", kind) + dsl.Attribute("constructor", constructor) + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestServiceFilesOwnTheirImports verifies that imports used by one service do +// not leak into another service file generated from the same design root. +func TestServiceFilesOwnTheirImports(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + dsl.API("file-owned imports", func() {}) + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestNestedRelocatedDeclarationsOwnTheirImports verifies that metadata imports +// used by two relocated declarations stay in their respective declaration +// files and do not leak into the service file that references their package. +func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + codegen.RunDSL(t, func() { + dsl.API("nested file-owned imports", func() {}) + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "gen/custom/first/shared", "shared") + }) + }) + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "gen/custom/second/shared", "shared") + }) + }) + dsl.Service("Nested", func() { + dsl.Method("Outer", func() { + dsl.Payload(outer) + }) + dsl.Method("Inner", func() { + dsl.Payload(inner) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "first", "shared"), "shared") + writeStubPackage(t, filepath.Join(genDir, "custom", "second", "shared"), "shared") + runGeneratedTests(t, genDir) +} + +// TestTransportSectionsOwnTheirImports verifies that a streaming transport +// file imports only the declarations used by the streaming endpoints it +// renders, even when another endpoint uses the same package name elsewhere. +func TestTransportSectionsOwnTheirImports(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("transport section imports", func() {}) + streamMessage := dsl.Type("StreamMessage", func() { + dsl.Meta("struct:pkg:path", "stream/shared") + dsl.Attribute("value", dsl.String) + }) + request := dsl.Type("Request", func() { + dsl.Meta("struct:pkg:path", "request/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Messages", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(streamMessage) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.Response(200) + }) + }) + dsl.Method("Create", func() { + dsl.Payload(request) + dsl.HTTP(func() { + dsl.POST("/messages") + dsl.Response(204) + }) + }) + }) + }) + generation := codegen.NewGeneration("gen", []eval.Root{root}) + require.NoError(t, planServiceData(generation)) + require.NoError(t, generation.Freeze()) + files, err := Transport(generation) + require.NoError(t, err) + + var header strings.Builder + for _, file := range files { + if filepath.ToSlash(file.Path) != "gen/http/messages/server/websocket.go" { + continue + } + require.NoError(t, file.SectionTemplates[0].Write(&header)) + break + } + require.NotEmpty(t, header.String()) + require.Contains(t, header.String(), `"gen/stream/shared"`) + require.NotContains(t, header.String(), `"gen/request/shared"`) +} + +// writeStubPackage creates the external package referenced by struct:field:type +// metadata inside the generated module used by the integration test. +func writeStubPackage(t *testing.T, dir, packageName string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "value.go"), + []byte("package "+packageName+"\n\ntype Value string\n"), + 0o600, + )) +} + func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { roots := []eval.Root{ codegen.RunDSL(t, unusedRelocatedValueRoot()), @@ -129,17 +360,16 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { ) } -func TestServiceSelectiveRelocatedUnionOwnerCompiles(t *testing.T) { - root := codegen.RunDSL(t, selectiveRelocatedUnionRoot()) +// TestServiceRelocatedUnionOwnerCompilesAcrossGeneration verifies a complete +// root analysis emits one shared relocated union for every referencing service. +func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { + root := codegen.RunDSL(t, sharedRelocatedUnionRoot()) generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, servicecodegen.Plan(root, generation)) require.NoError(t, generation.Freeze()) services, err := servicecodegen.NewServicesData(root, generation) require.NoError(t, err) - data := services.Get(root.Services[1].Name) - servicecodegen.SetUserTypeImports("generated.local/gen", data) files := servicecodegen.Files("generated.local/gen", []*servicecodegen.ServicesData{services}) - addServiceImports(files, data) dir := t.TempDir() for _, file := range files { _, err := file.Render(dir) @@ -160,9 +390,9 @@ func unusedRelocatedValueRoot() func() { } } -// selectiveRelocatedUnionRoot declares the same relocated union from two -// services so rendering only the later service must still emit its definition. -func selectiveRelocatedUnionRoot() func() { +// sharedRelocatedUnionRoot declares the same relocated union from two services +// so one generation-wide render emits its definition exactly once. +func sharedRelocatedUnionRoot() func() { return func() { first := relocatedValueType("FirstValue") second := relocatedValueType("SecondValue") diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index f8c49aac31..429e8c1360 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -1,3 +1,5 @@ +// This file assembles HTTP, gRPC, and JSON-RPC files from service analysis; +// each transport builder owns the imports of the file it returns. package generator import ( @@ -18,10 +20,6 @@ func Transport(generation *codegen.Generation) ([]*codegen.File, error) { if err != nil { return nil, err } - for _, s := range r.Services { - service.SetUserTypeImports(generation.GenPkg, services.Get(s.Name)) - } - // HTTP httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) files = append(files, httpcodegen.ServerFiles(generation.GenPkg, httpServices)...) @@ -48,9 +46,6 @@ func Transport(generation *codegen.Generation) ([]*codegen.File, error) { files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg, jsonrpcServices)...) files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg, jsonrpcServices)...) - - // Add service data meta type imports - addServicesImports(files, services, r.Services) } return files, nil } diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 911543ef3e..96e43b38ba 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -1,3 +1,6 @@ +// This file generates Go transformations between compatible design types. +// Recursive helpers carry each side's package owner through nested named +// declarations so emitted references select the planned Go package. package codegen import ( @@ -564,12 +567,11 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str cases := make([]map[string]any, 0, len(srcUnion.Values)) for i, st := range srcUnion.Values { tt := tgtUnion.Values[i] - castPkg := ta.TargetCtx.Pkg(tt.Attribute) - // When generating transforms outside of the type's package, some nested - // helper user types may not carry struct:pkg:path metadata. In that case - // default to the union type package rather than the current file package. - if castPkg == ta.TargetCtx.DefaultPkg && unionPkg != "" && unionPkg != ta.TargetCtx.DefaultPkg { - castPkg = unionPkg + branchAttrs := &TransformAttrs{ + SourceCtx: ta.SourceCtx.Enter(st.Attribute), + TargetCtx: ta.TargetCtx.Enter(tt.Attribute), + Prefix: ta.Prefix, + Hooks: ta.Hooks, } useHelper := false if _, ok := st.Attribute.Type.(expr.UserType); ok && expr.IsObject(st.Attribute.Type) { @@ -583,9 +585,9 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str "TargetFieldName": Goify(tt.Name, true), "SourceAttr": st.Attribute, "TargetAttr": tt.Attribute, - "TargetCastType": ta.TargetCtx.Scope.Ref(tt.Attribute, castPkg), + "TargetCastType": branchAttrs.TargetCtx.Scope.Ref(tt.Attribute, branchAttrs.TargetCtx.Pkg(tt.Attribute)), "UseHelper": useHelper, - "HelperName": TransformHelperName(st.Attribute, tt.Attribute, ta), + "HelperName": TransformHelperName(st.Attribute, tt.Attribute, branchAttrs), }) } @@ -682,18 +684,13 @@ func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta * // target. Both source and target must be user types. The caller // (collectHelpers) guarantees no helper was generated yet for the pair. func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - name := TransformHelperName(source, target, ta) - - // When transforming into a user type defined in an external package, assume - // nested anonymous types (e.g., union sum types) belong to the same target - // package unless they explicitly specify a different location. Work on a - // copy of the context so the caller's context is never mutated. - if pkg := ta.TargetCtx.Pkg(target); pkg != "" && pkg != ta.TargetCtx.DefaultPkg { - tgtCtx := ta.TargetCtx.Dup() - tgtCtx.DefaultPkg = pkg - tgtCtx.SamePackageConversion = false - ta = &TransformAttrs{SourceCtx: ta.SourceCtx, TargetCtx: tgtCtx, Prefix: ta.Prefix, Hooks: ta.Hooks} + ta = &TransformAttrs{ + SourceCtx: ta.SourceCtx.Enter(source), + TargetCtx: ta.TargetCtx.Enter(target), + Prefix: ta.Prefix, + Hooks: ta.Hooks, } + name := TransformHelperName(source, target, ta) code, err := TransformAttribute(source, target, "v", "res", true, ta) if err != nil { diff --git a/codegen/import.go b/codegen/import.go index 70e23e5cb9..a4cd3495a8 100644 --- a/codegen/import.go +++ b/codegen/import.go @@ -1,3 +1,5 @@ +// This file models generated Go imports and derives type imports from explicit +// Goa metadata without assigning them to unrelated generated files. package codegen import ( @@ -134,22 +136,23 @@ func GetMetaTypeImports(att *expr.AttributeExpr) []*ImportSpec { } // safelyGetMetaTypeImports parses attributes while keeping track of previous usertypes to avoid infinite recursion -func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) []*ImportSpec { +func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) []*ImportSpec { if att == nil { return nil } if seen == nil { - seen = make(map[string]struct{}) + seen = make(map[expr.UserType]struct{}) } uniqueImports := make(map[ImportSpec]struct{}) imports := make([]*ImportSpec, 0) switch t := att.Type.(type) { case expr.UserType: - if _, wasSeen := seen[t.ID()]; wasSeen { + origin := t.Origin() + if _, wasSeen := seen[origin]; wasSeen { return imports } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} for _, im := range safelyGetMetaTypeImports(t.Attribute(), seen) { if im != nil { uniqueImports[*im] = struct{}{} @@ -192,12 +195,3 @@ func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) } return imports } - -// AddServiceMetaTypeImports adds meta type imports for each method of the service expr -func AddServiceMetaTypeImports(header *SectionTemplate, svc *expr.ServiceExpr) { - for _, m := range svc.Methods { - AddImport(header, GetMetaTypeImports(m.Payload)...) - AddImport(header, GetMetaTypeImports(m.StreamingPayload)...) - AddImport(header, GetMetaTypeImports(m.Result)...) - } -} diff --git a/codegen/import_test.go b/codegen/import_test.go index 3aae8c7273..720ad3801d 100644 --- a/codegen/import_test.go +++ b/codegen/import_test.go @@ -1,3 +1,5 @@ +// This file verifies that import discovery follows complete attribute shapes +// while keeping independent user declarations distinct during cycle checks. package codegen import ( @@ -170,3 +172,42 @@ func TestGetMetaTypeImports(t *testing.T) { }) } } + +// TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID verifies +// semantic IDs do not collapse imports from exact in-memory declarations. +func TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID(t *testing.T) { + first := &expr.UserTypeExpr{ + TypeName: "First", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"First", "example.com/first"}, + }, + }, + } + second := &expr.UserTypeExpr{ + TypeName: "Second", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"Second", "example.com/second"}, + }, + }, + } + object := expr.Object{ + &expr.NamedAttributeExpr{Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + &expr.NamedAttributeExpr{Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + } + + imports := GetMetaTypeImports(&expr.AttributeExpr{Type: &object}) + paths := make([]string, len(imports)) + for i, spec := range imports { + paths[i] = spec.Path + } + sort.Strings(paths) + if want := []string{"example.com/first", "example.com/second"}; !reflect.DeepEqual(paths, want) { + t.Errorf("want %+v, got %+v", want, paths) + } +} diff --git a/codegen/scope.go b/codegen/scope.go index 67611854c8..8df4755fea 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -43,6 +43,20 @@ func NewNameScope() *NameScope { } } +// Fork returns a mutable naming scope containing every name and hashed binding +// already recorded in s. Generators use it for private helpers that must avoid +// declarations owned by a frozen generated package. +func (s *NameScope) Fork() *NameScope { + fork := NewNameScope() + for hash, name := range s.names { + fork.names[hash] = name + } + for name, count := range s.counts { + fork.counts[name] = count + } + return fork +} + // HashedUnique builds the unique name for key using name and - if not unique - // appending suffix and - if still not unique - a counter value. It returns // the same value when called multiple times for a key returning the same hash. diff --git a/codegen/scope_test.go b/codegen/scope_test.go index 19680e19b9..fb0f699102 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -1,3 +1,4 @@ +// This file verifies package-level name allocation and generated type identity. package codegen import ( @@ -204,7 +205,9 @@ func TestNameScope_GoTypeNameDistinguishesInlineObjectFieldOrder(t *testing.T) { assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) } -func TestNameScope_GoTypeNameDistinguishesGoifiedBranchTypeCollisions(t *testing.T) { +// TestNameScope_GoTypeNameSharesIdenticalEmittedUnionDefinitions verifies +// semantically different branch declarations share a structurally equal union. +func TestNameScope_GoTypeNameSharesIdenticalEmittedUnionDefinitions(t *testing.T) { branch := func(name, id string) expr.UserType { return &expr.UserTypeExpr{ TypeName: name, @@ -227,7 +230,24 @@ func TestNameScope_GoTypeNameDistinguishesGoifiedBranchTypeCollisions(t *testing second := union(branch("foo_bar", "second")) scope := NewNameScope() assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) - assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +// TestNameScopeForkPreservesBindingsAndAcceptsHelperNames verifies a frozen +// declaration scope can seed a separate mutable helper namespace. +func TestNameScopeForkPreservesBindingsAndAcceptsHelperNames(t *testing.T) { + typeExpr := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Value", + } + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + scope.Freeze() + + fork := scope.Fork() + assert.Equal(t, "Value", fork.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + assert.Equal(t, "Value2", fork.Unique("Value")) + assert.Equal(t, "helper", fork.Unique("helper")) } func TestUnionTypeID(t *testing.T) { @@ -329,6 +349,52 @@ func TestUnionTypeIDIgnoresNonEmittedPointerSharing(t *testing.T) { }) } +// TestUnionTypeIDIncludesGeneratedUserTypeShape verifies generated aliases +// with one name and distinct definitions produce distinct union identities. +func TestUnionTypeIDIncludesGeneratedUserTypeShape(t *testing.T) { + generatedBranch := func(dataType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: "ValueText", + } + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{{ + Name: "text", + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + } + } + + require.NotEqual(t, + NewUnionTypeID(generatedBranch(expr.String)), + NewUnionTypeID(generatedBranch(expr.Int)), + ) +} + +// TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape verifies recursive +// generated aliases terminate and retain their distinct field definitions. +func TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape(t *testing.T) { + recursiveUnion := func(fieldType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{TypeName: "ValueNode"} + alias.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: fieldType}}, + {Name: "next", Attribute: &expr.AttributeExpr{Type: alias}}, + }} + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "node", Attribute: &expr.AttributeExpr{Type: alias}}, + }, + } + } + + require.NotEqual(t, + NewUnionTypeID(recursiveUnion(expr.String)), + NewUnionTypeID(recursiveUnion(expr.Int)), + ) +} + func TestNameScope_GoFullTypeName_UsesScopedRelocatedUserTypeNameWhenQualified(t *testing.T) { scope := NewNameScope() first := &expr.UserTypeExpr{ diff --git a/codegen/service/client.go b/codegen/service/client.go index cbe861f665..8b2e6b8953 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -1,3 +1,5 @@ +// This file renders one service's in-process client and keeps its type imports +// scoped to that generated client file. package service import ( @@ -13,10 +15,11 @@ const ( ) // ClientFile returns the client file for the given service. -func ClientFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { +func ClientFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { svc := services.Get(service.Name) data := endpointData(svc) path := filepath.Join(codegen.Gendir, svc.PathName, "client.go") + outputPackage := genpkg + "/" + svc.PathName var ( sections []*codegen.SectionTemplate ) @@ -26,6 +29,7 @@ func ClientFile(_ string, service *expr.ServiceExpr, services *ServicesData) *co {Path: "io"}, codegen.GoaImport(""), } + imports = append(imports, AttributeImports(genpkg, outputPackage, serviceReferenceAttributes(service)...)...) header := codegen.Header(service.Name+" client", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "client-struct", diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 11d12af21c..5c183985c5 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -1,3 +1,6 @@ +// This file generates ConvertTo and CreateFrom functions for service types +// mapped to external Go structs. Service-side names come from the frozen +// package catalog, including nested types relocated by design metadata. package service import ( @@ -192,19 +195,17 @@ func generateConvertFileForPath( tgtPkg = tgtPkg[:idx] } - // Use the correct source context based on where the conversion file will be generated - var srcCtx *codegen.AttributeContext + outputPath := servicePackagePath(services.generation.GenPkg, service) if loc := codegen.UserTypeLocation(c.User); loc != nil { - srcScope := services.generation.GeneratedPackage( - generatedPackagePath(services.generation.GenPkg, service, loc), - ).Scope() - // Use conversion context so types in the same package are not qualified - srcCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, srcScope) - } else { - srcCtx = typeContext(svc.Scope) + outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) } - tgtCtx := codegen.NewAttributeContext(false, false, false, tgtPkg, codegen.NewNameScope()) srcAtt := &expr.AttributeExpr{Type: c.User} + srcResolver := newServiceResolver(services.generation, service, outputPath).Enter(srcAtt) + srcCtx := &codegen.AttributeContext{ + UseDefault: true, + Scope: srcResolver, + } + tgtCtx := codegen.NewAttributeContext(false, false, false, tgtPkg, codegen.NewNameScope()) tgtAtt := &expr.AttributeExpr{Type: dt} tgtAtt.AddMeta("struct:type:name", dt.Name()) // Used by transformer to generate the correct type name. code, tf, err := codegen.GoTransform( @@ -247,18 +248,16 @@ func generateConvertFileForPath( } srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) - // Use the correct target context based on where the conversion file will be generated - var tgtCtx *codegen.AttributeContext + tgtAtt := &expr.AttributeExpr{Type: c.User} + outputPath := servicePackagePath(services.generation.GenPkg, service) if loc := codegen.UserTypeLocation(c.User); loc != nil { - tgtScope := services.generation.GeneratedPackage( - generatedPackagePath(services.generation.GenPkg, service, loc), - ).Scope() - // Use conversion context so types in the same package are not qualified - tgtCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, tgtScope) - } else { - tgtCtx = typeContext(svc.Scope) + outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) + } + tgtResolver := newServiceResolver(services.generation, service, outputPath).Enter(tgtAtt) + tgtCtx := &codegen.AttributeContext{ + UseDefault: true, + Scope: tgtResolver, } - tgtAtt := &expr.AttributeExpr{Type: c.User} code, tf, err := codegen.GoTransform( &expr.AttributeExpr{Type: dt}, tgtAtt, "v", "temp", srcCtx, tgtCtx, "transform", true) diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go new file mode 100644 index 0000000000..fa7bf26c34 --- /dev/null +++ b/codegen/service/declaration_resolver.go @@ -0,0 +1,303 @@ +// This file resolves service type definitions and references through the +// frozen generated-package catalog. It follows explicit type locations by +// import path and keeps unlocated nested declarations in their enclosing +// package. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // declarationResolver renders service-side attributes from the package + // records selected during Plan. + declarationResolver struct { + generation *codegen.Generation + service *expr.ServiceExpr + currentPath string + outputPath string + derived map[expr.UserType]codegen.DerivedTypeID + view bool + } +) + +// newServiceResolver resolves declarations starting in service's generated +// package and qualifies names relative to outputPath. +func newServiceResolver(generation *codegen.Generation, service *expr.ServiceExpr, outputPath string) *declarationResolver { + return &declarationResolver{ + generation: generation, + service: service, + currentPath: servicePackagePath(generation.GenPkg, service), + outputPath: outputPath, + } +} + +// newViewResolver resolves every declaration in service's views package. +// derived binds rebuilt projected expression origins to their typed catalog +// identities. +func newViewResolver(generation *codegen.Generation, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { + viewsPath := servicePackagePath(generation.GenPkg, service) + "/views" + return &declarationResolver{ + generation: generation, + service: service, + currentPath: viewsPath, + outputPath: viewsPath, + derived: derived, + view: true, + } +} + +// Name returns the generated Go type name for att. Package ownership comes +// from the resolver's current import path, not from the textual pkg argument. +func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + if custom, _ := codegen.GetMetaType(att); custom != "" { + return custom + } + return codegen.GoNativeTypeName(actual) + case *expr.Array: + return "[]" + r.Ref(actual.ElemType, "") + case *expr.Map: + return fmt.Sprintf("map[%s]%s", r.Ref(actual.KeyType, ""), r.Ref(actual.ElemType, "")) + case *expr.Object: + return r.Def(att, ptr, useDefault) + case expr.UserType: + if actual == expr.ErrorResult { + return "goa.ServiceError" + } + owner := r.owner(att) + declaration := r.userType(owner, actual) + return r.qualify(owner, declaration.Name) + case *expr.Union: + owner := r.owner(att) + declaration, err := r.generation.GeneratedPackage(owner).Union(actual) + if err != nil { + panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.service.Name, owner, err)) + } + return r.qualify(owner, declaration.Name) + case expr.CompositeExpr: + return r.Name(actual.Attribute(), "", ptr, useDefault) + default: + panic(fmt.Sprintf("resolve service type %T for service %q", actual, r.service.Name)) + } +} + +// Def returns the Go definition for att while resolving every nested named +// declaration through its actual generated package. +func (r *declarationResolver) Def(att *expr.AttributeExpr, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + return r.Name(att, "", ptr, useDefault) + case *expr.Array: + definition := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + definition = "*" + definition + } + return "[]" + definition + case *expr.Map: + key := r.Enter(actual.KeyType).(*declarationResolver).Def(actual.KeyType, ptr, useDefault) + if expr.IsObject(actual.KeyType.Type) { + key = "*" + key + } + value := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + value = "*" + value + } + return fmt.Sprintf("map[%s]%s", key, value) + case *expr.Object: + lines := []string{"struct {"} + for _, field := range *actual { + fieldResolver := r.Enter(field.Attribute).(*declarationResolver) + definition := fieldResolver.Def(field.Attribute, ptr, useDefault) + if serviceFieldIsPointer(att, field.Name, ptr, useDefault) { + definition = "*" + definition + } + var description string + if field.Attribute.Description != "" { + description = codegen.Comment(field.Attribute.Description) + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + codegen.GoifyAtt(field.Attribute, field.Name, true), + definition, + codegen.AttributeTagsWithName(att, field.Name, field.Attribute), + )) + } + return strings.Join(append(lines, "}"), "\n") + case expr.UserType, *expr.Union: + return r.Name(att, "", ptr, useDefault) + case expr.CompositeExpr: + return r.Def(actual.Attribute(), ptr, useDefault) + default: + panic(fmt.Sprintf("define service type %T for service %q", actual, r.service.Name)) + } +} + +// Ref returns the generated Go reference for att. +func (r *declarationResolver) Ref(att *expr.AttributeExpr, pkg string) string { + name := r.Name(att, pkg, false, false) + if _, ok := att.Type.(*expr.Object); ok { + return name + } + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +// Field returns the generated Go field name for one service attribute. +func (*declarationResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(att, name, firstUpper) +} + +// Package returns the qualifier for att relative to the file being rendered. +func (r *declarationResolver) Package(att *expr.AttributeExpr) string { + owner := r.currentPath + if att != nil { + owner = r.owner(att) + } + if owner == r.outputPath { + return "" + } + return generatedPackageName(r.generation.GenPkg, r.service, owner) +} + +// Enter returns a resolver whose current package owns att and its unlocated +// nested declarations. +func (r *declarationResolver) Enter(att *expr.AttributeExpr) codegen.Attributor { + owner := r.owner(att) + if owner == r.currentPath { + return r + } + entered := *r + entered.currentPath = owner + return &entered +} + +// inOutputPackage returns a resolver for a file emitted in packagePath. +func (r *declarationResolver) inOutputPackage(packagePath string) *declarationResolver { + if packagePath == r.currentPath && packagePath == r.outputPath { + return r + } + output := *r + output.currentPath = packagePath + output.outputPath = packagePath + return &output +} + +// withOutputPackage returns a resolver that keeps its current declaration +// owner but qualifies references for a file emitted in packagePath. +func (r *declarationResolver) withOutputPackage(packagePath string) *declarationResolver { + if packagePath == r.outputPath { + return r + } + output := *r + output.outputPath = packagePath + return &output +} + +// bindDerived returns a resolver that associates a render-only expression +// origin with one declaration planned from its exact source type. +func (r *declarationResolver) bindDerived(origin expr.UserType, identity codegen.DerivedTypeID) *declarationResolver { + bound := *r + bound.derived = make(map[expr.UserType]codegen.DerivedTypeID, len(r.derived)+1) + for existing, existingIdentity := range r.derived { + bound.derived[existing] = existingIdentity + } + bound.derived[origin.Origin()] = identity + return &bound +} + +// IsSumType reports that service unions use Goa's generated sum-type structs. +func (*declarationResolver) IsSumType() bool { + return true +} + +// Scope returns the frozen name scope owned by the resolver's current package. +func (r *declarationResolver) Scope() *codegen.NameScope { + return r.generation.GeneratedPackage(r.currentPath).Scope() +} + +// owner returns the import path that owns att. View projections stay in the +// views package after their original struct:pkg:path metadata is removed. +func (r *declarationResolver) owner(att *expr.AttributeExpr) string { + if r.view { + return r.currentPath + } + if location := codegen.UserTypeLocation(att.Type); location != nil { + return path.Join(r.generation.GenPkg, location.RelImportPath) + } + return r.currentPath +} + +// userType selects an exact, generated union branch, or rebuilt view record. +func (r *declarationResolver) userType(owner string, userType expr.UserType) *codegen.TypeDeclaration { + generatedPackage := r.generation.GeneratedPackage(owner) + if identity, ok := r.derived[userType.Origin()]; ok { + declaration, err := generatedPackage.DerivedType(identity) + if err != nil { + panic(fmt.Sprintf("resolve derived type %q for service %q in package %q: %v", userType.Name(), r.service.Name, owner, err)) + } + return declaration + } + declaration, err := generatedPackage.Type(userType) + if err != nil { + panic(fmt.Sprintf("resolve user type %q for service %q in package %q: %v", userType.Name(), r.service.Name, owner, err)) + } + return declaration +} + +// qualify adds the owning package name when the current output file is in a +// different generated package. +func (r *declarationResolver) qualify(owner, name string) string { + if owner == r.outputPath { + return name + } + return generatedPackageName(r.generation.GenPkg, r.service, owner) + "." + name +} + +// refDeclaration qualifies declaration for the resolver's output file while +// preserving the pointer or value semantics of dataType. +func (r *declarationResolver) refDeclaration(declaration *codegen.TypeDeclaration, dataType expr.DataType) string { + qualified := r.qualify(declaration.PackagePath, declaration.Name) + if strings.HasPrefix(declaration.Ref(dataType), "*") { + return "*" + qualified + } + return qualified +} + +// declarationName returns the unqualified planned name for one named type. +func (r *declarationResolver) declarationName(attribute *expr.AttributeExpr) string { + entered := r.Enter(attribute).(*declarationResolver) + return entered.userType(entered.currentPath, attribute.Type.(expr.UserType)).Name +} + +// generatedPackageName returns the Go package name for one generated import +// path selected by the service generator. +func generatedPackageName(genpkg string, service *expr.ServiceExpr, packagePath string) string { + servicePath := servicePackagePath(genpkg, service) + switch packagePath { + case servicePath: + return strings.ToLower(codegen.Goify(service.Name, false)) + case servicePath + "/views": + return strings.ToLower(codegen.Goify(service.Name, false)) + "views" + default: + return strings.ToLower(codegen.Goify(path.Base(packagePath), false)) + } +} + +// serviceFieldIsPointer matches Goa service struct pointer semantics for one +// field definition. +func serviceFieldIsPointer(parent *expr.AttributeExpr, name string, pointer, useDefault bool) bool { + field := expr.AsObject(parent.Type).Attribute(name) + return expr.IsObject(field.Type) || + parent.IsPrimitivePointer(name, useDefault) || + pointer && expr.IsPrimitive(field.Type) && field.Type.Kind() != expr.AnyKind && field.Type.Kind() != expr.BytesKind +} diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go new file mode 100644 index 0000000000..db8270c1c8 --- /dev/null +++ b/codegen/service/declaration_resolver_test.go @@ -0,0 +1,177 @@ +// This file verifies that service transformations resolve every named type +// through the frozen package catalog as recursion crosses explicit package +// locations. +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestDeclarationResolverTransformsRelocatedUnionBranches verifies both +// conversion directions use the frozen generated alias in the owning package. +func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { + service := &expr.ServiceExpr{Name: "Convert"} + generatedBranch := resolverUserType("ValueText", expr.String) + union := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: generatedBranch}}, + }, + } + relocated := resolverUserType("Record", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: union}}, + }) + relocated.Attribute().AddMeta("struct:pkg:path", "types") + + generation := codegen.NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + _, err := types.DeclareUserType(relocated) + require.NoError(t, err) + _, err = types.DeclareUserType(resolverUserType("ValueText", expr.Int)) + require.NoError(t, err) + _, err = types.DeclareUnion(union) + require.NoError(t, err) + branchDeclaration, err := types.DeclareUnionBranchType(union, "text", generatedBranch) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText2", branchDeclaration.Name) + + externalBranch := resolverUserType("ExternalValueText", expr.String) + externalUnion := &expr.Union{ + TypeName: "ExternalValue", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: externalBranch}}, + }, + } + external := resolverUserType("ExternalRecord", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: externalUnion}}, + }) + + relocatedAttribute := &expr.AttributeExpr{Type: relocated} + externalAttribute := &expr.AttributeExpr{Type: external} + resolver := newServiceResolver(generation, service, "generated.local/gen/types") + relocatedContext := declarationContext(resolver.Enter(relocatedAttribute), false) + externalContext := codegen.NewAttributeContext(false, false, true, "external", codegen.NewNameScope()) + + _, _, err = codegen.GoTransform( + relocatedAttribute, + externalAttribute, + "record", + "externalRecord", + relocatedContext, + externalContext, + "convert", + true, + ) + require.NoError(t, err) + + toRelocated, toRelocatedHelpers, err := codegen.GoTransform( + externalAttribute, + relocatedAttribute, + "externalRecord", + "record", + externalContext, + relocatedContext, + "create", + true, + ) + require.NoError(t, err) + require.Contains(t, transformSource(toRelocated, toRelocatedHelpers), "ValueText2") +} + +// TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType +// verifies errors and interceptor fields use their actual package owner. +func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Collisions"} + local := resolverUserType("Fault", expr.String) + relocated := resolverUserType("fault", expr.String) + relocated.Attribute().AddMeta("struct:pkg:path", "errors") + container := resolverUserType("Container", &expr.Object{ + {Name: "fault", Attribute: &expr.AttributeExpr{Type: relocated}}, + }) + container.Attribute().AddMeta("struct:pkg:path", "types") + + generation := codegen.NewGeneration("generated.local/gen", nil) + servicePackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + localDeclaration, err := servicePackage.DeclareUserType(local) + require.NoError(t, err) + errorsPackage := generation.GeneratedPackage("generated.local/gen/errors") + _, err = errorsPackage.DeclareUserType(relocated) + require.NoError(t, err) + typesPackage := generation.GeneratedPackage("generated.local/gen/types") + _, err = typesPackage.DeclareUserType(container) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + resolver := newServiceResolver( + generation, + service, + servicePackagePath(generation.GenPkg, service), + ) + require.Equal(t, "Fault", localDeclaration.Name) + require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) + + errorData := buildErrorInitData(&expr.ErrorExpr{ + AttributeExpr: &expr.AttributeExpr{Type: relocated}, + Name: "fault", + }, resolver) + require.Equal(t, "errors_.Fault", errorData.TypeName) + require.Equal(t, "errors_.Fault", errorData.TypeRef) + + attributes := collectAttributes( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "fault", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + &expr.AttributeExpr{Type: container}, + resolver, + ) + require.Equal(t, "errors_.Fault", attributes[0].TypeRef) +} + +// TestDeclarationResolverPanicsWhenPlanOmittedType verifies render analysis +// fails immediately instead of allocating a missing declaration. +func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Missing"} + generation := codegen.NewGeneration("generated.local/gen", nil) + generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + require.NoError(t, generation.Freeze()) + resolver := newServiceResolver( + generation, + service, + servicePackagePath(generation.GenPkg, service), + ) + missing := resolverUserType("Missing", expr.String) + require.PanicsWithValue( + t, + "resolve user type \"Missing\" for service \"Missing\" in package \"generated.local/gen/missing\": user type \"Missing\" has no declaration in generated package \"generated.local/gen/missing\"", + func() { + resolver.Name(&expr.AttributeExpr{Type: missing}, "", false, true) + }, + ) +} + +// resolverUserType constructs one exact declaration for resolver tests. +func resolverUserType(name string, dataType expr.DataType) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: name, + UID: "resolver-test#" + name, + } +} + +// transformSource combines an inline transformation with every recursive +// helper so tests can assert the complete code emitted for one conversion. +func transformSource(code string, helpers []*codegen.TransformFunctionData) string { + var source strings.Builder + source.WriteString(code) + for _, helper := range helpers { + source.WriteString(helper.Code) + } + return source.String() +} diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index ca26a860f5..fbb0455442 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -1,3 +1,5 @@ +// This file renders one service's endpoint API and derives type imports only +// from the methods emitted into that endpoint file. package service import ( @@ -72,6 +74,7 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa svc := services.Get(service.Name) svcName := svc.PathName path := filepath.Join(codegen.Gendir, svcName, "endpoints.go") + outputPackage := genpkg + "/" + svcName data := endpointData(svc) var ( sections []*codegen.SectionTemplate @@ -85,6 +88,7 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa codegen.GoaImport("security"), {Path: genpkg + "/" + svcName + "/" + "views", Name: svc.ViewsPkg}, } + imports = append(imports, AttributeImports(genpkg, outputPackage, serviceReferenceAttributes(service)...)...) header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "endpoints-struct", diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index 4580cfdc90..ca33ec0c98 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -1,3 +1,5 @@ +// This file renders starter interceptor implementations that depend only on +// the service package and interceptor metadata, not service type packages. package service import ( @@ -38,16 +40,17 @@ func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *Ser if len(sdata.ServerInterceptors) > 0 { serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") if _, err := os.Stat(serverPath); os.IsNotExist(err) { + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "fmt"}, + {Path: "goa.design/clue/log"}, + codegen.GoaImport(""), + {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, + } files = append(files, &codegen.File{ Path: serverPath, SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), + codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", imports), { Name: "example-server-interceptor", Source: serviceTemplates.Read(exampleServerInterceptorT), @@ -62,16 +65,17 @@ func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *Ser if len(sdata.ClientInterceptors) > 0 { clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") if _, err := os.Stat(clientPath); os.IsNotExist(err) { + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "fmt"}, + {Path: "goa.design/clue/log"}, + codegen.GoaImport(""), + {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, + } files = append(files, &codegen.File{ Path: clientPath, SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), + codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", imports), { Name: "example-client-interceptor", Source: serviceTemplates.Read(exampleClientInterceptorT), diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index 1253fc5c0b..e767f89755 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -1,3 +1,5 @@ +// This file renders starter service implementations and imports only the +// generated types referenced by each implementation's service methods. package service import ( @@ -73,6 +75,7 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, {Path: "goa.design/clue/log"}, {Path: "goa.design/goa/v3/security"}, } + specs = append(specs, AttributeImports(genpkg, path.Dir(genpkg), serviceReferenceAttributes(svc)...)...) sections := []*codegen.SectionTemplate{ codegen.Header("", apipkg, specs), { @@ -92,8 +95,9 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, Data: data, }) } + resolver := newServiceResolver(services.generation, svc, path.Dir(genpkg)) for _, m := range svc.Methods { - sections = append(sections, basicEndpointSection(m, data)) + sections = append(sections, basicEndpointSection(m, data, resolver)) } // Add HandleStream method for JSON-RPC WebSocket services (not SSE) @@ -112,20 +116,20 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, } } -// basicEndpointSection returns a section with a basic implementation for the -// given method. -func basicEndpointSection(m *expr.MethodExpr, svcData *Data) *codegen.SectionTemplate { +// basicEndpointSection returns a starter implementation whose payload and +// result references come from the method's frozen generated-package records. +func basicEndpointSection(m *expr.MethodExpr, svcData *Data, resolver *declarationResolver) *codegen.SectionTemplate { md := svcData.Method(m.Name) ed := &basicEndpointData{ MethodData: md, ServiceVarName: svcData.VarName, } if m.Payload.Type != expr.Empty { - ed.PayloadFullRef = svcData.Scope.GoFullTypeRef(m.Payload, svcData.PkgName) + ed.PayloadFullRef = resolver.Ref(m.Payload, "") } if m.Result.Type != expr.Empty { - ed.ResultFullName = svcData.Scope.GoFullTypeName(m.Result, svcData.PkgName) - ed.ResultFullRef = svcData.Scope.GoFullTypeRef(m.Result, svcData.PkgName) + ed.ResultFullName = resolver.Name(m.Result, "", false, true) + ed.ResultFullRef = resolver.Ref(m.Result, "") ed.ResultIsStruct = expr.IsObject(m.Result.Type) if md.ViewedResult != nil { view := expr.DefaultView diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 80023889b0..790dc3edf2 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -46,7 +46,6 @@ type ( // generatedPackageData owns the render data emitted into one Go package. generatedPackageData struct { - importPath string outputPath string packageName string types map[*codegen.TypeDeclaration]*generatedTypeData @@ -57,6 +56,7 @@ type ( // error behavior at its metadata-selected file. generatedTypeData struct { declaration *codegen.TypeDeclaration + userType expr.UserType location *codegen.Location section *codegen.SectionTemplate error *codegen.SectionTemplate @@ -88,7 +88,7 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { return err } } - return nil + return planViews(root, generation, rootTypes) } // planningInputs returns the service attributes that can cause service types @@ -150,10 +150,8 @@ func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, loc return nil } seen[key] = struct{}{} - if typeLocation != nil { - if _, err := generation.GeneratedPackage(key.packagePath).DeclareUserType(declaredType); err != nil { - return err - } + if _, err := generation.GeneratedPackage(key.packagePath).DeclareUserType(declaredType); err != nil { + return err } return recurse(actual.Attribute(), typeLocation) case *expr.Object: @@ -224,20 +222,15 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati } return recurse(actual.ElemType, location) case *expr.Union: - var generatedPackage *codegen.GeneratedPackage - if location != nil { - packagePath := generatedPackagePath(generation.GenPkg, service, location) - generatedPackage = generation.GeneratedPackage(packagePath) - if _, err := generatedPackage.DeclareUnion(actual); err != nil { - return err - } + packagePath := generatedPackagePath(generation.GenPkg, service, location) + generatedPackage := generation.GeneratedPackage(packagePath) + if _, err := generatedPackage.DeclareUnion(actual); err != nil { + return err } for _, named := range actual.Values { if userType, ok := generatedUnionBranch(named, rootTypes); ok { - if generatedPackage != nil { - if _, err := generatedPackage.DeclareUnionBranchType(actual, named.Name, userType); err != nil { - return err - } + if _, err := generatedPackage.DeclareUnionBranchType(actual, named.Name, userType); err != nil { + return err } if err := recurse(userType.Attribute(), location); err != nil { return err @@ -252,6 +245,104 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati return nil } +// planViews rebuilds the same projected expression graph used by rendering, +// declares every derived view type, and then declares view-local union +// families after the derived type names have been recorded. +func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *rootTypeSet) error { + for _, service := range root.Services { + viewsPath := servicePackagePath(generation.GenPkg, service) + "/views" + views := generation.GeneratedPackage(viewsPath) + seenProjected := make(map[expr.UserType]expr.UserType) + derived := make(map[expr.UserType]codegen.DerivedTypeID) + var projectedRoots []*expr.AttributeExpr + for _, method := range service.Methods { + if !hasResultType(method.Result) { + continue + } + projected, source := projectedResultRoot(service, method) + pairs := projectTypePairs(projected, source, seenProjected) + for _, pair := range pairs { + identity := codegen.NewProjectedTypeID(pair.source) + if _, err := views.DeclareDerivedType(identity, codegen.Goify(pair.projected.Name(), true)); err != nil { + return err + } + derived[pair.projected.Origin()] = identity + } + removeMeta(projected) + projectedRoots = append(projectedRoots, projected) + + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + resultDeclaration, err := serviceTypes.UserType(rootTypes.canonical(resultType)) + if err != nil { + return err + } + if _, err := views.DeclareDerivedType(codegen.NewViewedResultTypeID(resultType), resultDeclaration.Name); err != nil { + return err + } + } + } + seenUnions := make(map[expr.UserType]struct{}) + for _, projected := range projectedRoots { + if err := planViewUnions(projected, views, derived, seenUnions); err != nil { + return err + } + } + } + return nil +} + +// planViewUnions declares every union family reachable from one projected +// graph. Projected user types already own their derived declarations; only a +// branch without one is a generated alias owned by its union family. +func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr) error { + return planViewUnions(attribute, generatedPackage, derived, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return recurse(actual.Attribute()) + case *expr.Object: + for _, field := range *actual { + if err := recurse(field.Attribute); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType) + case *expr.Map: + if err := recurse(actual.KeyType); err != nil { + return err + } + return recurse(actual.ElemType) + case *expr.Union: + if _, err := generatedPackage.DeclareUnion(actual); err != nil { + return err + } + for _, branch := range actual.Values { + if userType, ok := branch.Attribute.Type.(expr.UserType); ok { + if _, projected := derived[userType.Origin()]; !projected { + if _, err := generatedPackage.DeclareUnionBranchType(actual, branch.Name, userType); err != nil { + return err + } + } + } + if err := recurse(branch.Attribute); err != nil { + return err + } + } + } + return nil +} + // newRootTypeSet records the exact declarations whose compiler-created copies // share package records. Generated union aliases have independent origins // and never enter this set. @@ -329,7 +420,6 @@ func (d *ServicesData) generatedPackage(service *expr.ServiceExpr, location *cod packageName = location.PackageName() } generatedPackage := &generatedPackageData{ - importPath: importPath, outputPath: outputPath, packageName: packageName, types: make(map[*codegen.TypeDeclaration]*generatedTypeData), @@ -385,7 +475,7 @@ func (d *ServicesData) registerPackageData(service *expr.ServiceExpr, data *Data if userType.Loc == nil { continue } - d.registerType(service, userType.Declaration, userType.Loc, &codegen.SectionTemplate{ + d.registerType(service, userType.Declaration, userType.Type, userType.Loc, &codegen.SectionTemplate{ Name: "service-user-type", Source: serviceTemplates.Read(userTypeT), Data: userType, @@ -395,7 +485,7 @@ func (d *ServicesData) registerPackageData(service *expr.ServiceExpr, data *Data if errorType.Loc == nil || errorType.Type == expr.ErrorResult { continue } - d.registerType(service, errorType.Declaration, errorType.Loc, &codegen.SectionTemplate{ + d.registerType(service, errorType.Declaration, errorType.Type, errorType.Loc, &codegen.SectionTemplate{ Name: "error-user-type", Source: serviceTemplates.Read(userTypeT), Data: errorType, @@ -426,19 +516,20 @@ func (d *ServicesData) registerMethodType(service *expr.ServiceExpr, attribute * if err != nil { return err } - d.registerType(service, declaration, location, section) + d.registerType(service, declaration, userType, location, section) return nil } // registerType stores section under declaration. Repeated uses of the same // canonical record retain the first root-order section and emit once. -func (d *ServicesData) registerType(service *expr.ServiceExpr, declaration *codegen.TypeDeclaration, location *codegen.Location, section *codegen.SectionTemplate) { +func (d *ServicesData) registerType(service *expr.ServiceExpr, declaration *codegen.TypeDeclaration, userType expr.UserType, location *codegen.Location, section *codegen.SectionTemplate) { generatedPackage := d.generatedPackage(service, location) if _, ok := generatedPackage.types[declaration]; ok { return } generatedPackage.types[declaration] = &generatedTypeData{ declaration: declaration, + userType: userType, location: location, section: section, } diff --git a/codegen/service/imports.go b/codegen/service/imports.go new file mode 100644 index 0000000000..103ac00f10 --- /dev/null +++ b/codegen/service/imports.go @@ -0,0 +1,131 @@ +// This file computes imports from the service expressions rendered into one +// generated Go file. Callers identify the file's package explicitly so a file +// never imports itself and imports from unrelated services cannot leak into it. +package service + +import ( + "path" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // importCollector accumulates the imports referenced by one generated Go + // file while traversing recursive service type definitions. + importCollector struct { + genpkg string + outputPackage string + importsByPath map[string]*codegen.ImportSpec + } +) + +// AttributeImports returns the generated-type and struct:field:type imports +// referenced by attributes. Pass a named user type attribute when the file +// references that declaration. Pass the user type's underlying attribute when +// the file emits its definition. outputPackage is the full Go import path of +// the file receiving the imports. +func AttributeImports(genpkg, outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { + collector := newImportCollector(genpkg, outputPackage) + for _, attribute := range attributes { + collector.collect(attribute) + } + return collector.imports() +} + +// serviceReferenceAttributes returns the method and error attributes whose +// named declarations are referenced by service, endpoint, and client files. +func serviceReferenceAttributes(service *expr.ServiceExpr) []*expr.AttributeExpr { + attributes := make([]*expr.AttributeExpr, 0, len(service.Methods)*4+len(service.Errors)) + for _, serviceError := range service.Errors { + attributes = append(attributes, serviceError.AttributeExpr) + } + for _, method := range service.Methods { + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} + +// newImportCollector creates a file-scoped collector that omits imports of the +// package containing the generated file. +func newImportCollector(genpkg, outputPackage string) *importCollector { + return &importCollector{ + genpkg: genpkg, + outputPackage: outputPackage, + importsByPath: make(map[string]*codegen.ImportSpec), + } +} + +// collect walks inline shapes but stops at named types because a reference to a +// named declaration does not render that declaration's fields in the file. +func (c *importCollector) collect(attribute *expr.AttributeExpr) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + c.addMetaImport(attribute) + switch actual := attribute.Type.(type) { + case expr.UserType: + c.addLocation(codegen.UserTypeLocation(actual)) + case *expr.Object: + for _, named := range *actual { + c.collect(named.Attribute) + } + case *expr.Array: + c.collect(actual.ElemType) + case *expr.Map: + c.collect(actual.KeyType) + c.collect(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + c.collect(named.Attribute) + } + } +} + +// addLocation records the generated package selected by location unless it is +// the package currently being emitted. +func (c *importCollector) addLocation(location *codegen.Location) { + if location == nil { + return + } + importPath := path.Join(c.genpkg, location.RelImportPath) + if importPath == c.outputPackage { + return + } + c.importsByPath[importPath] = &codegen.ImportSpec{ + Name: location.PackageName(), + Path: importPath, + } +} + +// addMetaImport records the package named by struct:field:type metadata unless +// the metadata refers to the package currently being emitted. +func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { + _, spec := codegen.GetMetaType(attribute) + if spec == nil || spec.Path == c.outputPackage { + return + } + c.importsByPath[spec.Path] = spec +} + +// imports returns a deterministic snapshot of the packages collected for one +// generated file. +func (c *importCollector) imports() []*codegen.ImportSpec { + paths := make([]string, 0, len(c.importsByPath)) + for importPath := range c.importsByPath { + paths = append(paths, importPath) + } + sort.Strings(paths) + imports := make([]*codegen.ImportSpec, len(paths)) + for i, importPath := range paths { + imports[i] = c.importsByPath[importPath] + } + return imports +} diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index fe3ce8678d..77a2e33671 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies generated server and client interceptor data, including +// selected payload and result attribute references. package service import ( @@ -211,12 +213,12 @@ func TestCollectAttributes(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - scope := codegen.NewNameScope() + attributor := codegen.NewAttributeScope(codegen.NewNameScope()) if tc.panics { - assert.Panics(t, func() { collectAttributes(tc.attrNames, tc.parent, scope) }) + assert.Panics(t, func() { collectAttributes(tc.attrNames, tc.parent, attributor) }) return } - got := collectAttributes(tc.attrNames, tc.parent, scope) + got := collectAttributes(tc.attrNames, tc.parent, attributor) assert.Equal(t, tc.want, got) }) } diff --git a/codegen/service/service.go b/codegen/service/service.go index 5b79cc48c0..602a98f9a3 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -1,3 +1,5 @@ +// This file renders service declarations and aggregates relocated declarations +// into the exact generated Go packages and files that own them. package service import ( @@ -18,7 +20,7 @@ func Files(genpkg string, analyses []*ServicesData) []*codegen.File { files = append(files, serviceFiles(genpkg, service, services)...) } } - return append(files, generatedPackageFiles(analyses)...) + return append(files, generatedPackageFiles(genpkg, analyses)...) } // serviceFiles renders the declarations and helpers owned exclusively by one @@ -164,6 +166,19 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa codegen.GoaImport("security"), codegen.NewImport(svc.ViewsPkg, genpkg+"/"+svcName+"/views"), } + outputPackage := genpkg + "/" + svcName + attributes := serviceReferenceAttributes(service) + for _, userType := range svc.userTypes { + if userType.Loc == nil { + attributes = append(attributes, userType.Type.Attribute()) + } + } + for _, errorType := range svc.errorTypes { + if errorType.Loc == nil { + attributes = append(attributes, errorType.Type.Attribute()) + } + } + imports = append(imports, AttributeImports(genpkg, outputPackage, attributes...)...) header := codegen.Header(service.Name+" service", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "service", @@ -194,7 +209,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa // generatedPackageFiles renders each relocated user type in its configured // file and one sorted unions.go for every package that owns unions. -func generatedPackageFiles(analyses []*ServicesData) []*codegen.File { +func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.File { packages := aggregateGeneratedPackages(analyses) packagePaths := make([]string, 0, len(packages)) for packagePath := range packages { @@ -220,10 +235,18 @@ func generatedPackageFiles(analyses []*ServicesData) []*codegen.File { sort.Slice(generatedTypes, func(i, j int) bool { return generatedTypes[i].declaration.Name < generatedTypes[j].declaration.Name }) - sections := []*codegen.SectionTemplate{codegen.Header("User types", generatedPackage.packageName, []*codegen.ImportSpec{ + imports := []*codegen.ImportSpec{ codegen.SimpleImport("fmt"), codegen.GoaImport(""), - })} + } + collector := newImportCollector(genpkg, packagePath) + for _, generatedType := range generatedTypes { + collector.collect(generatedType.userType.Attribute()) + } + imports = append(imports, collector.imports()...) + sections := []*codegen.SectionTemplate{ + codegen.Header("User types", generatedPackage.packageName, imports), + } for _, generatedType := range generatedTypes { sections = append(sections, generatedType.section) if generatedType.error != nil { @@ -241,12 +264,26 @@ func generatedPackageFiles(analyses []*ServicesData) []*codegen.File { sort.Slice(unions, func(i, j int) bool { return unions[i].Name < unions[j].Name }) - sections := []*codegen.SectionTemplate{codegen.Header("Union types", generatedPackage.packageName, []*codegen.ImportSpec{ + imports := []*codegen.ImportSpec{ codegen.SimpleImport("bytes"), codegen.SimpleImport("encoding/json"), codegen.SimpleImport("fmt"), codegen.GoaImport(""), - })} + } + collector := newImportCollector(genpkg, packagePath) + for _, union := range unions { + for _, named := range union.source.Values { + if userType, ok := named.Attribute.Type.(expr.UserType); ok && codegen.UserTypeLocation(userType) == nil { + collector.collect(userType.Attribute()) + continue + } + collector.collect(named.Attribute) + } + } + imports = append(imports, collector.imports()...) + sections := []*codegen.SectionTemplate{ + codegen.Header("Union types", generatedPackage.packageName, imports), + } for _, union := range unions { sections = append(sections, &codegen.SectionTemplate{ Name: "service-union-type", @@ -272,7 +309,6 @@ func aggregateGeneratedPackages(analyses []*ServicesData) map[string]*generatedP generatedPackage, ok := packages[packagePath] if !ok { generatedPackage = &generatedPackageData{ - importPath: analyzedPackage.importPath, outputPath: analyzedPackage.outputPath, packageName: analyzedPackage.packageName, types: make(map[*codegen.TypeDeclaration]*generatedTypeData), @@ -318,92 +354,6 @@ func dedupeByResult(ms []*MethodData) []*MethodData { return out } -// SetUserTypeImports sets the import paths for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func SetUserTypeImports(genpkg string, d *Data) { - d.UserTypeImports = userTypeImports(genpkg, d) -} - -// AddServiceDataMetaTypeImports adds all imports defined by struct:field:type -// metadata for the service data. -func AddServiceDataMetaTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.metaTypeImports...) -} - -// AddUserTypeImports adds the imports for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func AddUserTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.UserTypeImports...) -} - -func metaTypeImports(svcExpr *expr.ServiceExpr, svcData *Data) []*codegen.ImportSpec { - seen := make(map[codegen.ImportSpec]struct{}) - var imports []*codegen.ImportSpec - for _, m := range svcExpr.Methods { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Payload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.StreamingPayload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Result)...) - } - for _, ut := range svcData.userTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(ut.Type.Attribute())...) - } - for _, et := range svcData.errorTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(et.Type.Attribute())...) - } - for _, t := range svcData.viewedResultTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - for _, t := range svcData.projectedTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - return imports -} - -func userTypeImports(genpkg string, d *Data) []*codegen.ImportSpec { - importsByPath := make(map[string]*codegen.ImportSpec) - - initLoc := func(loc *codegen.Location) { - if loc == nil { - return - } - importsByPath[loc.FilePath] = &codegen.ImportSpec{Name: loc.PackageName(), Path: genpkg + "/" + loc.RelImportPath} - } - - // Process method-specific locations - for _, m := range d.Methods { - initLoc(m.PayloadLoc) - initLoc(m.ResultLoc) - for _, l := range m.ErrorLocs { - initLoc(l) - } - } - - // Process service-level types once (not per method) - for _, ut := range d.userTypes { - initLoc(ut.Loc) - } - for _, et := range d.errorTypes { - initLoc(et.Loc) - } - - imports := make([]*codegen.ImportSpec, 0, len(importsByPath)) - for _, imp := range importsByPath { // Order does not matter, imports are sorted during formatting. - imports = append(imports, imp) - } - return imports -} - -func appendUniqueImport(imports []*codegen.ImportSpec, seen map[codegen.ImportSpec]struct{}, specs ...*codegen.ImportSpec) []*codegen.ImportSpec { - for _, spec := range specs { - if _, ok := seen[*spec]; ok { - continue - } - seen[*spec] = struct{}{} - imports = append(imports, spec) - } - return imports -} - func errorName(et *UserTypeData) string { obj := expr.AsObject(et.Type) if obj != nil { diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index 394da4336d..c50c8ffe7c 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -1,3 +1,6 @@ +// This file analyzes evaluated service designs into immutable render data. +// Public type declarations and references come from the frozen generated +// package catalog; mutable scopes are used only for private helper names. package service import ( @@ -81,10 +84,6 @@ type ( // ProtoImports lists the import specifications for the custom // proto types used by the service. ProtoImports []*codegen.ImportSpec - // UserTypeImports lists the import specifications for the user types - // used by the service. - UserTypeImports []*codegen.ImportSpec - // userTypes lists the type definitions that the service depends on. userTypes []*UserTypeData // errorTypes lists the error type definitions that the service depends on. @@ -99,9 +98,9 @@ type ( unions []*UnionTypeData // viewedResultTypes lists all the viewed method result types. viewedResultTypes []*ViewedResultTypeData - // metaTypeImports lists the imports derived from struct:field:type - // metadata for the service. - metaTypeImports []*codegen.ImportSpec + // viewDerived binds the independently rebuilt view graph to declarations + // reserved while the service was planned. + viewDerived map[expr.UserType]codegen.DerivedTypeID } // MethodData describes a single service method. @@ -461,6 +460,8 @@ type ( TypeKey string // ValueKey is the value field name for JSON marshaling (defaults to "value"). ValueKey string + + source *expr.Union } // UnionFieldData describes a single branch of a union. @@ -469,6 +470,8 @@ type ( Name string // KindConst is the Go identifier for the kind constant of this branch. KindConst string + // Constructor is the Go identifier for the branch constructor function. + Constructor string // FieldName is the struct field name in the union. FieldName string // FieldType is the Go type used in the union struct field and public API. @@ -655,16 +658,24 @@ type ( identity codegen.UnionTypeID } - // userTypeDataKey distinguishes ordinary DSL declarations by their stable - // ID and generated union branch aliases by their package declaration record. + // userTypeDataKey distinguishes exact in-memory declarations and the frozen + // package declaration selected for each one. userTypeDataKey struct { - id string + origin expr.UserType declaration *codegen.TypeDeclaration } - // unionBranchLookup resolves a generated branch alias from its owning frozen - // union declaration family. - unionBranchLookup func(*expr.NamedAttributeExpr) (*codegen.TypeDeclaration, error) + // projectedTypePair binds one rebuilt view declaration to the exact source + // declaration that gives it a stable DerivedTypeID. + projectedTypePair struct { + source expr.UserType + projected expr.UserType + sourceAttribute *expr.AttributeExpr + projectedAttribute *expr.AttributeExpr + } + + // unionBranchLookup resolves one branch's complete frozen declaration family. + unionBranchLookup func(*expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) ) // NewServicesData analyzes root using declarations frozen by generation. @@ -781,20 +792,30 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projTypes []*ProjectedTypeData viewedRTs []*ViewedResultTypeData ) - scope := codegen.NewNameScope() + servicePackage := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg, service)) + scope := servicePackage.Scope().Fork() scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - viewScope := codegen.NewNameScope() + viewScope := d.generation.GeneratedPackage( + servicePackagePath(d.generation.GenPkg, service) + "/views", + ).Scope().Fork() pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") viewspkg := pkgName + "views" seenTypes := make(map[userTypeDataKey]struct{}) seenErrors := make(map[string]struct{}) - seenProj := make(map[string]*ProjectedTypeData) + seenProjected := make(map[expr.UserType]expr.UserType) + seenProj := make(map[expr.UserType]*ProjectedTypeData) seenViewed := make(map[string]*ViewedResultTypeData) + viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) + serviceResolver := newServiceResolver( + d.generation, + service, + servicePackagePath(d.generation.GenPkg, service), + ) // A function to collect user types from an error expression recordError := func(er *expr.ErrorExpr) error { - collected, err := d.collectTypes(er.AttributeExpr, service, scope, seenTypes, nil, nil) + collected, err := d.collectTypes(er.AttributeExpr, service, serviceResolver, seenTypes, nil) if err != nil { return err } @@ -804,7 +825,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { return nil } seenErrors[er.Name] = struct{}{} - errorInits = append(errorInits, buildErrorInitData(er, scope)) + errorInits = append(errorInits, buildErrorInitData(er, serviceResolver)) } return nil } @@ -820,11 +841,13 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { return nil } var loc *codegen.Location + resolver := serviceResolver if ut, ok := att.Type.(expr.UserType); ok { loc = codegen.UserTypeLocation(ut) + resolver = serviceResolver.Enter(att).(*declarationResolver) att = ut.Attribute() } - collected, err := d.collectTypes(att, service, scope, seenTypes, loc, nil) + collected, err := d.collectTypes(att, service, resolver, seenTypes, loc) if err != nil { return err } @@ -851,8 +874,31 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { // Collect projected types if hasResultType(m.Result) { projected, result := projectedResultRoot(service, m) - ptypes := collectProjectedTypes(projected, result, viewspkg, scope, viewScope, seenProj) - projTypes = append(projTypes, ptypes...) + pairs := projectTypePairs(projected, result, seenProjected) + removeMeta(projected) + views := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg, service) + "/views") + for _, pair := range pairs { + identity := codegen.NewProjectedTypeID(pair.source) + viewDerived[pair.projected.Origin()] = identity + } + viewResolver := newViewResolver(d.generation, service, viewDerived) + for _, pair := range pairs { + identity := codegen.NewProjectedTypeID(pair.source) + declaration, err := views.DerivedType(identity) + if err != nil { + return nil, err + } + projectedType := buildProjectedType( + pair.projectedAttribute, + pair.sourceAttribute, + viewspkg, + serviceResolver, + viewResolver, + declaration, + ) + seenProj[pair.source.Origin()] = projectedType + projTypes = append(projTypes, projectedType) + } } for _, er := range m.Errors { if err := recordError(er); err != nil { @@ -867,7 +913,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { // reads the design and never mutates it, so a raw object here means the // root was not normalized. recordMethodType := func(m *expr.MethodExpr, att *expr.AttributeExpr) { - if att == nil { + if att == nil || att.Type == expr.Empty { return } if _, ok := att.Type.(*expr.Object); ok { @@ -876,7 +922,11 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { service.Name, m.Name)) // bug } if ut, ok := att.Type.(expr.UserType); ok { - seenTypes[userTypeDataKey{id: ut.ID()}] = struct{}{} + declaration := serviceResolver.Enter(att).(*declarationResolver).userType( + serviceResolver.owner(att), + ut, + ) + seenTypes[userTypeDataKey{origin: ut.Origin(), declaration: declaration}] = struct{}{} } } @@ -899,7 +949,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { if len(svcs) > 0 { // Force generate type only in the specified services if slices.Contains(svcs, service.Name) { - collected, err := d.collectTypes(att, service, scope, seenTypes, nil, nil) + collected, err := d.collectTypes(att, service, serviceResolver, seenTypes, nil) if err != nil { return nil, err } @@ -908,7 +958,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { continue } // Force generate type in all the services - collected, err := d.collectTypes(att, service, scope, seenTypes, nil, nil) + collected, err := d.collectTypes(att, service, serviceResolver, seenTypes, nil) if err != nil { return nil, err } @@ -921,7 +971,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { ) methods = make([]*MethodData, len(service.Methods)) for i, e := range service.Methods { - m, err := d.buildMethodData(e, scope) + m, err := d.buildMethodData(e, scope, serviceResolver) if err != nil { return nil, err } @@ -941,9 +991,22 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { m.ViewedResult = vrt continue } - projected := seenProj[rt.ID()] + projected := seenProj[rt.Origin()] projAtt := &expr.AttributeExpr{Type: projected.Type} - vrt := buildViewedResultType(e.Result, projAtt, viewspkg, scope, viewScope) + viewedDeclaration, err := d.generation.GeneratedPackage( + servicePackagePath(d.generation.GenPkg, service) + "/views", + ).DerivedType(codegen.NewViewedResultTypeID(rt)) + if err != nil { + return nil, err + } + vrt := buildViewedResultType( + e.Result, + projAtt, + viewspkg, + serviceResolver, + newViewResolver(d.generation, service, viewDerived), + viewedDeclaration, + ) found := false for _, rt := range viewedRTs { if rt.Type.ID() == vrt.Type.ID() { @@ -970,9 +1033,9 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { // Collect union sum-type definitions for the service. unionByPackage := make(map[unionDataKey]*UnionTypeData) - seen := make(map[string]struct{}) + seen := make(map[expr.UserType]struct{}) collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) error { - return d.collectUnionTypes(att, service, scope, loc, unionByPackage, seen, false) + return d.collectUnionTypes(att, service, serviceResolver, loc, unionByPackage, seen, false) } for _, t := range types { if err := collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc); err != nil { @@ -1042,8 +1105,8 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { ViewsPkg: viewspkg, Methods: methods, Schemes: schemes, - ServerInterceptors: d.collectInterceptors(service, methods, scope, true), - ClientInterceptors: d.collectInterceptors(service, methods, scope, false), + ServerInterceptors: d.collectInterceptors(service, methods, serviceResolver, true), + ClientInterceptors: d.collectInterceptors(service, methods, serviceResolver, false), Scope: scope, ViewScope: viewScope, errorTypes: errTypes, @@ -1052,9 +1115,8 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projectedTypes: projTypes, viewedResultTypes: viewedRTs, unions: unions, + viewDerived: viewDerived, } - data.metaTypeImports = metaTypeImports(service, data) - if err := d.registerPackageData(service, data); err != nil { return nil, err } @@ -1063,7 +1125,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { // collectInterceptors returns the set of interceptors defined on the given // service including any interceptor defined on specific service methods or API. -func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*MethodData, scope *codegen.NameScope, server bool) []*InterceptorData { +func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*MethodData, resolver *declarationResolver, server bool) []*InterceptorData { var ints []*expr.InterceptorExpr if server { ints = d.Root.API.ServerInterceptors @@ -1091,32 +1153,29 @@ func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*Met res := make([]*InterceptorData, 0, len(ints)) for _, i := range ints { - res = append(res, buildInterceptorData(svc, methods, i, scope, server)) + res = append(res, buildInterceptorData(svc, methods, i, resolver, server)) } return res } -// typeContext returns a contextual attribute for service types. Service types -// are Go types and uses non-pointers to hold attributes having default values. -func typeContext(scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, "", scope) -} - -// projectedTypeContext returns a contextual attribute for a projected type. -// Projected types are Go types that uses pointers for all attributes (even the -// required ones). -func projectedTypeContext(pkg string, ptr bool, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(ptr, false, true, pkg, scope) +// declarationContext configures transformations and validations to resolve +// every named service or view type through its planned package declaration. +func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.AttributeContext { + return &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: true, + Scope: resolver, + } } // collectTypes recurses through the attribute to gather all user types and // binds relocated types to their frozen package declarations. -func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.ServiceExpr, localScope *codegen.NameScope, seen map[userTypeDataKey]struct{}, loc *codegen.Location, branch *unionBranch) (data []*UserTypeData, err error) { +func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.ServiceExpr, resolver *declarationResolver, seen map[userTypeDataKey]struct{}, loc *codegen.Location) (data []*UserTypeData, err error) { if at == nil || at.Type == expr.Empty { return nil, nil } collect := func(at *expr.AttributeExpr, loc *codegen.Location) error { - collected, err := d.collectTypes(at, service, localScope, seen, loc, nil) + collected, err := d.collectTypes(at, service, resolver, seen, loc) data = append(data, collected...) return err } @@ -1126,44 +1185,28 @@ func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.Servic if typeLoc == nil { typeLoc = loc } - typeScope := localScope - var declaration *codegen.TypeDeclaration - if typeLoc != nil { - generatedPackage := d.generation.GeneratedPackage( - generatedPackagePath(d.generation.GenPkg, service, typeLoc), - ) - if branch == nil { - declaration, err = generatedPackage.UserType(d.rootTypes.canonical(dt)) - } else { - declaration, err = generatedPackage.UnionBranchType(branch.union, branch.name, dt) - } - if err != nil { - return nil, err - } - typeScope = generatedPackage.Scope() - // Keep the service-local reservations used by method and helper names; - // the relocated package supplies the public declaration names. - localScope.GoTypeName(at) - localScope.GoTypeDef(dt.Attribute(), false, true) - localScope.GoTypeRef(at) - } - key := userTypeDataKey{id: dt.ID(), declaration: declaration} + entered := resolver.Enter(at).(*declarationResolver) + declaration := entered.userType(entered.currentPath, dt) + key := userTypeDataKey{origin: dt.Origin(), declaration: declaration} if _, ok := seen[key]; ok { return nil, nil } + definitionResolver := entered.inOutputPackage(entered.currentPath) data = append(data, &UserTypeData{ Declaration: declaration, Name: dt.Name(), - VarName: typeName(declaration, typeScope, at), + VarName: declaration.Name, Description: dt.Attribute().Description, - Def: typeScope.GoTypeDef(dt.Attribute(), false, true), - Ref: userTypeRef(declaration, typeScope, at), + Def: definitionResolver.Def(dt.Attribute(), false, true), + Ref: definitionResolver.Ref(at, ""), Loc: typeLoc, Type: dt, }) seen[key] = struct{}{} - if err := collect(dt.Attribute(), typeLoc); err != nil { - return nil, err + collected, collectErr := d.collectTypes(dt.Attribute(), service, entered, seen, typeLoc) + data = append(data, collected...) + if collectErr != nil { + return nil, collectErr } case *expr.Object: for _, nat := range *dt { @@ -1185,14 +1228,7 @@ func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.Servic case *expr.Union: for _, nat := range dt.Values { if userType, ok := generatedUnionBranch(nat, d.rootTypes); ok && loc != nil { - collected, collectErr := d.collectTypes( - &expr.AttributeExpr{Type: userType}, - service, - localScope, - seen, - loc, - &unionBranch{union: dt, name: nat.Name}, - ) + collected, collectErr := d.collectTypes(&expr.AttributeExpr{Type: userType}, service, resolver, seen, loc) data = append(data, collected...) if collectErr != nil { return nil, collectErr @@ -1207,16 +1243,6 @@ func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.Servic return data, nil } -// userTypeRef returns the reference spelling for a relocated declaration. A -// generated union branch alias is not registered as an ordinary scope type, so -// its package-owned declaration name is used directly. -func userTypeRef(declaration *codegen.TypeDeclaration, scope *codegen.NameScope, attribute *expr.AttributeExpr) string { - if declaration == nil { - return scope.GoTypeRef(attribute) - } - return declaration.Ref(attribute.Type) -} - // collectUnionTypes traverses the attribute to gather all union sum-type // definitions referenced by the service. It records each emitted definition by // generated package so Extend can copy one union into multiple packages while @@ -1224,26 +1250,27 @@ func userTypeRef(declaration *codegen.TypeDeclaration, scope *codegen.NameScope, // provided location is used for all nested user types so that unions are // generated in the views package and refer to view-local types (preventing // import cycles). -func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr.ServiceExpr, localScope *codegen.NameScope, loc *codegen.Location, unions map[unionDataKey]*UnionTypeData, seen map[string]struct{}, view bool) error { +func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr.ServiceExpr, resolver *declarationResolver, loc *codegen.Location, unions map[unionDataKey]*UnionTypeData, seen map[expr.UserType]struct{}, view bool) error { if att == nil || att.Type == expr.Empty { return nil } recurse := func(att *expr.AttributeExpr, loc *codegen.Location) error { - return d.collectUnionTypes(att, service, localScope, loc, unions, seen, view) + return d.collectUnionTypes(att, service, resolver, loc, unions, seen, view) } switch dt := att.Type.(type) { case expr.UserType: - if _, ok := seen[dt.ID()]; ok { + if _, ok := seen[dt.Origin()]; ok { return nil } - seen[dt.ID()] = struct{}{} + seen[dt.Origin()] = struct{}{} typeLoc := loc + entered := resolver.Enter(att).(*declarationResolver) if !view { if ownLocation := codegen.UserTypeLocation(dt); ownLocation != nil { typeLoc = ownLocation } } - return recurse(dt.Attribute(), typeLoc) + return d.collectUnionTypes(dt.Attribute(), service, entered, typeLoc, unions, seen, view) case *expr.Object: for _, nat := range sortedNamedAttributes(*dt) { if err := recurse(nat.Attribute, loc); err != nil { @@ -1266,26 +1293,15 @@ func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr. } key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(dt)} if _, ok := unions[key]; !ok { - unionScope := localScope - var declaration *codegen.UnionDeclaration - var branchLookup unionBranchLookup - if !view && loc != nil { - generatedPackage := d.generation.GeneratedPackage(packagePath) - var err error - declaration, err = generatedPackage.Union(dt) - if err != nil { - return err - } - unionScope = generatedPackage.Scope() - branchLookup = func(branch *expr.NamedAttributeExpr) (*codegen.TypeDeclaration, error) { - userType, ok := generatedUnionBranch(branch, d.rootTypes) - if !ok { - return nil, nil - } - return generatedPackage.UnionBranchType(dt, branch.Name, userType) - } + generatedPackage := d.generation.GeneratedPackage(packagePath) + declaration, err := generatedPackage.Union(dt) + if err != nil { + return err + } + branchLookup := func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { + return generatedPackage.UnionBranch(dt, branch.Name) } - unionData, err := buildUnionTypeData(dt, declaration, unionScope, loc, view, branchLookup) + unionData, err := buildUnionTypeData(dt, declaration, resolver.inOutputPackage(packagePath), loc, view, branchLookup) if err != nil { return err } @@ -1315,53 +1331,22 @@ func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr. // union is generated in the views package: field types are computed using the // view scope and are always emitted unqualified so they refer to the // view-local projected types. -func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, scope *codegen.NameScope, loc *codegen.Location, view bool, branchLookup unionBranchLookup) (*UnionTypeData, error) { - att := &expr.AttributeExpr{Type: u} - var name, kindName string - if declaration != nil { - name = declaration.Name - kindName = declaration.KindName - } else { - name = scope.GoTypeName(att) - kindName = scope.Unique(name + "Kind") - } - var unionPkg string - if !view && loc != nil { - unionPkg = loc.PackageName() - } - +func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, attributor codegen.Attributor, loc *codegen.Location, view bool, branchLookup unionBranchLookup) (*UnionTypeData, error) { fields := make([]*UnionFieldData, len(u.Values)) for i, nat := range u.Values { fieldName := codegen.Goify(nat.Name, true) - var pkg string - if !view { - if tloc := codegen.UserTypeLocation(nat.Attribute.Type); tloc != nil { - pkg = tloc.PackageName() - if pkg == unionPkg { - pkg = "" - } - } - } - var fieldType string - if branchLookup != nil { - branchDeclaration, err := branchLookup(nat) - if err != nil { - return nil, err - } - if branchDeclaration != nil && pkg == "" { - fieldType = userTypeRef(branchDeclaration, scope, nat.Attribute) - } - } - if fieldType == "" { - fieldType = scope.GoFullTypeRef(nat.Attribute, pkg) + branchDeclaration, err := branchLookup(nat) + if err != nil { + return nil, err } + fieldType := attributor.Enter(nat.Attribute).Ref(nat.Attribute, "") primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) _, isUserType := nat.Attribute.Type.(expr.UserType) - emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && pkg == "" - kindConst := kindName + codegen.Goify(nat.Name, true) + emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && attributor.Package(nat.Attribute) == "" fields[i] = &UnionFieldData{ Name: nat.Name, - KindConst: kindConst, + KindConst: branchDeclaration.KindConst, + Constructor: branchDeclaration.Constructor, FieldName: fieldName, FieldType: fieldType, Nilable: codegen.IsNilable(nat.Attribute.Type), @@ -1373,41 +1358,16 @@ func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, sc return &UnionTypeData{ Declaration: declaration, - Name: name, - KindName: kindName, + Name: declaration.Name, + KindName: declaration.KindName, Fields: fields, Loc: loc, TypeKey: u.GetTypeKey(), ValueKey: u.GetValueKey(), + source: u, }, nil } -// typeName returns declaration's frozen name for a relocated type and falls -// back to the service-local scope for ordinary service and view types. -func typeName(declaration *codegen.TypeDeclaration, scope *codegen.NameScope, attribute *expr.AttributeExpr) string { - if declaration != nil { - return declaration.Name - } - return scope.GoTypeName(attribute) -} - -// typeScope returns the frozen package scope and declaration for a relocated -// user type, or the mutable service scope for an ordinary service type. -func (d *ServicesData) typeScope(service *expr.ServiceExpr, localScope *codegen.NameScope, attribute *expr.AttributeExpr, location *codegen.Location) (*codegen.NameScope, *codegen.TypeDeclaration, error) { - if location == nil { - return localScope, nil, nil - } - userType := attribute.Type.(expr.UserType) - generatedPackage := d.generation.GeneratedPackage( - generatedPackagePath(d.generation.GenPkg, service, location), - ) - declaration, err := generatedPackage.UserType(d.rootTypes.canonical(userType)) - if err != nil { - return nil, nil, err - } - return generatedPackage.Scope(), declaration, nil -} - // sortedNamedAttributes returns object fields sorted by attribute name. // Union naming uses NameScope uniqueness, so callers that discover unions while // traversing objects must use a deterministic field order to avoid oscillating @@ -1438,21 +1398,34 @@ func primitiveAliasGoType(dt expr.DataType) (string, bool) { } } +// serviceTypeData returns the frozen declaration name, owning-package +// definition, and service-package reference for one normalized method type. +func serviceTypeData(attribute *expr.AttributeExpr, resolver *declarationResolver) (string, string, string) { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return resolver.Name(attribute, "", false, true), + "", + resolver.Ref(attribute, "") + } + entered := resolver.Enter(attribute).(*declarationResolver) + declaration := entered.userType(entered.currentPath, userType) + definitionResolver := entered.inOutputPackage(entered.currentPath) + return declaration.Name, + definitionResolver.Def(userType.Attribute(), false, true), + resolver.Ref(attribute, "") +} + // buildErrorInitData creates the data needed to generate code around endpoint error return values. -func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInitData { +func buildErrorInitData(er *expr.ErrorExpr, resolver *declarationResolver) *ErrorInitData { _, temporary := er.Meta["goa:error:temporary"] _, timeout := er.Meta["goa:error:timeout"] _, fault := er.Meta["goa:error:fault"] - var pkg string - if ut, ok := er.Type.(expr.UserType); ok { - pkg = codegen.UserTypeLocation(ut).PackageName() - } return &ErrorInitData{ Name: fmt.Sprintf("Make%s", codegen.Goify(er.Name, true)), Description: er.Description, ErrName: er.Name, - TypeName: scope.GoTypeName(er.AttributeExpr), - TypeRef: scope.GoFullTypeRef(er.AttributeExpr, pkg), + TypeName: resolver.Name(er.AttributeExpr, "", false, true), + TypeRef: resolver.Ref(er.AttributeExpr, ""), Temporary: temporary, Timeout: timeout, Fault: fault, @@ -1461,7 +1434,7 @@ func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInit // buildMethodData creates the data needed to render the given endpoint. It // records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope) (*MethodData, error) { +func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope, resolver *declarationResolver) (*MethodData, error) { var ( vname string desc string @@ -1490,15 +1463,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc } if m.Payload.Type != expr.Empty { payloadLoc = codegen.UserTypeLocation(m.Payload.Type) - payloadScope, declaration, err := d.typeScope(m.Service, scope, m.Payload, payloadLoc) - if err != nil { - return nil, err - } - payloadName = typeName(declaration, payloadScope, m.Payload) - if dt, ok := m.Payload.Type.(expr.UserType); ok { - payloadDef = payloadScope.GoTypeDef(dt.Attribute(), false, true) - } - payloadRef = payloadScope.GoFullTypeRef(m.Payload, payloadLoc.PackageName()) + payloadName, payloadDef, payloadRef = serviceTypeData(m.Payload, resolver) payloadDesc = m.Payload.Description if payloadDesc == "" { payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", @@ -1508,15 +1473,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc } if m.Result.Type != expr.Empty { resultLoc = codegen.UserTypeLocation(m.Result.Type) - resultScope, declaration, err := d.typeScope(m.Service, scope, m.Result, resultLoc) - if err != nil { - return nil, err - } - rname = typeName(declaration, resultScope, m.Result) - if dt, ok := m.Result.Type.(expr.UserType); ok { - resultDef = resultScope.GoTypeDef(dt.Attribute(), false, true) - } - resultRef = resultScope.GoFullTypeRef(m.Result, resultLoc.PackageName()) + rname, resultDef, resultRef = serviceTypeData(m.Result, resolver) resultDesc = m.Result.Description if resultDesc == "" { resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", @@ -1528,7 +1485,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc errors = make([]*ErrorInitData, len(m.Errors)) errorLocs = make(map[string]*codegen.Location, len(m.Errors)) for i, er := range m.Errors { - errors[i] = buildErrorInitData(er, scope) + errors[i] = buildErrorInitData(er, resolver) errorLocs[er.Name] = codegen.UserTypeLocation(er.Type) } } @@ -1614,14 +1571,14 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc ResponseStruct: vname + "ResponseData", } - if err := d.initStreamData(data, m, vname, rname, resultRef, scope); err != nil { + if err := d.initStreamData(data, m, vname, rname, resultRef, scope, resolver); err != nil { return nil, err } return data, nil } // initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope) error { +func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope, resolver *declarationResolver) error { if !m.IsStreaming() && !m.HasMixedResults() { return nil } @@ -1637,22 +1594,9 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam // If StreamingResult is different from Result, use it for streaming if m.HasMixedResults() && m.StreamingResult != nil && m.StreamingResult.Type != expr.Empty { - resultScope, declaration, err := d.typeScope( - m.Service, - scope, - m.StreamingResult, - codegen.UserTypeLocation(m.StreamingResult.Type), - ) - if err != nil { - return err - } - srname = typeName(declaration, resultScope, m.StreamingResult) - srref = resultScope.GoTypeRef(m.StreamingResult) + srname, data.StreamingResultDef, srref = serviceTypeData(m.StreamingResult, resolver) data.StreamingResult = srname data.StreamingResultRef = srref - if dt, ok := m.StreamingResult.Type.(expr.UserType); ok { - data.StreamingResultDef = resultScope.GoTypeDef(dt.Attribute(), false, true) - } data.StreamingResultDesc = m.StreamingResult.Description if data.StreamingResultDesc == "" { data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", @@ -1662,20 +1606,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam } if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { - payloadScope, declaration, err := d.typeScope( - m.Service, - scope, - m.StreamingPayload, - codegen.UserTypeLocation(m.StreamingPayload.Type), - ) - if err != nil { - return err - } - spayloadName = typeName(declaration, payloadScope, m.StreamingPayload) - spayloadRef = payloadScope.GoTypeRef(m.StreamingPayload) - if dt, ok := m.StreamingPayload.Type.(expr.UserType); ok { - spayloadDef = payloadScope.GoTypeDef(dt.Attribute(), false, true) - } + spayloadName, spayloadDef, spayloadRef = serviceTypeData(m.StreamingPayload, resolver) spayloadDesc = m.StreamingPayload.Description if spayloadDesc == "" { spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", @@ -1773,7 +1704,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam } // buildInterceptorData creates the data needed to generate interceptor code. -func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr.InterceptorExpr, scope *codegen.NameScope, server bool) *InterceptorData { +func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr.InterceptorExpr, resolver *declarationResolver, server bool) *InterceptorData { data := &InterceptorData{ Name: codegen.Goify(i.Name, true), DesignName: i.Name, @@ -1793,14 +1724,14 @@ func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr. if in.Name == i.Name { if !attributesCollected { payload, result, streamingPayload := m.Payload, m.Result, m.StreamingPayload - data.ReadPayload = collectAttributes(i.ReadPayload, payload, scope) - data.WritePayload = collectAttributes(i.WritePayload, payload, scope) - data.ReadResult = collectAttributes(i.ReadResult, result, scope) - data.WriteResult = collectAttributes(i.WriteResult, result, scope) - data.ReadStreamingPayload = collectAttributes(i.ReadStreamingPayload, streamingPayload, scope) - data.WriteStreamingPayload = collectAttributes(i.WriteStreamingPayload, streamingPayload, scope) - data.ReadStreamingResult = collectAttributes(i.ReadStreamingResult, result, scope) - data.WriteStreamingResult = collectAttributes(i.WriteStreamingResult, result, scope) + data.ReadPayload = collectAttributes(i.ReadPayload, payload, resolver) + data.WritePayload = collectAttributes(i.WritePayload, payload, resolver) + data.ReadResult = collectAttributes(i.ReadResult, result, resolver) + data.WriteResult = collectAttributes(i.WriteResult, result, resolver) + data.ReadStreamingPayload = collectAttributes(i.ReadStreamingPayload, streamingPayload, resolver) + data.WriteStreamingPayload = collectAttributes(i.WriteStreamingPayload, streamingPayload, resolver) + data.ReadStreamingResult = collectAttributes(i.ReadStreamingResult, result, resolver) + data.WriteStreamingResult = collectAttributes(i.WriteStreamingResult, result, resolver) if len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 { data.HasPayloadAccess = true } @@ -1967,8 +1898,10 @@ func schemeScopes(s *expr.SchemeExpr) []string { return scopes } -// collectAttributes builds AttributeData from an AttributeExpr -func collectAttributes(attrNames, parent *expr.AttributeExpr, scope *codegen.NameScope) []*AttributeData { +// collectAttributes resolves the interceptor fields selected from parent into +// the generated names, type references, and pointer behavior rendered by the +// interceptor templates. +func collectAttributes(attrNames, parent *expr.AttributeExpr, resolver codegen.Attributor) []*AttributeData { if attrNames == nil { return nil } @@ -1977,6 +1910,7 @@ func collectAttributes(attrNames, parent *expr.AttributeExpr, scope *codegen.Nam return nil } data := make([]*AttributeData, len(*obj)) + parentResolver := resolver.Enter(parent) for i, nat := range *obj { parentAttr := parent.Find(nat.Name) if parentAttr == nil { @@ -1984,77 +1918,66 @@ func collectAttributes(attrNames, parent *expr.AttributeExpr, scope *codegen.Nam // here would surface as a nil deref at template render time. panic(fmt.Sprintf("attribute %q not found in parent attribute", nat.Name)) // bug } - var pkg string - if loc := codegen.UserTypeLocation(parentAttr.Type); loc != nil { - pkg = loc.PackageName() - } data[i] = &AttributeData{ Name: codegen.Goify(nat.Name, true), - TypeRef: scope.GoFullTypeRef(parentAttr, pkg), + TypeRef: parentResolver.Ref(parentAttr, parentResolver.Package(parentAttr)), Pointer: parent.IsPrimitivePointer(nat.Name, true), } } return data } -// collectProjectedTypes builds a projected type for every user type found when -// recursing through the attributes. The projected types live in the views -// package and support the marshaling and unmarshalling of result types that -// make use of views. We need to build projected types for all user types - not -// just result types - because user types may contain result types and thus may -// need to be marshalled in different ways depending on the view being used. -func collectProjectedTypes(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, seen map[string]*ProjectedTypeData) []*ProjectedTypeData { - collect := func(projected, att *expr.AttributeExpr) []*ProjectedTypeData { - return collectProjectedTypes(projected, att, viewspkg, scope, viewScope, seen) - } - var data []*ProjectedTypeData - switch pt := projected.Type.(type) { +// projectTypePairs rewrites a copied result graph into pointer-backed view +// types and returns each generated declaration with its exact source. The +// source Origin makes independently rebuilt plan and render graphs select the +// same package record. +func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) []*projectedTypePair { + collect := func(projected, source *expr.AttributeExpr) []*projectedTypePair { + return projectTypePairs(projected, source, seen) + } + switch projectedType := projected.Type.(type) { case expr.UserType: - dt := att.Type.(expr.UserType) - if pd, ok := seen[dt.ID()]; ok { - // a projected type is already created for this user type. We change the - // attribute type to this seen projected type. The seen projected type - // can be nil if the attribute type has a circular type definition in - // which case we don't change the attribute type until the projected type - // is created during the recursion. - if pd != nil { - projected.Type = pd.Type + sourceType := source.Type.(expr.UserType) + origin := sourceType.Origin() + if existing, ok := seen[origin]; ok { + if existing != nil { + projected.Type = existing } - return data - } - seen[dt.ID()] = nil - pt.Rename(pt.Name() + "View") - // We recurse before building the projected type so that user types within - // a projected type is also converted to their respective projected types. - types := collect(pt.Attribute(), dt.Attribute()) - pd := buildProjectedType(projected, att, viewspkg, scope, viewScope) - seen[dt.ID()] = pd - data = append(data, pd) - data = append(data, types...) + return nil + } + seen[origin] = nil + projectedType.Rename(projectedType.Name() + "View") + nested := collect(projectedType.Attribute(), sourceType.Attribute()) + seen[origin] = projectedType + return append([]*projectedTypePair{{ + source: sourceType, + projected: projectedType, + sourceAttribute: source, + projectedAttribute: projected, + }}, nested...) case *expr.Array: - dt := att.Type.(*expr.Array) - types := collect(pt.ElemType, dt.ElemType) - data = append(data, types...) + return collect(projectedType.ElemType, source.Type.(*expr.Array).ElemType) case *expr.Map: - dt := att.Type.(*expr.Map) - types := collect(pt.KeyType, dt.KeyType) - data = append(data, types...) - types = collect(pt.ElemType, dt.ElemType) - data = append(data, types...) + sourceMap := source.Type.(*expr.Map) + pairs := collect(projectedType.KeyType, sourceMap.KeyType) + return append(pairs, collect(projectedType.ElemType, sourceMap.ElemType)...) case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - types := collect(n.Attribute, dt.Attribute(n.Name)) - data = append(data, types...) + sourceObject := source.Type.(*expr.Object) + var pairs []*projectedTypePair + for _, field := range *projectedType { + pairs = append(pairs, collect(field.Attribute, sourceObject.Attribute(field.Name))...) } + return pairs case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - types := collect(n.Attribute, dt.Values[i].Attribute) - data = append(data, types...) + sourceUnion := source.Type.(*expr.Union) + var pairs []*projectedTypePair + for index, branch := range projectedType.Values { + pairs = append(pairs, collect(branch.Attribute, sourceUnion.Values[index].Attribute)...) } + return pairs + default: + return nil } - return data } // projectedResultRoot returns the root attribute used to collect projected @@ -2069,27 +1992,30 @@ func projectedResultRoot(service *expr.ServiceExpr, m *expr.MethodExpr) (*expr.A return expr.DupAtt(m.Result), m.Result } +// normalizedMethodTypeID returns the semantic identifier assigned when +// NormalizeRoot wraps a raw method object in a generated user type. func normalizedMethodTypeID(service *expr.ServiceExpr, m *expr.MethodExpr, suffix string) string { return service.Name + "#" + codegen.Goify(m.Name, true) + suffix } // hasResultType returns true if the given attribute has a result type recursively. -func hasResultType(att *expr.AttributeExpr, seens ...map[string]struct{}) bool { +func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { if _, ok := att.Type.(*expr.ResultTypeExpr); ok { return true } - var seen map[string]struct{} + var seen map[expr.UserType]struct{} if len(seens) > 0 { seen = seens[0] } else { - seen = make(map[string]struct{}) + seen = make(map[expr.UserType]struct{}) } switch a := att.Type.(type) { case expr.UserType: - if _, ok := seen[a.ID()]; ok { + origin := a.Origin() + if _, ok := seen[origin]; ok { return false } - seen[a.ID()] = struct{}{} + seen[origin] = struct{}{} return hasResultType(a.Attribute(), seen) case *expr.Array: return hasResultType(a.ElemType, seen) @@ -2111,32 +2037,33 @@ func hasResultType(att *expr.AttributeExpr, seens ...map[string]struct{}) bool { return false } -// buildProjectedType builds projected type for the given user type. -// -// viewspkg is the name of the views package -func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ProjectedTypeData { +// buildProjectedType returns the render data for one pointer-backed view +// declaration and its conversions to the exact source service type. +func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ProjectedTypeData { var ( projections []*InitData typeInits []*InitData views []*ViewData - varname = viewScope.GoTypeName(projected) + varname = declaration.Name pt = projected.Type.(expr.UserType) ) if _, isrt := pt.(*expr.ResultTypeExpr); isrt { - typeInits = buildViewConversions(projected, att, viewspkg, scope, viewScope, true) - projections = buildViewConversions(projected, att, viewspkg, scope, viewScope, false) - views = buildViews(att.Type.(*expr.ResultTypeExpr), viewScope) + typeInits = buildViewConversions(projected, att, serviceResolver, viewResolver, true) + projections = buildViewConversions(projected, att, serviceResolver, viewResolver, false) + serviceName, _, _ := serviceTypeData(att, serviceResolver) + views = buildViews(att.Type.(*expr.ResultTypeExpr), serviceName) } - validations := buildValidations(projected, viewScope) + validations := buildValidations(projected, viewResolver) removeMeta(projected) return &ProjectedTypeData{ UserTypeData: &UserTypeData{ + Declaration: declaration, Name: varname, Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), VarName: varname, - Def: viewScope.GoTypeDef(pt.Attribute(), true, true), - Ref: viewScope.GoTypeRef(projected), + Def: viewResolver.Def(pt.Attribute(), true, true), + Ref: viewResolver.Ref(projected, ""), Type: pt, }, Projections: projections, @@ -2148,7 +2075,7 @@ func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, sco } // buildViews builds the view data for all the views in the given result type. -func buildViews(rt *expr.ResultTypeExpr, viewScope *codegen.NameScope) []*ViewData { +func buildViews(rt *expr.ResultTypeExpr, typeName string) []*ViewData { views := make([]*ViewData, len(rt.Views)) for i, view := range rt.Views { vatt := expr.AsObject(view.Type) @@ -2160,7 +2087,7 @@ func buildViews(rt *expr.ResultTypeExpr, viewScope *codegen.NameScope) []*ViewDa Name: view.Name, Description: view.Description, Attributes: attrs, - TypeVarName: viewScope.GoTypeName(&expr.AttributeExpr{Type: rt}), + TypeVarName: typeName, } } return views @@ -2168,7 +2095,7 @@ func buildViews(rt *expr.ResultTypeExpr, viewScope *codegen.NameScope) []*ViewDa // buildViewedResultType builds a viewed result type from the given result type // and projected type. -func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ViewedResultTypeData { +func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { // collect result type views rt := att.Type.(*expr.ResultTypeExpr) isarr := expr.IsArray(att.Type) @@ -2179,13 +2106,16 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, if v, ok := att.Meta.Last(expr.ViewMetaKey); ok { viewName = v } - views := buildViews(rt, viewScope) + projectedDeclaration := viewResolver.userType(viewResolver.currentPath, projected.Type.(expr.UserType)) + views := buildViews(rt, declaration.Name) // build validation data - resvar := scope.GoTypeName(att) - resref := scope.GoTypeRef(att) + resvar, _, serviceRef := serviceTypeData(att, serviceResolver) + projT := wrapProjected(projected.Type.(expr.UserType)) + wrapperResolver := viewResolver.bindDerived(projT, codegen.NewViewedResultTypeID(rt)) + resref := wrapperResolver.refDeclaration(declaration, att.Type) data := map[string]any{ - "Projected": scope.GoTypeName(projected), + "Projected": projectedDeclaration.Name, "ArgVar": "result", "Source": "result", "Views": views, @@ -2204,7 +2134,8 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, } // build constructor to initialize viewed result type from result type - vresref := viewScope.GoFullTypeRef(att, viewspkg) + serviceViewResolver := wrapperResolver.withOutputPackage(serviceResolver.outputPath) + vresref := serviceViewResolver.refDeclaration(declaration, att.Type) data = map[string]any{ "ToViewed": true, "ArgVar": "res", @@ -2212,23 +2143,19 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, "Views": views, "ReturnTypeRef": vresref, "IsCollection": isarr, - "TargetType": scope.GoFullTypeName(att, viewspkg), - "InitName": "new" + viewScope.GoTypeName(projected), + "TargetType": serviceViewResolver.Name(&expr.AttributeExpr{Type: projT}, "", false, true), + "InitName": "new" + projectedDeclaration.Name, } buf = &bytes.Buffer{} if err := initTypeCodeTmpl.Execute(buf, data); err != nil { panic(err) // bug } - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() - } name = "NewViewed" + resvar init := &InitData{ Name: name, Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), Args: []*InitArgData{ - {Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}, + {Name: "res", Ref: serviceRef}, {Name: "view", Ref: "string"}, }, ReturnTypeRef: vresref, @@ -2236,16 +2163,14 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, } // build constructor to initialize result type from viewed result type - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - resref = scope.GoFullTypeRef(att, loc.PackageName()) - } + resref = serviceRef data = map[string]any{ "ToResult": true, "ArgVar": "vres", "ReturnVar": "res", "Views": views, "ReturnTypeRef": resref, - "InitName": "new" + scope.GoTypeName(att), + "InitName": "new" + resvar, } buf = &bytes.Buffer{} if err := initTypeCodeTmpl.Execute(buf, data); err != nil { @@ -2255,22 +2180,22 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, resinit := &InitData{ Name: name, Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: scope.GoFullTypeRef(att, viewspkg)}}, + Args: []*InitArgData{{Name: "vres", Ref: vresref}}, ReturnTypeRef: resref, Code: buf.String(), } - projT := wrapProjected(projected.Type.(expr.UserType)) return &ViewedResultTypeData{ UserTypeData: &UserTypeData{ + Declaration: declaration, Name: resvar, Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), VarName: resvar, - Def: viewScope.GoTypeDef(projT.Attribute(), false, true), + Def: wrapperResolver.Def(projT.Attribute(), false, true), Ref: resref, Type: projT, }, - FullName: scope.GoFullTypeName(att, viewspkg), + FullName: serviceViewResolver.Name(&expr.AttributeExpr{Type: projT}, "", false, true), FullRef: vresref, ResultInit: resinit, Init: init, @@ -2312,7 +2237,7 @@ func wrapProjected(projected expr.UserType) expr.UserType { // view. When toResult is true the constructors initialize the result type from // the projected type, otherwise they project the result type to the projected // type based on the view. -func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, toResult bool) []*InitData { +func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { vrt := att.Type.(*expr.ResultTypeExpr) if toResult { vrt = projected.Type.(*expr.ResultTypeExpr) @@ -2325,6 +2250,10 @@ func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, s } init := make([]*InitData, 0, len(vrt.Views)) + serviceName, _, serviceRef := serviceTypeData(att, serviceResolver) + projectedType := projected.Type.(expr.UserType) + projectedDeclaration := viewResolver.userType(viewResolver.currentPath, projectedType) + serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) for _, view := range vrt.Views { var typ expr.DataType obj := &expr.Object{} @@ -2335,7 +2264,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, s if parr != nil { ename := parr.ElemType.Type.Name() if toResult { - ename = scope.GoTypeName(parr.ElemType) + ename = viewResolver.Name(parr.ElemType, "", false, true) } typ = &expr.Array{ElemType: &expr.AttributeExpr{ Type: &expr.ResultTypeExpr{ @@ -2348,7 +2277,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, s } wname := projected.Type.Name() if toResult { - wname = scope.GoTypeName(projected) + wname = projectedDeclaration.Name } // viewed is the projected type narrowed down to the view attributes. viewed := &expr.AttributeExpr{ @@ -2362,41 +2291,86 @@ func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, s }, } - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() + viewedType := viewed.Type.(expr.UserType) + viewIdentity, ok := viewResolver.derived[projectedType.Origin()] + if !ok { + panic(fmt.Sprintf("projected type %q has no planned derived identity", projectedType.Name())) // bug + } + viewedResolver := serviceViewResolver.bindDerived(viewedType, viewIdentity) + if projectedArray := expr.AsArray(projected.Type); projectedArray != nil { + projectedElement := projectedArray.ElemType.Type.(expr.UserType) + elementIdentity, ok := viewResolver.derived[projectedElement.Origin()] + if !ok { + panic(fmt.Sprintf("projected element type %q has no planned derived identity", projectedElement.Name())) // bug + } + viewedElement := expr.AsArray(viewed.Type).ElemType.Type.(expr.UserType) + viewedResolver = viewedResolver.bindDerived(viewedElement, elementIdentity) } if toResult { - srcCtx := projectedTypeContext(viewspkg, true, viewScope) - tgtCtx := typeContext(scope) - resvar := scope.GoTypeName(att) + srcCtx := declarationContext(viewedResolver, true) + tgtCtx := declarationContext(serviceResolver, false) + resvar := serviceName name := "new" + resvar if view.Name != expr.DefaultView { name += codegen.Goify(view.Name, true) } - code, helpers := buildConstructorCode(viewed, att, "vres", "res", srcCtx, tgtCtx, view.Name) + elementInit := "" + if parr != nil { + serviceElement := expr.AsArray(att.Type).ElemType + serviceElementResolver := serviceResolver.Enter(serviceElement).(*declarationResolver) + elementInit = serviceElementResolver.userType( + serviceElementResolver.currentPath, + serviceElement.Type.(expr.UserType), + ).Name + } + code, helpers := buildConstructorCode( + viewed, + att, + "vres", + "res", + srcCtx, + tgtCtx, + view.Name, + elementInit, + serviceResolver.declarationName, + ) init = append(init, &InitData{ Name: name, Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: viewScope.GoFullTypeRef(projected, viewspkg)}}, - ReturnTypeRef: scope.GoFullTypeRef(att, pkg), + Args: []*InitArgData{{Name: "vres", Ref: serviceViewResolver.Ref(projected, "")}}, + ReturnTypeRef: serviceRef, Code: code, Helpers: helpers, }) } else { - srcCtx := typeContext(scope) - tgtCtx := projectedTypeContext(viewspkg, true, viewScope) - tname := scope.GoTypeName(projected) + srcCtx := declarationContext(serviceResolver, false) + tgtCtx := declarationContext(viewedResolver, true) + tname := projectedDeclaration.Name name := "new" + tname if view.Name != expr.DefaultView { name += codegen.Goify(view.Name, true) } - code, helpers := buildConstructorCode(att, viewed, "res", "vres", srcCtx, tgtCtx, view.Name) + elementInit := "" + if parr != nil { + projectedElement := parr.ElemType.Type.(expr.UserType) + elementInit = viewResolver.userType(viewResolver.currentPath, projectedElement).Name + } + code, helpers := buildConstructorCode( + att, + viewed, + "res", + "vres", + srcCtx, + tgtCtx, + view.Name, + elementInit, + viewedResolver.declarationName, + ) init = append(init, &InitData{ Name: name, - Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, scope.GoTypeName(att), tname, view.Name), - Args: []*InitArgData{{Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}}, - ReturnTypeRef: viewScope.GoFullTypeRef(projected, viewspkg), + Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, view.Name), + Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, + ReturnTypeRef: serviceViewResolver.Ref(projected, ""), Code: code, Helpers: helpers, }) @@ -2407,9 +2381,9 @@ func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, s // buildValidations builds the data required to generate validations for the // projected types. -func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) []*ValidateData { +func buildValidations(projected *expr.AttributeExpr, resolver *declarationResolver) []*ValidateData { ut := projected.Type.(expr.UserType) - tname := scope.GoTypeName(projected) + tname := resolver.Name(projected, "", false, true) var validations []*ValidateData if rt, isrt := ut.(*expr.ResultTypeExpr); isrt { // for result types we create a validation function containing view @@ -2432,7 +2406,7 @@ func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) [ if arr != nil { // dealing with an array type data["Source"] = "item" - data["ValidateVar"] = "Validate" + scope.GoTypeName(arr.ElemType) + vn + data["ValidateVar"] = "Validate" + resolver.Name(arr.ElemType, "", false, true) + vn } else { var fields []map[string]any o := &expr.Object{} @@ -2446,14 +2420,14 @@ func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) [ } fields = append(fields, map[string]any{ "Name": name, - "ValidateVar": "Validate" + scope.GoTypeName(attr) + codegen.Goify(vw, true), + "ValidateVar": "Validate" + resolver.Name(attr, "", false, true) + codegen.Goify(vw, true), "IsRequired": rt.Attribute().IsRequired(name), }) } else { o.Set(name, attr) } }) - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) + ctx := declarationContext(resolver, !expr.IsPrimitive(projected.Type)) data["Validate"] = codegen.ValidationCode(&expr.AttributeExpr{Type: o, Validation: rt.Validation}, rt, ctx, true, false, true, "result") data["Fields"] = fields } @@ -2466,7 +2440,7 @@ func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) [ validations = append(validations, &ValidateData{ Name: name, Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, view.Name), - Ref: scope.GoTypeRef(projected), + Ref: resolver.Ref(projected, ""), Validate: buf.String(), }) } @@ -2474,11 +2448,11 @@ func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) [ // for a user type or a result type with single view, we generate only one validation // function containing the validation logic name := "Validate" + tname - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) + ctx := declarationContext(resolver, !expr.IsPrimitive(projected.Type)) validations = append(validations, &ValidateData{ Name: name, Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), - Ref: scope.GoTypeRef(projected), + Ref: resolver.Ref(projected, ""), Validate: codegen.ValidationCode(ut.Attribute(), ut, ctx, true, expr.IsAlias(ut), true, "result"), }) } @@ -2494,7 +2468,7 @@ func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) [ // target data structures in the transformation code. // // view is used to generate the constructor function name. -func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, view string) (string, []*codegen.TransformFunctionData) { +func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, view, elementInitName string, nestedInitName func(*expr.AttributeExpr) string) (string, []*codegen.TransformFunctionData) { var ( helpers []*codegen.TransformFunctionData buf bytes.Buffer @@ -2511,7 +2485,7 @@ func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar str if arr != nil { // result type collection - init := "new" + targetCtx.Scope.Name(arr.ElemType, "", targetCtx.Pointer, targetCtx.UseDefault) + init := "new" + elementInitName if view != "" && view != expr.DefaultView { init += codegen.Goify(view, true) } @@ -2542,14 +2516,11 @@ func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar str } data["Code"] = code - if view != "" { - data["InitName"] = targetCtx.Scope.Name(src, "", targetCtx.Pointer, targetCtx.UseDefault) - } fields := make([]map[string]any, 0, len(*targetRTs)) // iterate through the result types found in the target and add the // code to initialize them for _, nat := range *targetRTs { - finit := "new" + targetCtx.Scope.Name(nat.Attribute, "", targetCtx.Pointer, targetCtx.UseDefault) + finit := "new" + nestedInitName(nat.Attribute) if view != "" { v := "" if vatt := rt.View(view).Find(nat.Name); vatt != nil { @@ -2587,8 +2558,10 @@ func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name strin // needed to make sure that any field name overriding is removed when // generating protobuf types (as protogen itself won't honor these overrides). func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { + if err := codegen.Walk(att, func(a *expr.AttributeExpr) error { delete(a.Meta, "struct:pkg:path") return nil - }) + }); err != nil { + panic(err) // bug + } } diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index 89f2cc17ec..ff27715c9e 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -1,9 +1,12 @@ +// This file verifies pointer/value semantics recorded for generated union +// branch fields. package service import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -11,13 +14,22 @@ import ( func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() + generation := codegen.NewGeneration("gen", nil) + pkg := generation.GeneratedPackage("gen/service") + _, err := pkg.DeclareUnion(union) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + declaration, err := pkg.Union(union) + require.NoError(t, err) data, err := buildUnionTypeData( union, - nil, - codegen.NewNameScope(), + declaration, + newServiceResolver(generation, &expr.ServiceExpr{Name: "service"}, "gen/service"), &codegen.Location{RelImportPath: "gen/service"}, false, - nil, + func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { + return pkg.UnionBranch(union, branch.Name) + }, ) assert.NoError(t, err) diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index be78d9a020..c47756e89a 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -1,3 +1,5 @@ +// This file verifies that service union declarations keep deterministic names +// regardless of design traversal order. package service import ( @@ -82,9 +84,10 @@ func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location generation: generation, packages: make(map[string]*generatedPackageData), } - seen := make(map[string]struct{}) + seen := make(map[expr.UserType]struct{}) unionByHash := make(map[unionDataKey]*UnionTypeData) - if err := services.collectUnionTypes(att, service, codegen.NewNameScope(), loc, unionByHash, seen, false); err != nil { + resolver := newServiceResolver(generation, service, generatedPackagePath(generation.GenPkg, service, loc)) + if err := services.collectUnionTypes(att, service, resolver, loc, unionByHash, seen, false); err != nil { panic(err) } diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 08c326ca4b..950b463248 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -1,3 +1,5 @@ +// This file verifies service render analysis and the generated service files +// built from its immutable data. package service import ( @@ -70,6 +72,45 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { require.ErrorContains(t, err, "frozen") } +// TestServicesDataUsesRebuiltViewDeclarations verifies that planning and +// rendering can rebuild view expressions while sharing frozen declarations. +func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { + var result *expr.ResultTypeExpr + root := codegen.RunDSL(t, func() { + result = dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + views := generation.GeneratedPackage("goa.design/goa/example/values/views") + plannedProjected, err := views.DerivedType(codegen.NewProjectedTypeID(result)) + require.NoError(t, err) + plannedViewed, err := views.DerivedType(codegen.NewViewedResultTypeID(result)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + services, err := NewServicesData(root, generation) + require.NoError(t, err) + service := services.Get("Values") + require.Len(t, service.projectedTypes, 1) + require.Len(t, service.viewedResultTypes, 1) + require.Same(t, plannedProjected, service.projectedTypes[0].Declaration) + require.Same(t, plannedViewed, service.viewedResultTypes[0].Declaration) + require.Equal(t, "ValueView", plannedProjected.Name) + require.Equal(t, "Value", plannedViewed.Name) +} + func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) services := mustServicesData(t, root) @@ -158,12 +199,10 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { require.NoError(t, Plan(secondRoot, generation)) firstUnion := expr.AsObject(firstType).Attribute("Value").Type.(*expr.Union) secondUnion := expr.AsObject(secondType).Attribute("Value").Type.(*expr.Union) - firstAlias := firstUnion.Values[0].Attribute.Type.(expr.UserType) - secondAlias := secondUnion.Values[0].Attribute.Type.(expr.UserType) generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") - firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text", firstAlias) + firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text") require.NoError(t, err) - secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text", secondAlias) + secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text") require.NoError(t, err) require.Same(t, firstBranch, secondBranch) @@ -208,11 +247,10 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) require.NoError(t, Plan(root, generation)) union := expr.AsObject(container).Attribute("Value").Type.(*expr.Union) - alias := union.Values[0].Attribute.Type.(expr.UserType) generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") exactDeclaration, err := generatedPackage.UserType(exact) require.NoError(t, err) - branchDeclaration, err := generatedPackage.UnionBranchType(union, "text", alias) + branchDeclaration, err := generatedPackage.UnionBranchType(union, "text") require.NoError(t, err) require.NotSame(t, exactDeclaration, branchDeclaration) @@ -379,38 +417,25 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) - render := func(servicesToRender []*expr.ServiceExpr) string { - services := mustServicesData(t, root) - var generated strings.Builder - _ = servicesToRender - files := Files("goa.design/goa/example", []*ServicesData{services}) - for _, file := range files { - if !strings.Contains(file.Path, filepath.Join("gen", "types")) { - continue - } - for _, section := range file.SectionTemplates { - require.NoError(t, section.Write(&generated)) - } + services := mustServicesData(t, root) + var generated strings.Builder + files := Files("goa.design/goa/example", []*ServicesData{services}) + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) } - return generated.String() } - code := render(root.Services) + code := generated.String() require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) firstUsesValue := unionFieldType(code, "FirstValue") secondUsesValue := unionFieldType(code, "SecondValue") thirdUsesValue := unionFieldType(code, "ThirdValue") require.Equal(t, []string{"Value", "Value", "Value"}, []string{firstUsesValue, secondUsesValue, thirdUsesValue}) - - reversed := render([]*expr.ServiceExpr{root.Services[2], root.Services[1], root.Services[0]}) - require.Equal(t, firstUsesValue, unionFieldType(reversed, "FirstValue"), reversed) - require.Equal(t, secondUsesValue, unionFieldType(reversed, "SecondValue"), reversed) - require.Equal(t, thirdUsesValue, unionFieldType(reversed, "ThirdValue"), reversed) - - selective := render([]*expr.ServiceExpr{root.Services[1]}) - require.Equal(t, 1, strings.Count(selective, "type Value struct {"), selective) - require.Equal(t, "Value", unionFieldType(selective, "SecondValue"), selective) } func unionFieldType(code, owner string) string { diff --git a/codegen/service/templates/union_type.go.tpl b/codegen/service/templates/union_type.go.tpl index 1c61987691..7b5939bbd4 100644 --- a/codegen/service/templates/union_type.go.tpl +++ b/codegen/service/templates/union_type.go.tpl @@ -29,8 +29,8 @@ func (u {{ .Name }}) Kind() {{ .KindName }} { } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { +// {{ .Constructor }} constructs {{ $.Name }} with the {{ .Name }} branch set. +func {{ .Constructor }}(v {{ .FieldType }}) {{ $.Name }} { return {{ $.Name }}{ kind: {{ .KindConst }}, {{ .FieldName }}: v, diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden index c52656257a..2c78cff2cd 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden @@ -2,7 +2,7 @@ // Service is the PkgPathPayloadAttributeDSL service interface. type Service interface { // Foo implements Foo. - FooEndpoint(context.Context, *Bar) (res *Bar, err error) + Foo(context.Context, *Bar) (res *Bar, err error) } // APIName is the name of the API as defined in the design. diff --git a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden index 3beeca448a..1cf995edb0 100644 --- a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden @@ -70,3 +70,26 @@ func newResultInlineValidationView(res *ResultInlineValidation) *resultwithinlin } return vres } + +// newResultInlineValidationBResult converts projected type +// ResultInlineValidationBResult to service type ResultInlineValidationBResult. +func newResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResultView) *ResultInlineValidationBResult { + res := &ResultInlineValidationBResult{ + B: vres.B, + } + if vres.A != nil { + res.A = *vres.A + } + return res +} + +// newResultInlineValidationBResultView projects result type +// ResultInlineValidationBResult to projected type +// ResultInlineValidationBResultView using the "default" view. +func newResultInlineValidationBResultView(res *ResultInlineValidationBResult) *resultwithinlinevalidationviews.ResultInlineValidationBResultView { + vres := &resultwithinlinevalidationviews.ResultInlineValidationBResultView{ + A: &res.A, + B: res.B, + } + return vres +} diff --git a/codegen/service/views.go b/codegen/service/views.go index ce484dde40..7b00bb7a6e 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -1,3 +1,5 @@ +// This file renders projected and viewed result declarations in one service's +// views package, including unions required by those declarations. package service import ( @@ -17,7 +19,7 @@ type viewedType struct { // ViewsFile returns the views file for the given service which contains // logic to render result types using the defined views. -func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { +func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { svc := services.Get(service.Name) if len(svc.projectedTypes) == 0 { return nil @@ -29,13 +31,14 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod // depends on views), therefore unions must be generated in the views package // when referenced by projected types. unionByHash := make(map[unionDataKey]*UnionTypeData) - seenUnions := make(map[string]struct{}) + seenUnions := make(map[expr.UserType]struct{}) viewLoc := &codegen.Location{RelImportPath: "views"} + resolver := newViewResolver(services.generation, service, svc.viewDerived) for _, t := range svc.projectedTypes { if err := services.collectUnionTypes( &expr.AttributeExpr{Type: t.Type}, service, - svc.ViewScope, + resolver, viewLoc, unionByHash, seenUnions, @@ -53,6 +56,7 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod }) path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go") + outputPackage := genpkg + "/" + svc.PathName + "/views" imports := []*codegen.ImportSpec{ codegen.GoaImport(""), {Path: "unicode/utf8"}, @@ -64,6 +68,14 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod codegen.SimpleImport("fmt"), ) } + var attributes []*expr.AttributeExpr + for _, viewed := range svc.viewedResultTypes { + attributes = append(attributes, viewed.Type.Attribute()) + } + for _, projected := range svc.projectedTypes { + attributes = append(attributes, projected.Type.Attribute()) + } + imports = append(imports, AttributeImports(genpkg, outputPackage, attributes...)...) header := codegen.Header(service.Name+" views", "views", imports) sections := []*codegen.SectionTemplate{header} diff --git a/codegen/transformer.go b/codegen/transformer.go index 35ef51ec2f..da76376ab0 100644 --- a/codegen/transformer.go +++ b/codegen/transformer.go @@ -1,3 +1,5 @@ +// This file defines the naming and pointer contracts shared by Go +// transformation and validation generators. package codegen import ( @@ -20,6 +22,13 @@ type ( // attribute and field name. If firstUpper is true then the field name // first letter is capitalized. Field(att *expr.AttributeExpr, name string, firstUpper bool) string + // Package returns the qualifier used to reference att from the current + // generated file, or the empty string for a same-package declaration. + Package(att *expr.AttributeExpr) string + // Enter returns the resolver that owns att and declarations nested in it. + Enter(att *expr.AttributeExpr) Attributor + // IsSumType reports whether unions use Goa's generated sum-type layout. + IsSumType() bool } // AttributeContext contains properties which impacts the code generating @@ -40,12 +49,6 @@ type ( UseDefault bool // Scope is the attribute scope. Scope Attributor - // DefaultPkg is the default package name where the attribute - // type is found. it can be overridden via struct:pkg:path meta. - DefaultPkg string - // SamePackageConversion if true indicates that this context is being used - // for conversion code generation within the same package as the types. - SamePackageConversion bool // UnionPointer if true indicates that optional sum-type union fields use // pointers to preserve transport-level presence. Required union fields also // use pointers when Pointer is true. Service types leave this false because @@ -58,6 +61,8 @@ type ( AttributeScope struct { // scope is the name scope for the attribute. scope *NameScope + // pkg is the default generated Go package qualifier. + pkg string } // TransformAttrs are the attributes that help in the transformation. @@ -103,21 +108,13 @@ func NewAttributeContext(pointer, reqIgnore, useDefault bool, pkg string, scope Pointer: pointer, IgnoreRequired: reqIgnore, UseDefault: useDefault, - Scope: NewAttributeScope(scope), - DefaultPkg: pkg, + Scope: newAttributeScope(scope, pkg), } } -// NewAttributeContextForConversion initializes an attribute context for same-package conversion. -func NewAttributeContextForConversion(pointer, reqIgnore, useDefault bool, pkg string, scope *NameScope) *AttributeContext { - ctx := NewAttributeContext(pointer, reqIgnore, useDefault, pkg, scope) - ctx.SamePackageConversion = true - return ctx -} - // NewAttributeScope initializes an attribute scope. func NewAttributeScope(scope *NameScope) *AttributeScope { - return &AttributeScope{scope: scope} + return newAttributeScope(scope, "") } // IsCompatible returns an error if a and b are not both objects, both arrays, @@ -181,23 +178,23 @@ func MapDepth(m *expr.Map) int { return mapDepth(m.ElemType.Type, 0) } -func mapDepth(dt expr.DataType, depth int, seen ...map[string]struct{}) int { +func mapDepth(dt expr.DataType, depth int, seen ...map[expr.DataType]struct{}) int { if mp := expr.AsMap(dt); mp != nil { depth++ depth = mapDepth(mp.ElemType.Type, depth, seen...) } else if ar := expr.AsArray(dt); ar != nil { depth = mapDepth(ar.ElemType.Type, depth, seen...) } else if mo := expr.AsObject(dt); mo != nil { - var s map[string]struct{} + var s map[expr.DataType]struct{} if len(seen) > 0 { s = seen[0] } else { - s = make(map[string]struct{}) + s = make(map[expr.DataType]struct{}) seen = append(seen, s) } - key := dt.Name() + key := dt if u, ok := dt.(expr.UserType); ok { - key = u.ID() + key = u.Origin() } if _, ok := s[key]; ok { return depth @@ -237,7 +234,7 @@ func (a *AttributeContext) IsFieldPointer(name string, att *expr.AttributeExpr) if expr.IsUnion(field.Type) { return a.IsUnionPointer(att.IsRequired(name)) } - if _, ok := a.Scope.(*AttributeScope); !ok { + if !a.Scope.IsSumType() { return expr.IsPrimitive(field.Type) && a.IsPrimitivePointer(name, att) } return goFieldIsPointer(att, name, a.Pointer, a.UseDefault) @@ -251,37 +248,25 @@ func (a *AttributeContext) IsUnionPointer(required bool) bool { // Pkg returns the package name of the given type. func (a *AttributeContext) Pkg(att *expr.AttributeExpr) string { - if att == nil { - return a.DefaultPkg - } - if loc := UserTypeLocation(att.Type); loc != nil { - pkg := loc.PackageName() - // If this is same-package conversion and the type's package matches - // the context's default package, return empty string to avoid qualification - if a.SamePackageConversion && pkg == a.DefaultPkg { - return "" - } - return pkg - } - if expr.AsUnion(att.Type) != nil { - if a.SamePackageConversion { - return "" - } - return a.DefaultPkg - } - return a.DefaultPkg + return a.Scope.Package(att) +} + +// Enter returns a copy whose attributor owns att and unlocated declarations +// nested inside it. +func (a *AttributeContext) Enter(att *expr.AttributeExpr) *AttributeContext { + entered := a.Dup() + entered.Scope = a.Scope.Enter(att) + return entered } // Dup creates a shallow copy of the AttributeContext. func (a *AttributeContext) Dup() *AttributeContext { return &AttributeContext{ - Pointer: a.Pointer, - IgnoreRequired: a.IgnoreRequired, - UseDefault: a.UseDefault, - Scope: a.Scope, - DefaultPkg: a.DefaultPkg, - SamePackageConversion: a.SamePackageConversion, - UnionPointer: a.UnionPointer, + Pointer: a.Pointer, + IgnoreRequired: a.IgnoreRequired, + UseDefault: a.UseDefault, + Scope: a.Scope, + UnionPointer: a.UnionPointer, } } @@ -314,6 +299,33 @@ func (a *AttributeScope) Ref(att *expr.AttributeExpr, pkg string) string { return a.scope.GoFullTypeRef(att, pkg) } +// Package returns the qualifier selected by att's explicit type location or +// the scope's default package. +func (a *AttributeScope) Package(att *expr.AttributeExpr) string { + if att == nil { + return a.pkg + } + if loc := UserTypeLocation(att.Type); loc != nil { + return loc.PackageName() + } + return a.pkg +} + +// Enter returns a scope whose default qualifier follows att's explicit type +// location. The underlying name scope remains unchanged. +func (a *AttributeScope) Enter(att *expr.AttributeExpr) Attributor { + if loc := UserTypeLocation(att.Type); loc != nil && loc.PackageName() != a.pkg { + return newAttributeScope(a.scope, loc.PackageName()) + } + return a +} + +// IsSumType reports that AttributeScope renders unions using Goa's generated +// sum-type structs. +func (*AttributeScope) IsSumType() bool { + return true +} + // Field returns a valid Go struct field name. func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { return GoifyAtt(att, name, firstUpper) @@ -323,3 +335,9 @@ func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bo func (a *AttributeScope) Scope() *NameScope { return a.scope } + +// newAttributeScope builds an attribute scope with explicit package +// qualification behavior. +func newAttributeScope(scope *NameScope, pkg string) *AttributeScope { + return &AttributeScope{scope: scope, pkg: pkg} +} diff --git a/codegen/union.go b/codegen/union.go index f8e6e10f46..2498fad5a6 100644 --- a/codegen/union.go +++ b/codegen/union.go @@ -21,7 +21,13 @@ type ( // and nilability. func NewUnionTypeID(union *expr.Union) UnionTypeID { var key strings.Builder - writeUnionTypeID(&key, union, make(map[*expr.Object]int), make(map[*expr.Union]int)) + writeUnionTypeID( + &key, + union, + make(map[*expr.Object]int), + make(map[*expr.Union]int), + make(map[expr.UserType]int), + ) return UnionTypeID(key.String()) } @@ -40,7 +46,7 @@ func (id UnionTypeID) Hash() string { // writeUnionTypeID appends one union definition using length-prefixed values // so different inputs cannot produce an ambiguous concatenation. -func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int) { +func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { if index, ok := unions[union]; ok { writeUnionIDPart(key, "union-ref") writeUnionIDPart(key, strconv.Itoa(index)) @@ -54,12 +60,12 @@ func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr writeUnionIDPart(key, union.GetValueKey()) for _, value := range union.Values { writeUnionIDPart(key, value.Name) - writeUnionAttributeID(key, value.Attribute, objects, unions) + writeUnionAttributeID(key, value.Attribute, objects, unions, userTypes) } } // writeUnionAttributeID appends the generated Go identity of an attribute. -func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int) { +func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { writeUnionIDPart(key, strconv.FormatBool(IsNilable(att.Type))) if metaType, ok := att.Meta["struct:field:type"]; ok { writeUnionIDPart(key, "meta-type") @@ -74,32 +80,40 @@ func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, object case expr.UserType: writeUnionIDPart(key, "user") writeUnionIDPart(key, Goify(actual.Name(), true)) - writeUnionIDPart(key, actual.Hash()) if loc := UserTypeLocation(actual); loc != nil { writeUnionIDPart(key, loc.RelImportPath) } else { writeUnionIDPart(key, "") } + origin := actual.Origin() + if index, ok := userTypes[origin]; ok { + writeUnionIDPart(key, "user-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + userTypes[origin] = len(userTypes) + defer delete(userTypes, origin) + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) case *expr.Array: writeUnionIDPart(key, "array") - writeUnionAttributeID(key, actual.ElemType, objects, unions) + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) case *expr.Map: writeUnionIDPart(key, "map") - writeUnionAttributeID(key, actual.KeyType, objects, unions) - writeUnionAttributeID(key, actual.ElemType, objects, unions) + writeUnionAttributeID(key, actual.KeyType, objects, unions, userTypes) + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) case *expr.Object: - writeUnionObjectID(key, att, actual, objects, unions) + writeUnionObjectID(key, att, actual, objects, unions, userTypes) case *expr.Union: - writeUnionTypeID(key, actual, objects, unions) + writeUnionTypeID(key, actual, objects, unions, userTypes) case expr.CompositeExpr: - writeUnionAttributeID(key, actual.Attribute(), objects, unions) + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) default: panic("unknown union branch data type") } } // writeUnionObjectID appends the inline Go struct emitted for an object. -func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int) { +func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { if index, ok := objects[object]; ok { writeUnionIDPart(key, "object-ref") writeUnionIDPart(key, strconv.Itoa(index)) @@ -112,7 +126,7 @@ func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object writeUnionIDPart(key, GoifyAtt(field.Attribute, field.Name, true)) writeUnionIDPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) writeUnionIDPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, false))) - writeUnionAttributeID(key, field.Attribute, objects, unions) + writeUnionAttributeID(key, field.Attribute, objects, unions, userTypes) } } diff --git a/codegen/validation.go b/codegen/validation.go index 384bdf15fd..30c3536429 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -1,3 +1,5 @@ +// This file generates validation code for service, view, and transport +// attributes using the package owner carried by each attribute context. package codegen import ( @@ -188,7 +190,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } case expr.IsUnion(att.Type): u := expr.AsUnion(att.Type) - if _, ok := attCtx.Scope.(*AttributeScope); ok { + if attCtx.Scope.IsSumType() { cases := make([]map[string]any, 0, len(u.Values)) for _, v := range u.Values { // Sum-type unions (struct-based, with Kind/AsX accessors) store each @@ -232,14 +234,14 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A unionCtx.Pointer = false val := validateAttribute(unionCtx, vatt, put, "v", context+".value", true, view, seen) if val != "" { - types = append(types, attCtx.Scope.Ref(vatt, attCtx.DefaultPkg)) + types = append(types, attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt))) vals = append(vals, val) } } else { fieldName := attCtx.Scope.Field(vatt, v.Name, true) val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context+".value", true, view, seen) if val != "" { - tref := attCtx.Scope.Ref(&expr.AttributeExpr{Type: put}, attCtx.DefaultPkg) + tref := attCtx.Scope.Ref(&expr.AttributeExpr{Type: put}, attCtx.Pkg(&expr.AttributeExpr{Type: put})) types = append(types, tref+"_"+fieldName) vals = append(vals, val) } @@ -272,8 +274,7 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. return code } if expr.IsUnion(att.Type) { - _, sumType := ctx.Scope.(*AttributeScope) - if sumType { + if ctx.Scope.IsSumType() { if !ctx.IsUnionPointer(req) { return code } diff --git a/expr/user_type.go b/expr/user_type.go index d9adf30c96..90e5381aec 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -1,18 +1,20 @@ +// This file defines user-authored type declarations and the distinction +// between their stable semantic IDs and in-memory copy provenance. package expr type ( // UserTypeExpr describes user defined types. While a given design must // ensure that the names are unique the code used to generate code can // create multiple user types that share the same name (for example because - // generated in different packages). UID is always unique and makes it - // possible to avoid infinite recursions when traversing the data structures - // described by the attribute expression e.g. when computing example values. + // generated in different packages). When supplied, UID is a stable semantic + // identifier used by deterministic examples and media-type behavior; Origin + // identifies copied in-memory declarations. UserTypeExpr struct { // The embedded attribute expression. *AttributeExpr // Name of type TypeName string - // UID of type + // UID is the optional stable semantic identifier of the type. UID string // origin is the earliest declaration copied to create this type. origin UserType diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index 9e63a6d1a6..da491560d6 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders gRPC clients and codecs per service; each returned file +// owns the generated-type imports used by its conversions. package codegen import ( @@ -15,10 +17,10 @@ func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { svcLen := len(services.Root.API.GRPC.Services) fw := make([]*codegen.File, 2*svcLen) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = clientFile(genpkg, svc, services) + fw[i] = addEndpointImports(clientFile(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) } for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = clientEncodeDecode(genpkg, svc, services) + fw[i+svcLen] = addEndpointImports(clientEncodeDecode(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) } return fw } diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 22c1d0fe53..f0cf29ed72 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -1,3 +1,5 @@ +// This file renders gRPC command parsers and per-service payload builders, +// including relocated payload imports in the builder that references them. package codegen import ( @@ -128,7 +130,7 @@ func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.Command &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - return cli.PayloadBuildersFile(fpath, title, specs, data) + return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), genpkg, svc.GRPCEndpoints...) } func buildFlags(e *EndpointData) ([]*cli.FlagData, *cli.BuildFunctionData) { diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index 7474d7a680..d5413c8d6c 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -1,3 +1,5 @@ +// This file defines protobuf-specific attribute naming used by gRPC type, +// validation, and transformation generation. package codegen import ( @@ -16,6 +18,7 @@ type ( // protoBufScope is the scope for protocol buffer attribute types. protoBufScope struct { scope *codegen.NameScope + pkg string } ) @@ -41,6 +44,22 @@ func (p *protoBufScope) Ref(att *expr.AttributeExpr, pkg string) string { return protoBufGoFullTypeRef(att, pkg, p.scope) } +// Package returns the protocol buffer package qualifier for att. +func (p *protoBufScope) Package(*expr.AttributeExpr) string { + return p.pkg +} + +// Enter keeps protobuf messages in the wire package owned by p. +func (p *protoBufScope) Enter(*expr.AttributeExpr) codegen.Attributor { + return p +} + +// IsSumType reports that protobuf unions use generated oneof messages rather +// than Goa service sum-type structs. +func (*protoBufScope) IsSumType() bool { + return false +} + // Field returns the field name as generated by protocol buffer compiler. // NOTE: protoc does not care about common initialisms like api -> API so we // first transform the name into snake case to end up with Api. @@ -56,7 +75,7 @@ func (p *protoBufScope) Scope() *codegen.NameScope { // protoBufTypeContext returns a contextual attribute for the protocol buffer type. func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) *codegen.AttributeContext { ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, scope) - ctx.Scope = &protoBufScope{scope: scope} + ctx.Scope = &protoBufScope{scope: scope, pkg: pkg} return ctx } diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index de2f957e6f..a91c775adf 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders gRPC servers and codecs per service; each returned file +// receives imports from the complete endpoint set it renders. package codegen import ( @@ -17,10 +19,10 @@ func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { svcLen := len(services.Root.API.GRPC.Services) fw := make([]*codegen.File, 2*svcLen) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = serverFile(genpkg, svc, services) + fw[i] = addEndpointImports(serverFile(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) } for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = serverEncodeDecode(genpkg, svc, services) + fw[i+svcLen] = addEndpointImports(serverEncodeDecode(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) } return fw } diff --git a/grpc/codegen/service_imports.go b/grpc/codegen/service_imports.go new file mode 100644 index 0000000000..ba5851ce8e --- /dev/null +++ b/grpc/codegen/service_imports.go @@ -0,0 +1,40 @@ +// This file derives imports from the gRPC endpoint sections rendered into one +// generated file. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// addEndpointImports adds the named service-type references used by endpoints +// to file's header. The output package is computed from the generated path. +// Current gRPC server, client, codec, type, and CLI files each render every +// endpoint; callers pass that complete endpoint list explicitly. +func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.GRPCEndpointExpr) *codegen.File { + outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") + outputPackage := path.Join(genpkg, path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, grpcEndpointAttributes(endpoints...)...)...) + return file +} + +// grpcEndpointAttributes returns the named service attributes referenced by +// the supplied gRPC endpoint sections. +func grpcEndpointAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index f5849111b6..8086ca0da0 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -1,3 +1,5 @@ +// This file renders gRPC client and server conversion types per service and +// attaches imports to the exact side-specific file that uses them. package codegen import ( @@ -13,7 +15,7 @@ import ( func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, true) + fw[i] = addEndpointImports(typesFile(genpkg, svc, services, true), genpkg, svc.GRPCEndpoints...) } return fw } @@ -23,7 +25,7 @@ func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, false) + fw[i] = addEndpointImports(typesFile(genpkg, svc, services, false), genpkg, svc.GRPCEndpoints...) } return fw } diff --git a/http/codegen/client.go b/http/codegen/client.go index f4efb304e0..d8d4b08e6c 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders HTTP client calls and codecs per service; each file owns +// the imports required by the service methods it contains. package codegen import ( @@ -13,17 +15,17 @@ import ( func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { - files = append(files, clientFile(genpkg, svc, data)) + files = append(files, addEndpointImports(clientFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) if f := WebsocketClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, httpWebSocketEndpoints(svc)...)) } if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, httpSSEEndpoints(svc)...)) } } for _, svc := range data.Expressions.Services { if f := ClientEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) } } return files diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index 3a0a108594..e58b5aa01d 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -1,3 +1,5 @@ +// This file renders HTTP client command parsers and per-service payload +// builders, including imports for relocated payload types used by each builder. package codegen import ( @@ -178,7 +180,7 @@ func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.Command codegen.GoaNamedImport("http", "goahttp"), {Path: genpkg + "/" + sd.Service.PathName, Name: sd.Service.PkgName}, } - return cli.PayloadBuildersFile(path, title, specs, data) + return addEndpointImports(cli.PayloadBuildersFile(path, title, specs, data), genpkg, svc.HTTPEndpoints...) } // buildFlags builds the flag data and build function for an endpoint. diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 6fb3144c79..970c1e2d3b 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,3 +1,5 @@ +// This file renders example HTTP server wiring and multipart stubs, attaching +// relocated type imports only to the example file that references them. package codegen import ( @@ -8,6 +10,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" + servicecodegen "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -154,10 +157,17 @@ func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServic specs := make([]*codegen.ImportSpec, 0, 2) specs = append(specs, &codegen.ImportSpec{Path: "mime/multipart"}) data := services.Get(svc.Name()) + var multipartEndpoints []*expr.HTTPEndpointExpr + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder != nil || endpoint.MultipartRequestEncoder != nil { + multipartEndpoints = append(multipartEndpoints, svc.Endpoint(endpoint.Method.Name)) + } + } specs = append(specs, &codegen.ImportSpec{ Path: path.Join(genpkg, data.Service.PathName), Name: scope.Unique(data.Service.PkgName, "svc"), }) + specs = append(specs, servicecodegen.AttributeImports(genpkg, example.RootPath(genpkg), httpEndpointAttributes(multipartEndpoints...)...)...) apiPkg := example.APIPkg(root, scope) sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} diff --git a/http/codegen/server.go b/http/codegen/server.go index ec7e4976f6..1a55e9f5f0 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders HTTP server handlers and encoders per service; each file +// receives imports derived only from the endpoint sections it contains. package codegen import ( @@ -15,17 +17,17 @@ import ( func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { - files = append(files, serverFile(genpkg, svc, data)) + files = append(files, addEndpointImports(serverFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, httpWebSocketEndpoints(svc)...)) } if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, httpSSEEndpoints(svc)...)) } } for _, svc := range data.Expressions.Services { if f := ServerEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) } } return files diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go new file mode 100644 index 0000000000..e024fdb98c --- /dev/null +++ b/http/codegen/service_imports.go @@ -0,0 +1,65 @@ +// This file derives imports from the HTTP endpoints rendered into one +// generated file. Streaming-only files pass only their streaming endpoints. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// addEndpointImports adds the named service-type references used by endpoints +// to file's header. The output package is computed from the generated path. +func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { + if file == nil { + return nil + } + outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") + outputPackage := path.Join(genpkg, path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, httpEndpointAttributes(endpoints...)...)...) + return file +} + +// httpEndpointAttributes returns the named service attributes referenced by +// the supplied HTTP endpoint sections. +func httpEndpointAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} + +// httpWebSocketEndpoints returns only the endpoints whose stream sections are +// rendered into WebSocket files. +func httpWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesWebSocket() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + +// httpSSEEndpoints returns only the endpoints whose stream sections are +// rendered into Server-Sent Events files. +func httpSSEEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesSSE() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} diff --git a/http/codegen/types.go b/http/codegen/types.go index b6eb01db67..d486b76fee 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -1,3 +1,5 @@ +// This file renders HTTP request and response types per service and transport +// side, using imports attached to that exact generated type file. package codegen import ( @@ -11,7 +13,7 @@ import ( func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, true, data) + fw[i] = addEndpointImports(typesFile(genpkg, svc, true, data), genpkg, svc.HTTPEndpoints...) } return fw } @@ -20,7 +22,7 @@ func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, false, data) + fw[i] = addEndpointImports(typesFile(genpkg, svc, false, data), genpkg, svc.HTTPEndpoints...) } return fw } diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 3ff14dd79c..7de1251120 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -1,3 +1,5 @@ +// This file analyzes HTTP streaming endpoints into the WebSocket server and +// client data rendered by their dedicated generated files. package codegen import ( @@ -146,7 +148,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin Required: true, // The example has always been computed from the // request body, not the streaming body. - Example: sd.bodies.request(e).Example(sds.Root.API.ExampleGenerator), + Example: sd.bodies.request(e).Example(sds.Root.API.ExampleGenerator), Validate: svcode, }, }} diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 2c17647ae0..e1ce4d9805 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders JSON-RPC client calls and codecs per service and keeps +// generated-type imports local to each returned file. package codegen import ( @@ -14,12 +16,12 @@ func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File jsvcs := data.Root.API.JSONRPC.Services files := make([]*codegen.File, 0, len(jsvcs)*3) for _, svc := range jsvcs { - files = append(files, clientFile(genpkg, svc, data)) + files = append(files, addEndpointImports(clientFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) if f := websocketClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, jsonRPCWebSocketEndpoints(svc)...)) } if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, jsonRPCSSEEndpoints(svc)...)) } } for _, svc := range jsvcs { @@ -47,7 +49,7 @@ func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File if n := len(data.Get(svc.Name()).Endpoints); swapped != n { panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, svc.Name(), n)) } - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) } return files } diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index ec2d94802c..79e43ec6ec 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders JSON-RPC server handlers and codecs per service and keeps +// generated-type imports local to each returned file. package codegen import ( @@ -15,14 +17,14 @@ func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File jsvcs := data.Root.API.JSONRPC.Services files := make([]*codegen.File, 0, len(jsvcs)*3) for _, svc := range jsvcs { - files = append(files, serverFile(genpkg, svc, data)) + files = append(files, addEndpointImports(serverFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) // Generate either WebSocket or SSE file based on transport type if hasJSONRPCSSE(svc) { if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, jsonRPCSSEEndpoints(svc)...)) } } else if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, jsonRPCWebSocketEndpoints(svc)...)) } } for _, svc := range jsvcs { @@ -39,7 +41,7 @@ func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File } s.Name = "jsonrpc-" + s.Name } - files = append(files, f) + files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) } return files } @@ -56,7 +58,7 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. "lowerInitial": lowerInitial, "hasMixedTransports": func() bool { return hasMixedJSONRPCTransports(svc) }, } - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) + imports := make([]*codegen.ImportSpec, 0, 15) imports = append(imports, &codegen.ImportSpec{Path: "bufio"}, &codegen.ImportSpec{Path: "bytes"}, @@ -74,7 +76,6 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, ) - imports = append(imports, data.Service.UserTypeImports...) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), } diff --git a/jsonrpc/codegen/service_imports.go b/jsonrpc/codegen/service_imports.go new file mode 100644 index 0000000000..e47028e9d2 --- /dev/null +++ b/jsonrpc/codegen/service_imports.go @@ -0,0 +1,62 @@ +// This file derives imports from the JSON-RPC endpoint sections rendered into +// one generated file. Streaming-only files pass only their stream endpoints. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// addEndpointImports adds the named service-type references used by endpoints +// to file's header. The output package is computed from the generated path. +func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { + outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") + outputPackage := path.Join(genpkg, path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, jsonRPCEndpointAttributes(endpoints...)...)...) + return file +} + +// jsonRPCEndpointAttributes returns the named service attributes referenced by +// the supplied JSON-RPC endpoint sections. +func jsonRPCEndpointAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} + +// jsonRPCWebSocketEndpoints returns only the endpoints whose stream sections +// are rendered into WebSocket files. +func jsonRPCWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesWebSocket() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + +// jsonRPCSSEEndpoints returns only the endpoints whose stream sections are +// rendered into Server-Sent Events files. +func jsonRPCSSEEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesSSE() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index 08c64733df..0d0808870a 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -1,3 +1,5 @@ +// This file renders JSON-RPC server-sent-event clients and servers with imports +// scoped to the service represented by each stream file. package codegen import ( @@ -23,7 +25,7 @@ func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "server", "sse.go") title := fmt.Sprintf("%s SSE server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 9+len(data.Service.UserTypeImports)) + imports := make([]*codegen.ImportSpec, 0, 9) imports = append(imports, &codegen.ImportSpec{Path: "context"}, &codegen.ImportSpec{Path: "errors"}, @@ -35,7 +37,6 @@ func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg codegen.GoaNamedImport("http", "goahttp"), &codegen.ImportSpec{Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, ) - imports = append(imports, data.Service.UserTypeImports...) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go index 13c1751328..cb5a5eb7e8 100644 --- a/jsonrpc/codegen/websocket_client.go +++ b/jsonrpc/codegen/websocket_client.go @@ -1,3 +1,5 @@ +// This file renders one JSON-RPC WebSocket client implementation and leaves +// service-specific import attachment to the owning file builder. package codegen import ( @@ -19,7 +21,7 @@ func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt title := fmt.Sprintf("%s WebSocket JSON-RPC client", svc.Name()) // Build imports list for WebSocket clients - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) + imports := make([]*codegen.ImportSpec, 0, 15) imports = append(imports, &codegen.ImportSpec{Path: "bytes"}, &codegen.ImportSpec{Path: "context"}, @@ -37,7 +39,6 @@ func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt codegen.GoaNamedImport("http", "goahttp"), &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, ) - imports = append(imports, data.Service.UserTypeImports...) sections := []*codegen.SectionTemplate{ codegen.Header(title, "client", imports), diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go index d463ac4c8f..cc3b7acb8c 100644 --- a/jsonrpc/codegen/websocket_server.go +++ b/jsonrpc/codegen/websocket_server.go @@ -1,3 +1,5 @@ +// This file renders one JSON-RPC WebSocket server implementation and leaves +// service-specific import attachment to the owning file builder. package codegen import ( @@ -24,7 +26,7 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt } svcName := data.Service.PathName title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 14+len(data.Service.UserTypeImports)) + imports := make([]*codegen.ImportSpec, 0, 14) imports = append(imports, &codegen.ImportSpec{Path: "context"}, &codegen.ImportSpec{Path: "encoding/json"}, @@ -41,7 +43,6 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt codegen.GoaNamedImport("http", "goahttp"), &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, ) - imports = append(imports, data.Service.UserTypeImports...) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { From 5469ad1eccff49cde93338e47addcd0fb3bcd308 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 02:29:37 -0700 Subject: [PATCH 17/43] codegen: freeze aliases and method type names --- codegen/generated_types.go | 197 +++++++---- codegen/generated_types_test.go | 116 +++++-- codegen/generator/generation_test.go | 2 +- codegen/generator/openapi.go | 2 + .../service_union_package_scope_test.go | 183 +++++++++++ codegen/normalize.go | 231 +++---------- codegen/service/client.go | 2 +- codegen/service/convert.go | 4 +- codegen/service/declaration_resolver.go | 126 +++++-- codegen/service/declaration_resolver_test.go | 31 +- codegen/service/endpoint.go | 2 +- codegen/service/example_svc.go | 4 +- codegen/service/generated_package.go | 61 +++- codegen/service/imports.go | 310 ++++++++++++++++-- codegen/service/imports_test.go | 121 +++++++ codegen/service/service.go | 60 +++- codegen/service/service_data.go | 89 +++-- .../service_data_union_nilability_test.go | 7 +- .../service/service_data_union_order_test.go | 15 +- codegen/service/service_test.go | 40 ++- codegen/service/views.go | 4 +- codegen/validation.go | 11 +- codegen/validation_test.go | 26 ++ expr/dup.go | 2 + expr/result_type.go | 2 + expr/types.go | 2 + grpc/codegen/service_data.go | 59 +++- http/codegen/service_data.go | 117 +++++-- http/codegen/websocket.go | 10 +- 29 files changed, 1405 insertions(+), 431 deletions(-) create mode 100644 codegen/service/imports_test.go diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 411aa39019..87f5aef1bc 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -26,7 +26,7 @@ type ( frozen bool } - // DerivedTypeID identifies a generated view declaration by the exact source + // DerivedTypeID identifies a generated declaration by the exact source // declaration and the closed transformation that produces it. DerivedTypeID struct { origin expr.UserType @@ -36,38 +36,25 @@ type ( // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { - // Name is the unqualified Go declaration name. Derived view types and - // generated union branch aliases keep Name empty until the owning - // generation is frozen. - Name string - // PackagePath is the import path of the package that owns the declaration. - PackagePath string + name string + packagePath string } // UnionDeclaration records the canonical union and discriminator names in // the package that emits them. UnionDeclaration struct { - // Name is the unqualified Go union name. It remains empty until the - // owning generation is frozen. - Name string - // KindName is the unqualified Go discriminator type name. It remains - // empty until the owning generation is frozen. - KindName string - // PackagePath is the import path of the package that owns both names. - PackagePath string + name string + kindName string + packagePath string } // UnionBranchDeclaration records the package-level declarations emitted for // one union branch. UnionBranchDeclaration struct { - // KindConst is the unqualified discriminator constant name. - KindConst string - // Constructor is the unqualified constructor function name. - Constructor string - // Type is the optional generated alias declaration for the branch. - Type *TypeDeclaration - - typeName string + kindConst string + constructor string + branchType *TypeDeclaration + typeName string } // unionDeclaration retains the expression needed to allocate the public @@ -84,8 +71,8 @@ type ( name string } - // derivedTypeKind distinguishes the only two view declaration families - // rebuilt independently during planning and rendering. + // derivedTypeKind distinguishes the closed declaration families rebuilt + // independently during planning and rendering. derivedTypeKind uint // derivedTypeDeclaration retains the preferred name until package freeze. @@ -98,35 +85,106 @@ type ( // derivedTypeOrder contains only stable semantic values so view declaration // suffixes never depend on expression pointer addresses or traversal order. derivedTypeOrder struct { - kind derivedTypeKind - name string - sourceName string - sourceID string - sourceShape string + kind derivedTypeKind + name string + sourceName string + sourceID string } ) const ( projectedTypeKind derivedTypeKind = iota + 1 viewedResultTypeKind + methodPayloadTypeKind + methodStreamingPayloadTypeKind + methodResultTypeKind + methodStreamingResultTypeKind ) // NewProjectedTypeID returns the generated declaration identity for the // pointer-backed projection of source emitted in a service views package. func NewProjectedTypeID(source expr.UserType) DerivedTypeID { - return DerivedTypeID{origin: source.Origin(), kind: projectedTypeKind} + return newDerivedTypeID(source, projectedTypeKind) } // NewViewedResultTypeID returns the generated declaration identity for the // viewed-result wrapper of source emitted in a service views package. func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { - return DerivedTypeID{origin: source.Origin(), kind: viewedResultTypeKind} + return newDerivedTypeID(source, viewedResultTypeKind) +} + +// NewMethodPayloadTypeID returns the generated declaration identity for a raw +// object wrapped as a service method payload. +func NewMethodPayloadTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, methodPayloadTypeKind) +} + +// NewMethodStreamingPayloadTypeID returns the generated declaration identity +// for a raw object wrapped as a service method streaming payload. +func NewMethodStreamingPayloadTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, methodStreamingPayloadTypeKind) +} + +// NewMethodResultTypeID returns the generated declaration identity for a raw +// object wrapped as a service method result. +func NewMethodResultTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, methodResultTypeKind) +} + +// NewMethodStreamingResultTypeID returns the generated declaration identity +// for a raw object wrapped as a service method streaming result. +func NewMethodStreamingResultTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, methodStreamingResultTypeKind) +} + +// Name returns the unqualified Go declaration name. It is empty until the +// generation freezes declarations whose names depend on package collisions. +func (d *TypeDeclaration) Name() string { + return d.name +} + +// PackagePath returns the import path of the package that owns the declaration. +func (d *TypeDeclaration) PackagePath() string { + return d.packagePath +} + +// Name returns the unqualified Go union declaration name. It is empty until +// the generation freezes the owning package. +func (d *UnionDeclaration) Name() string { + return d.name +} + +// KindName returns the unqualified Go discriminator type name. It is empty +// until the generation freezes the owning package. +func (d *UnionDeclaration) KindName() string { + return d.kindName +} + +// PackagePath returns the import path of the package that owns the union. +func (d *UnionDeclaration) PackagePath() string { + return d.packagePath +} + +// KindConst returns the unqualified discriminator constant for the branch. +func (d *UnionBranchDeclaration) KindConst() string { + return d.kindConst +} + +// Constructor returns the unqualified constructor function for the branch. +func (d *UnionBranchDeclaration) Constructor() string { + return d.constructor +} + +// Type returns the generated branch alias declaration and whether the branch +// emits one. +func (d *UnionBranchDeclaration) Type() (*TypeDeclaration, bool) { + return d.branchType, d.branchType != nil } // Ref returns the Go reference spelling for declaration's data type, including // Goa's pointer/value semantics for named objects, unions, and aliases. func (d *TypeDeclaration) Ref(dataType expr.DataType) string { - return goTypeRef(d.Name, dataType) + return goTypeRef(d.name, dataType) } // DeclareUserType reserves userType's exact exported Go name and returns its @@ -159,7 +217,7 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara ) } p.scope.HashedUnique(userType, name, "") - declaration := &TypeDeclaration{Name: name, PackagePath: p.path} + declaration := &TypeDeclaration{name: name, packagePath: p.path} p.userTypes[origin] = declaration p.typeBindings[origin] = declaration p.userTypeNames[name] = userType.Name() @@ -194,13 +252,25 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin identity.origin.Name(), ) } - declaration := &TypeDeclaration{PackagePath: p.path} + declaration := &TypeDeclaration{packagePath: p.path} + if identity.kind.isMethodType() { + if _, ok := p.typeBindings[identity.origin]; ok { + return nil, fmt.Errorf( + "user type %q is already bound to another declaration in generated package %q", + identity.origin.Name(), + p.path, + ) + } + } p.derivedTypes[identity] = &derivedTypeDeclaration{ declaration: declaration, name: name, order: order, } p.derivedKeys[order] = identity + if identity.kind.isMethodType() { + p.typeBindings[identity.origin] = declaration + } return declaration, nil } @@ -216,7 +286,7 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, e return planned.declaration, nil } - declaration := &UnionDeclaration{PackagePath: p.path} + declaration := &UnionDeclaration{packagePath: p.path} branches := make(map[unionBranchID]*UnionBranchDeclaration, len(union.Values)) for _, branch := range union.Values { identity := unionBranchID{name: branch.Name} @@ -253,7 +323,7 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName if !ok { return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) } - if branch.Type != nil { + if branch.branchType != nil { name := Goify(userType.Name(), true) if branch.typeName != name { return nil, fmt.Errorf( @@ -265,18 +335,18 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName ) } origin := userType.Origin() - if existing, ok := p.typeBindings[origin]; ok && existing != branch.Type { + if existing, ok := p.typeBindings[origin]; ok && existing != branch.branchType { return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) } - p.typeBindings[origin] = branch.Type - return branch.Type, nil + p.typeBindings[origin] = branch.branchType + return branch.branchType, nil } - declaration := &TypeDeclaration{PackagePath: p.path} + declaration := &TypeDeclaration{packagePath: p.path} origin := userType.Origin() if existing, ok := p.typeBindings[origin]; ok && existing != declaration { return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) } - branch.Type = declaration + branch.branchType = declaration branch.typeName = Goify(userType.Name(), true) p.typeBindings[origin] = declaration return declaration, nil @@ -342,10 +412,10 @@ func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string) if err != nil { return nil, err } - if branch.Type == nil { + if branch.branchType == nil { return nil, fmt.Errorf("branch %q of union %q has no generated type in package %q", branchName, union.Name(), p.path) } - return branch.Type, nil + return branch.branchType, nil } // Scope returns the frozen package-owned name scope used to render generated @@ -383,7 +453,7 @@ func (p *GeneratedPackage) freeze() { return compareDerivedTypeOrder(a.order, b.order) }) for _, planned := range derived { - planned.declaration.Name = p.scope.Unique(planned.name) + planned.declaration.name = p.scope.Unique(planned.name) } identities := make([]UnionTypeID, 0, len(p.unions)) @@ -394,8 +464,8 @@ func (p *GeneratedPackage) freeze() { for _, identity := range identities { planned := p.unions[identity] name := p.scope.HashedUnique(identity, Goify(planned.union.Name(), true), "") - planned.declaration.Name = name - planned.declaration.KindName = p.scope.Unique(name + "Kind") + planned.declaration.name = name + planned.declaration.kindName = p.scope.Unique(name + "Kind") branches := make([]unionBranchID, 0, len(planned.branches)) for branch := range planned.branches { @@ -406,26 +476,40 @@ func (p *GeneratedPackage) freeze() { }) for _, identity := range branches { branch := planned.branches[identity] - if branch.Type != nil { - branch.Type.Name = p.scope.Unique(branch.typeName) + if branch.branchType != nil { + branch.branchType.name = p.scope.Unique(branch.typeName) } - branch.KindConst = p.scope.Unique(planned.declaration.KindName + Goify(identity.name, true)) - branch.Constructor = p.scope.Unique("New" + planned.declaration.Name + Goify(identity.name, true)) + branch.kindConst = p.scope.Unique(planned.declaration.kindName + Goify(identity.name, true)) + branch.constructor = p.scope.Unique("New" + planned.declaration.name + Goify(identity.name, true)) } } p.scope.Freeze() p.frozen = true } +// newDerivedTypeID validates and records the exact declaration origin used by +// independently rebuilt planning and rendering graphs. +func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID { + if source == nil || source.Origin() == nil { + panic("derived type source has no declaration origin") + } + return DerivedTypeID{origin: source.Origin(), kind: kind} +} + +// isMethodType reports whether the derived declaration names a raw method +// object wrapper in the service package. +func (k derivedTypeKind) isMethodType() bool { + return k >= methodPayloadTypeKind && k <= methodStreamingResultTypeKind +} + // newDerivedTypeOrder builds deterministic ordering data independent of // expression pointer addresses. func newDerivedTypeOrder(identity DerivedTypeID, name string) derivedTypeOrder { return derivedTypeOrder{ - kind: identity.kind, - name: name, - sourceName: identity.origin.Name(), - sourceID: identity.origin.ID(), - sourceShape: expr.Hash(identity.origin, false, false, false), + kind: identity.kind, + name: name, + sourceName: identity.origin.Name(), + sourceID: identity.origin.ID(), } } @@ -438,7 +522,6 @@ func compareDerivedTypeOrder(left, right derivedTypeOrder) int { {left.name, right.name}, {left.sourceName, right.sourceName}, {left.sourceID, right.sourceID}, - {left.sourceShape, right.sourceShape}, } { if compared := strings.Compare(values[0], values[1]); compared != 0 { return compared diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 8211aa2a64..3e183db76b 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -69,10 +69,8 @@ func TestGeneratedPackageUserTypes(t *testing.T) { first, err := types.DeclareUserType(widget) require.NoError(t, err) - require.Equal(t, &TypeDeclaration{ - Name: "Widget", - PackagePath: "generated.local/gen/types", - }, first) + require.Equal(t, "Widget", first.Name()) + require.Equal(t, "generated.local/gen/types", first.PackagePath()) second, err := types.DeclareUserType(widget) require.NoError(t, err) require.Same(t, first, second) @@ -82,7 +80,7 @@ func TestGeneratedPackageUserTypes(t *testing.T) { require.Same(t, first, lookedUp) declaredMissing, err := types.DeclareUserType(missing) require.NoError(t, err) - require.Equal(t, "Missing", declaredMissing.Name) + require.Equal(t, "Missing", declaredMissing.Name()) require.NoError(t, generation.Freeze()) require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) } @@ -147,8 +145,53 @@ func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { viewedCopy, err := views.DerivedType(NewViewedResultTypeID(copy)) require.NoError(t, err) require.Same(t, viewed, viewedCopy) - require.Equal(t, "ValueView", projected.Name) - require.Equal(t, "Value", viewed.Name) + require.Equal(t, "ValueView", projected.Name()) + require.Equal(t, "Value", viewed.Name()) +} + +// TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder verifies that stable +// semantic source identifiers, not traversal order, decide suffix ownership. +func TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := NewGeneration("generated.local/gen", nil) + views := generation.GeneratedPackage("generated.local/gen/service/views") + first := generatedUserType("Value", "first") + second := generatedUserType("Value", "second") + ids := []DerivedTypeID{NewProjectedTypeID(first), NewProjectedTypeID(second)} + if reverse { + ids[0], ids[1] = ids[1], ids[0] + } + for _, identity := range ids { + _, err := views.DeclareDerivedType(identity, "ValueView") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + firstDeclaration, err := views.DerivedType(NewProjectedTypeID(first)) + require.NoError(t, err) + secondDeclaration, err := views.DerivedType(NewProjectedTypeID(second)) + require.NoError(t, err) + return firstDeclaration.Name(), secondDeclaration.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + +// TestGeneratedPackageRejectsAmbiguousDerivedOrder verifies that two distinct +// origins cannot rely on unstable expression shape to break an otherwise +// identical semantic ordering tuple. +func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + views := generation.GeneratedPackage("generated.local/gen/service/views") + first := generatedUserTypeOf("Value", "same", expr.String) + second := generatedUserTypeOf("Value", "same", expr.Int) + + _, err := views.DeclareDerivedType(NewProjectedTypeID(first), "ValueView") + require.NoError(t, err) + _, err = views.DeclareDerivedType(NewProjectedTypeID(second), "ValueView") + require.ErrorContains(t, err, "cannot deterministically order") } // TestGeneratedPackageUnionBranchesShareDeclaration verifies that separately @@ -168,10 +211,10 @@ func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) require.NoError(t, err) require.Same(t, firstDeclaration, secondDeclaration) - require.Empty(t, firstDeclaration.Name) + require.Empty(t, firstDeclaration.Name()) require.NoError(t, generation.Freeze()) - require.Equal(t, "ValueText", firstDeclaration.Name) + require.Equal(t, "ValueText", firstDeclaration.Name()) lookedUp, err := types.UnionBranchType(secondUnion, "text") require.NoError(t, err) require.Same(t, firstDeclaration, lookedUp) @@ -198,8 +241,8 @@ func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { require.NoError(t, generation.Freeze()) require.ElementsMatch(t, []string{"ValueText", "ValueText2"}, []string{ - firstDeclaration.Name, - secondDeclaration.Name, + firstDeclaration.Name(), + secondDeclaration.Name(), }) } @@ -222,9 +265,11 @@ func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { require.NoError(t, generation.Freeze()) branch, err := types.UnionBranch(union, "text") require.NoError(t, err) - require.Equal(t, "ValueKindText2", branch.KindConst) - require.Equal(t, "NewValueText2", branch.Constructor) - require.Same(t, aliasDeclaration, branch.Type) + require.Equal(t, "ValueKindText2", branch.KindConst()) + require.Equal(t, "NewValueText2", branch.Constructor()) + branchType, ok := branch.Type() + require.True(t, ok) + require.Same(t, aliasDeclaration, branchType) } // TestGeneratedPackageUnions verifies that emitted-definition identity makes @@ -239,16 +284,15 @@ func TestGeneratedPackageUnions(t *testing.T) { firstDeclaration, err := types.DeclareUnion(first) require.NoError(t, err) - require.Equal(t, &UnionDeclaration{ - PackagePath: "generated.local/gen/types", - }, firstDeclaration) + require.Empty(t, firstDeclaration.Name()) + require.Equal(t, "generated.local/gen/types", firstDeclaration.PackagePath()) equivalentDeclaration, err := types.DeclareUnion(equivalent) require.NoError(t, err) require.Same(t, firstDeclaration, equivalentDeclaration) differentDeclaration, err := types.DeclareUnion(different) require.NoError(t, err) - require.Empty(t, differentDeclaration.Name) + require.Empty(t, differentDeclaration.Name()) require.NotSame(t, firstDeclaration, differentDeclaration) lookedUp, err := types.Union(equivalent) @@ -256,12 +300,12 @@ func TestGeneratedPackageUnions(t *testing.T) { require.Same(t, firstDeclaration, lookedUp) require.NoError(t, generation.Freeze()) require.ElementsMatch(t, []string{"Value", "Value2"}, []string{ - firstDeclaration.Name, - differentDeclaration.Name, + firstDeclaration.Name(), + differentDeclaration.Name(), }) require.ElementsMatch(t, []string{"ValueKind", "Value2Kind"}, []string{ - firstDeclaration.KindName, - differentDeclaration.KindName, + firstDeclaration.KindName(), + differentDeclaration.KindName(), }) reversedGeneration := NewGeneration("generated.local/gen", nil) @@ -271,8 +315,8 @@ func TestGeneratedPackageUnions(t *testing.T) { reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("Value", "type", "value")) require.NoError(t, err) require.NoError(t, reversedGeneration.Freeze()) - require.Equal(t, firstDeclaration.Name, reversedFirst.Name) - require.Equal(t, differentDeclaration.Name, reversedDifferent.Name) + require.Equal(t, firstDeclaration.Name(), reversedFirst.Name()) + require.Equal(t, differentDeclaration.Name(), reversedDifferent.Name()) } // TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder verifies that @@ -314,15 +358,15 @@ func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { require.NoError(t, err) } - require.Equal(t, "Value", userDeclaration.Name) - require.Equal(t, "ValueKind", kindDeclaration.Name) - require.Empty(t, unionDeclaration.Name) - require.Empty(t, unionDeclaration.KindName) + require.Equal(t, "Value", userDeclaration.Name()) + require.Equal(t, "ValueKind", kindDeclaration.Name()) + require.Empty(t, unionDeclaration.Name()) + require.Empty(t, unionDeclaration.KindName()) require.NoError(t, generation.Freeze()) - require.Equal(t, "Value", userDeclaration.Name) - require.Equal(t, "ValueKind", kindDeclaration.Name) - require.Equal(t, "Value2", unionDeclaration.Name) - require.Equal(t, "Value2Kind", unionDeclaration.KindName) + require.Equal(t, "Value", userDeclaration.Name()) + require.Equal(t, "ValueKind", kindDeclaration.Name()) + require.Equal(t, "Value2", unionDeclaration.Name()) + require.Equal(t, "Value2Kind", unionDeclaration.KindName()) require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType})) require.Equal(t, "ValueKind", types.Scope().GoTypeName(&expr.AttributeExpr{Type: kindUserType})) require.Equal(t, "Value2", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) @@ -341,7 +385,7 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { require.NoError(t, err) unionDeclaration, err := types.DeclareUnion(union) require.NoError(t, err) - require.Empty(t, unionDeclaration.Name) + require.Empty(t, unionDeclaration.Name()) require.NoError(t, generation.Freeze()) lookedUpUser, err := types.UserType(widget) @@ -350,7 +394,7 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { lookedUpUnion, err := types.Union(union) require.NoError(t, err) require.Same(t, unionDeclaration, lookedUpUnion) - require.Equal(t, "Value", lookedUpUnion.Name) + require.Equal(t, "Value", lookedUpUnion.Name()) require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) require.Panics(t, func() { @@ -382,8 +426,8 @@ func TestGenerationCatalogsAreIsolated(t *testing.T) { require.NoError(t, err) require.NoError(t, firstGeneration.Freeze()) require.NoError(t, secondGeneration.Freeze()) - require.Equal(t, "Value", firstDeclaration.Name) - require.Equal(t, "Value", secondDeclaration.Name) + require.Equal(t, "Value", firstDeclaration.Name()) + require.Equal(t, "Value", secondDeclaration.Name()) require.NotSame(t, firstDeclaration, secondDeclaration) require.NotSame(t, first.Scope(), second.Scope()) } diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index c41a600a43..fab55dbd1c 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -58,7 +58,7 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { if err != nil { return nil, err } - if declaration.Name == "" { + if declaration.Name() == "" { return nil, fmt.Errorf("union name is empty during render") } _, lateDeclare = generation.GeneratedPackage(typesPath).DeclareUnion(lateUnion) diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 28bbc31b29..2ed9b9df6a 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -1,3 +1,5 @@ +// This file renders OpenAPI documents from the same evaluated roots and frozen +// service declaration data used by the transport generators. package generator import ( diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index c8e8362191..fac720a85d 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -213,6 +213,189 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { runGeneratedTests(t, genDir) } +// TestServiceReferencesUseImportPathAliases verifies that one service can +// reference generated packages with the same Go package name without emitting +// duplicate import aliases or ambiguous qualified references. +func TestServiceReferencesUseImportPathAliases(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + codegen.RunDSL(t, func() { + dsl.API("path-owned aliases", func() {}) + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { + dsl.Payload(first) + }) + dsl.Method("Second", func() { + dsl.Payload(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `shared "gen/first/shared"`) + require.Contains(t, code, `shared2 "gen/second/shared"`) + require.Contains(t, code, `*shared.First`) + require.Contains(t, code, `*shared2.Second`) + runGeneratedTests(t, genDir) +} + +// TestNamedUnionBranchImportsReferenceOnly verifies that unions.go does not +// expand a named branch definition and import packages used only where that +// named type itself is declared. +func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + codegen.RunDSL(t, func() { + dsl.API("named branch imports", func() {}) + value := dsl.Type("Value", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "gen/custom/json", "json") + }) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(value) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "json"), "json") + content, err := os.ReadFile(filepath.Join(genDir, "values", "unions.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `"encoding/json"`) + require.NotContains(t, code, `"gen/custom/json"`) + runGeneratedTests(t, genDir) +} + +// TestNormalizedMethodTypesUseServicePackageNames verifies that raw method +// object wrappers collide only with declarations emitted in the same service +// package, never with a nested declaration relocated elsewhere. +func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil + } + + t.Run("relocated name does not collide", func(t *testing.T) { + codegen.RunDSL(t, func() { + dsl.API("relocated wrapper names", func() {}) + relocated := dsl.Type("UsePayload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Other", func() { + dsl.Payload(func() { + dsl.Field(1, "nested", relocated) + }) + dsl.HTTP(func() { + dsl.POST("/other") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type UsePayload struct") + require.NotContains(t, string(content), "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) + + t.Run("local name collides", func(t *testing.T) { + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planServiceData, Generate: Transport}, + }, nil + } + codegen.RunDSL(t, func() { + dsl.API("local wrapper names", func() {}) + local := dsl.Type("UsePayload", func() { + dsl.Field(1, "existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + dsl.HTTP(func() { + dsl.POST("/existing") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, "type UsePayload struct") + require.Contains(t, code, "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) +} + // TestNestedRelocatedDeclarationsOwnTheirImports verifies that metadata imports // used by two relocated declarations stay in their respective declaration // files and do not leak into the service file that references their package. diff --git a/codegen/normalize.go b/codegen/normalize.go index 0eaa03005e..f71bcae0af 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -1,206 +1,57 @@ +// This file performs the one allowed post-evaluation design mutation. It gives +// raw method object shapes stable semantic user-type wrappers while leaving Go +// declaration naming to the generated service package catalog. package codegen -import ( - "strings" +import "goa.design/goa/v3/expr" - "goa.design/goa/v3/expr" -) - -// NormalizeRoot applies the only sanctioned design mutation that may happen -// after the DSL has been evaluated and finalized: it wraps the raw object -// payload, result and streaming types of every service method into -// synthesized user types named after the method. Every code generation layer -// (service, transports, OpenAPI, example, CLI and type conversion) relies on -// method payload and result types being named, so the wrapping must happen -// before any generator reads the design. -// -// NormalizeRoot is idempotent: already wrapped methods are left untouched. It -// must run after the prepare plugins so that plugin contributed endpoints are -// normalized too, and before any generator runs. Past this point the design -// expression tree is read-only for all generators; the purity test in -// codegen/generator enforces that contract. +// NormalizeRoot wraps raw object payload, result, and streaming attributes in +// synthesized user types. The wrappers carry their natural preferred names and +// stable semantic identifiers; package planning later resolves Go collisions +// against declarations that are actually emitted in the service package. // -// The synthesized type names are resolved against a name scope seeded with -// the exact same registrations the service generator performs when it -// collects the service user types (see codegen/service analyze) so that -// wrapping up front produces the very same type names the service generator -// produced when it owned the wrapping. -func NormalizeRoot(r *expr.RootExpr) { - for _, svc := range r.Services { - normalizeService(svc) +// NormalizeRoot is idempotent and must run after prepare plugins and before +// generators read the design expression tree. +func NormalizeRoot(root *expr.RootExpr) { + for _, service := range root.Services { + normalizeService(service) } } -// normalizeService wraps the raw object method types of svc into synthesized -// user types. The name scope fed to PeekUnique is seeded by replaying the -// scope side effects of the service generator analysis in the same order: -// reserved identifiers and package name first, then the user types reachable -// from the service errors and from each method payload, streaming payload, -// result, streaming result, projected result types and method errors. -func normalizeService(svc *expr.ServiceExpr) { - scope := NewNameScope() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - scope.HashedUnique(svc, strings.ToLower(Goify(svc.Name, false)), "svc") - seen := make(map[string]struct{}) - for _, er := range svc.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - seedMethodAtt := func(att *expr.AttributeExpr) { - if att == nil { - return - } - if ut, ok := att.Type.(expr.UserType); ok { - att = ut.Attribute() - } - seedTypeNames(att, scope, seen) - } - seenProjected := make(map[string]struct{}) - for _, m := range svc.Methods { - seedMethodAtt(m.Payload) - seedMethodAtt(m.StreamingPayload) - seedMethodAtt(m.Result) - if m.HasMixedResults() { - seedMethodAtt(m.StreamingResult) - } - if hasResultTypeExpr(m.Result, make(map[string]struct{})) { - seedProjectedNames(expr.DupAtt(m.Result), m.Result, scope, seenProjected) - } - for _, er := range m.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - } - wrap := func(att *expr.AttributeExpr, name, id string) { - if att == nil { - return - } - if _, ok := att.Type.(*expr.Object); !ok { - return - } - att.Type = &expr.UserTypeExpr{ - AttributeExpr: expr.DupAtt(att), - TypeName: scope.PeekUnique(name), - UID: id, - } - } - for _, m := range svc.Methods { - name := Goify(m.Name, true) - wrap(m.Payload, name+"Payload", svc.Name+"#"+name+"Payload") - wrap(m.StreamingPayload, name+"StreamingPayload", svc.Name+"#"+name+"StreamingPayload") - wrap(m.Result, name+"Result", svc.Name+"#"+name+"Result") - if m.HasMixedResults() { - wrap(m.StreamingResult, name+"StreamingResult", svc.Name+"#"+name+"StreamingResult") +// normalizeService creates semantic wrappers for the raw object attributes of +// one service without consulting or mutating any Go name scope. +func normalizeService(service *expr.ServiceExpr) { + for _, method := range service.Methods { + name := Goify(method.Name, true) + normalizeMethodAttribute(method.Payload, name+"Payload", service.Name+"#"+name+"Payload") + normalizeMethodAttribute( + method.StreamingPayload, + name+"StreamingPayload", + service.Name+"#"+name+"StreamingPayload", + ) + normalizeMethodAttribute(method.Result, name+"Result", service.Name+"#"+name+"Result") + if method.HasMixedResults() { + normalizeMethodAttribute( + method.StreamingResult, + name+"StreamingResult", + service.Name+"#"+name+"StreamingResult", + ) } } } -// seedTypeNames mirrors the name scope side effects of the service generator -// user type collection (collectTypes in codegen/service): every user type -// reachable from at reserves its Go type name, the names referenced by its -// type definition and its type reference, in the same order. The returned -// strings are discarded, only the scope registrations matter. -func seedTypeNames(at *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - if at == nil || at.Type == expr.Empty { +// normalizeMethodAttribute gives a raw method object its semantic identity. +// Existing named and non-object method types remain unchanged. +func normalizeMethodAttribute(attribute *expr.AttributeExpr, name, id string) { + if attribute == nil { return } - switch dt := at.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - scope.GoTypeName(at) - scope.GoTypeDef(dt.Attribute(), false, true) - scope.GoTypeRef(at) - seen[dt.ID()] = struct{}{} - seedTypeNames(dt.Attribute(), scope, seen) - case *expr.Object: - for _, nat := range *dt { - seedTypeNames(nat.Attribute, scope, seen) - } - case *expr.Array: - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Map: - seedTypeNames(dt.KeyType, scope, seen) - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Union: - for _, nat := range dt.Values { - seedTypeNames(nat.Attribute, scope, seen) - } - } -} - -// seedProjectedNames mirrors the name scope side effects of the projected -// type collection (collectProjectedTypes and the view conversion builders in -// codegen/service): projected is a detached copy of the method result -// attribute whose user types are renamed with the "View" suffix while -// traversing, and every result type with views reserves the projected and -// original type names in the service scope, children first. -func seedProjectedNames(projected, att *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - switch pt := projected.Type.(type) { - case expr.UserType: - dt := att.Type.(expr.UserType) - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - pt.Rename(pt.Name() + "View") - seedProjectedNames(pt.Attribute(), dt.Attribute(), scope, seen) - if rt, ok := pt.(*expr.ResultTypeExpr); ok && len(rt.Views) > 0 { - if parr := expr.AsArray(pt); parr != nil { - scope.GoTypeName(parr.ElemType) - } - scope.GoTypeName(projected) - scope.GoTypeName(att) - } - case *expr.Array: - seedProjectedNames(pt.ElemType, att.Type.(*expr.Array).ElemType, scope, seen) - case *expr.Map: - dt := att.Type.(*expr.Map) - seedProjectedNames(pt.KeyType, dt.KeyType, scope, seen) - seedProjectedNames(pt.ElemType, dt.ElemType, scope, seen) - case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - seedProjectedNames(n.Attribute, dt.Attribute(n.Name), scope, seen) - } - case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - seedProjectedNames(n.Attribute, dt.Values[i].Attribute, scope, seen) - } - } -} - -// hasResultTypeExpr reports whether att transitively references a result type -// expression. It mirrors hasResultType in codegen/service which decides -// whether the service generator collects projected types for a method result. -func hasResultTypeExpr(att *expr.AttributeExpr, seen map[string]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true + if _, ok := attribute.Type.(*expr.Object); !ok { + return } - switch a := att.Type.(type) { - case expr.UserType: - if _, ok := seen[a.ID()]; ok { - return false - } - seen[a.ID()] = struct{}{} - return hasResultTypeExpr(a.Attribute(), seen) - case *expr.Array: - return hasResultTypeExpr(a.ElemType, seen) - case *expr.Map: - return hasResultTypeExpr(a.KeyType, seen) || hasResultTypeExpr(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } - } + attribute.Type = &expr.UserTypeExpr{ + AttributeExpr: expr.DupAtt(attribute), + TypeName: name, + UID: id, } - return false } diff --git a/codegen/service/client.go b/codegen/service/client.go index 8b2e6b8953..aa3a095077 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -29,7 +29,7 @@ func ClientFile(genpkg string, service *expr.ServiceExpr, services *ServicesData {Path: "io"}, codegen.GoaImport(""), } - imports = append(imports, AttributeImports(genpkg, outputPackage, serviceReferenceAttributes(service)...)...) + imports = append(imports, services.AttributeImports(outputPackage, serviceReferenceAttributes(service)...)...) header := codegen.Header(service.Name+" client", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "client-struct", diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 5c183985c5..2bbcd75e6b 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -200,7 +200,7 @@ func generateConvertFileForPath( outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) } srcAtt := &expr.AttributeExpr{Type: c.User} - srcResolver := newServiceResolver(services.generation, service, outputPath).Enter(srcAtt) + srcResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(srcAtt) srcCtx := &codegen.AttributeContext{ UseDefault: true, Scope: srcResolver, @@ -253,7 +253,7 @@ func generateConvertFileForPath( if loc := codegen.UserTypeLocation(c.User); loc != nil { outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) } - tgtResolver := newServiceResolver(services.generation, service, outputPath).Enter(tgtAtt) + tgtResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(tgtAtt) tgtCtx := &codegen.AttributeContext{ UseDefault: true, Scope: tgtResolver, diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index fa7bf26c34..43d50deeff 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -18,19 +18,48 @@ type ( // records selected during Plan. declarationResolver struct { generation *codegen.Generation + aliases *importAliases service *expr.ServiceExpr currentPath string outputPath string derived map[expr.UserType]codegen.DerivedTypeID view bool } + + // methodDeclarationAttributor binds one normalized method wrapper to its + // frozen declaration while leaving all other transport naming unchanged. + methodDeclarationAttributor struct { + origin expr.UserType + declaration *codegen.TypeDeclaration + delegate codegen.Attributor + } ) +// NewMethodTypeContext returns the service-side transport context for a named +// method type. The exact wrapper uses its frozen declaration; nested and wire +// attributes retain the transport's existing naming scope. +func NewMethodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { + userType, ok := attribute.Type.(expr.UserType) + if !ok || declaration == nil { + panic("method type context requires a named generated declaration") + } + delegate := codegen.NewAttributeContext(false, false, true, pkg, scope).Scope + return &codegen.AttributeContext{ + UseDefault: true, + Scope: &methodDeclarationAttributor{ + origin: userType.Origin(), + declaration: declaration, + delegate: delegate, + }, + } +} + // newServiceResolver resolves declarations starting in service's generated // package and qualifies names relative to outputPath. -func newServiceResolver(generation *codegen.Generation, service *expr.ServiceExpr, outputPath string) *declarationResolver { +func newServiceResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, outputPath string) *declarationResolver { return &declarationResolver{ generation: generation, + aliases: aliases, service: service, currentPath: servicePackagePath(generation.GenPkg, service), outputPath: outputPath, @@ -40,10 +69,11 @@ func newServiceResolver(generation *codegen.Generation, service *expr.ServiceExp // newViewResolver resolves every declaration in service's views package. // derived binds rebuilt projected expression origins to their typed catalog // identities. -func newViewResolver(generation *codegen.Generation, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { +func newViewResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { viewsPath := servicePackagePath(generation.GenPkg, service) + "/views" return &declarationResolver{ generation: generation, + aliases: aliases, service: service, currentPath: viewsPath, outputPath: viewsPath, @@ -57,8 +87,15 @@ func newViewResolver(generation *codegen.Generation, service *expr.ServiceExpr, func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDefault bool) string { switch actual := att.Type.(type) { case expr.Primitive: - if custom, _ := codegen.GetMetaType(att); custom != "" { - return custom + if custom, spec := codegen.GetMetaType(att); custom != "" { + if spec == nil { + return custom + } + _, typeName, qualified := strings.Cut(custom, ".") + if !qualified { + return custom + } + return r.aliases.name(spec.Path) + "." + typeName } return codegen.GoNativeTypeName(actual) case *expr.Array: @@ -73,14 +110,14 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe } owner := r.owner(att) declaration := r.userType(owner, actual) - return r.qualify(owner, declaration.Name) + return r.qualify(owner, declaration.Name()) case *expr.Union: owner := r.owner(att) declaration, err := r.generation.GeneratedPackage(owner).Union(actual) if err != nil { panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.service.Name, owner, err)) } - return r.qualify(owner, declaration.Name) + return r.qualify(owner, declaration.Name()) case expr.CompositeExpr: return r.Name(actual.Attribute(), "", ptr, useDefault) default: @@ -166,7 +203,7 @@ func (r *declarationResolver) Package(att *expr.AttributeExpr) string { if owner == r.outputPath { return "" } - return generatedPackageName(r.generation.GenPkg, r.service, owner) + return r.aliases.name(owner) } // Enter returns a resolver whose current package owns att and its unlocated @@ -260,13 +297,13 @@ func (r *declarationResolver) qualify(owner, name string) string { if owner == r.outputPath { return name } - return generatedPackageName(r.generation.GenPkg, r.service, owner) + "." + name + return r.aliases.name(owner) + "." + name } // refDeclaration qualifies declaration for the resolver's output file while // preserving the pointer or value semantics of dataType. func (r *declarationResolver) refDeclaration(declaration *codegen.TypeDeclaration, dataType expr.DataType) string { - qualified := r.qualify(declaration.PackagePath, declaration.Name) + qualified := r.qualify(declaration.PackagePath(), declaration.Name()) if strings.HasPrefix(declaration.Ref(dataType), "*") { return "*" + qualified } @@ -276,21 +313,68 @@ func (r *declarationResolver) refDeclaration(declaration *codegen.TypeDeclaratio // declarationName returns the unqualified planned name for one named type. func (r *declarationResolver) declarationName(attribute *expr.AttributeExpr) string { entered := r.Enter(attribute).(*declarationResolver) - return entered.userType(entered.currentPath, attribute.Type.(expr.UserType)).Name + return entered.userType(entered.currentPath, attribute.Type.(expr.UserType)).Name() } -// generatedPackageName returns the Go package name for one generated import -// path selected by the service generator. -func generatedPackageName(genpkg string, service *expr.ServiceExpr, packagePath string) string { - servicePath := servicePackagePath(genpkg, service) - switch packagePath { - case servicePath: - return strings.ToLower(codegen.Goify(service.Name, false)) - case servicePath + "/views": - return strings.ToLower(codegen.Goify(service.Name, false)) + "views" - default: - return strings.ToLower(codegen.Goify(path.Base(packagePath), false)) +// Name returns the frozen wrapper name for the bound method type and delegates +// every other attribute to the transport's existing scope. +func (a *methodDeclarationAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + if a.matches(attribute) { + if pkg == "" { + return a.declaration.Name() + } + return pkg + "." + a.declaration.Name() + } + return a.delegate.Name(attribute, pkg, pointer, useDefault) +} + +// Ref returns the frozen wrapper reference for the bound method type and +// delegates every other attribute to the transport's existing scope. +func (a *methodDeclarationAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { + if !a.matches(attribute) { + return a.delegate.Ref(attribute, pkg) + } + name := a.Name(attribute, pkg, false, false) + if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { + return "*" + name } + return name +} + +// Field delegates service field naming to the transport's existing scope. +func (a *methodDeclarationAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return a.delegate.Field(attribute, name, firstUpper) +} + +// Package delegates package qualification to the transport's existing scope. +func (a *methodDeclarationAttributor) Package(attribute *expr.AttributeExpr) string { + return a.delegate.Package(attribute) +} + +// Enter keeps the frozen binding for the exact wrapper and delegates nested +// attributes to the transport's existing package rules. +func (a *methodDeclarationAttributor) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + if a.matches(attribute) { + return a + } + return a.delegate.Enter(attribute) +} + +// IsSumType preserves the transport scope's union representation. +func (a *methodDeclarationAttributor) IsSumType() bool { + return a.delegate.IsSumType() +} + +// Scope returns the transport naming scope used for all unbound attributes. +func (a *methodDeclarationAttributor) Scope() *codegen.NameScope { + return a.delegate.Scope() +} + +// matches reports whether attribute is the exact normalized wrapper bound to +// this rendering context. +func (a *methodDeclarationAttributor) matches(attribute *expr.AttributeExpr) bool { + userType, ok := attribute.Type.(expr.UserType) + return ok && userType.Origin() == a.origin } // serviceFieldIsPointer matches Goa service struct pointer semantics for one diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index db8270c1c8..e977ee83a8 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -4,6 +4,7 @@ package service import ( + "path" "strings" "testing" @@ -40,7 +41,7 @@ func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { branchDeclaration, err := types.DeclareUnionBranchType(union, "text", generatedBranch) require.NoError(t, err) require.NoError(t, generation.Freeze()) - require.Equal(t, "ValueText2", branchDeclaration.Name) + require.Equal(t, "ValueText2", branchDeclaration.Name()) externalBranch := resolverUserType("ExternalValueText", expr.String) externalUnion := &expr.Union{ @@ -55,7 +56,12 @@ func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { relocatedAttribute := &expr.AttributeExpr{Type: relocated} externalAttribute := &expr.AttributeExpr{Type: external} - resolver := newServiceResolver(generation, service, "generated.local/gen/types") + resolver := newServiceResolver( + generation, + aliasesForTest(t, "generated.local/gen/types"), + service, + "generated.local/gen/types", + ) relocatedContext := declarationContext(resolver.Enter(relocatedAttribute), false) externalContext := codegen.NewAttributeContext(false, false, true, "external", codegen.NewNameScope()) @@ -111,10 +117,16 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( resolver := newServiceResolver( generation, + aliasesForTest( + t, + servicePackagePath(generation.GenPkg, service), + "generated.local/gen/errors", + "generated.local/gen/types", + ), service, servicePackagePath(generation.GenPkg, service), ) - require.Equal(t, "Fault", localDeclaration.Name) + require.Equal(t, "Fault", localDeclaration.Name()) require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) errorData := buildErrorInitData(&expr.ErrorExpr{ @@ -143,6 +155,7 @@ func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { require.NoError(t, generation.Freeze()) resolver := newServiceResolver( generation, + aliasesForTest(t, servicePackagePath(generation.GenPkg, service)), service, servicePackagePath(generation.GenPkg, service), ) @@ -156,6 +169,18 @@ func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { ) } +// aliasesForTest builds the same frozen full-path qualifier table used by +// service analysis for the package paths exercised by a focused resolver test. +func aliasesForTest(t *testing.T, paths ...string) *importAliases { + t.Helper() + plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} + require.NoError(t, plan.addFixedImports()) + for _, importPath := range paths { + require.NoError(t, plan.add(importPath, codegen.Goify(path.Base(importPath), false), true, false)) + } + return plan.freeze() +} + // resolverUserType constructs one exact declaration for resolver tests. func resolverUserType(name string, dataType expr.DataType) *expr.UserTypeExpr { return &expr.UserTypeExpr{ diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index fbb0455442..23da84d87d 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -88,7 +88,7 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa codegen.GoaImport("security"), {Path: genpkg + "/" + svcName + "/" + "views", Name: svc.ViewsPkg}, } - imports = append(imports, AttributeImports(genpkg, outputPackage, serviceReferenceAttributes(service)...)...) + imports = append(imports, services.AttributeImports(outputPackage, serviceReferenceAttributes(service)...)...) header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "endpoints-struct", diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index e767f89755..ea1cb0482c 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -75,7 +75,7 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, {Path: "goa.design/clue/log"}, {Path: "goa.design/goa/v3/security"}, } - specs = append(specs, AttributeImports(genpkg, path.Dir(genpkg), serviceReferenceAttributes(svc)...)...) + specs = append(specs, services.AttributeImports(path.Dir(genpkg), serviceReferenceAttributes(svc)...)...) sections := []*codegen.SectionTemplate{ codegen.Header("", apipkg, specs), { @@ -95,7 +95,7 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, Data: data, }) } - resolver := newServiceResolver(services.generation, svc, path.Dir(genpkg)) + resolver := newServiceResolver(services.generation, services.aliases, svc, path.Dir(genpkg)) for _, m := range svc.Methods { sections = append(sections, basicEndpointSection(m, data, resolver)) } diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 790dc3edf2..f0a2bbb211 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -30,6 +30,14 @@ type ( packagePath string } + // methodTypeCandidate identifies one normalized method role and the typed + // declaration identity allocated for it. + methodTypeCandidate struct { + attribute *expr.AttributeExpr + suffix string + identity func(expr.UserType) codegen.DerivedTypeID + } + // unionBranch identifies a generated user type that exists only to name one // branch of its owning union. unionBranch struct { @@ -69,6 +77,10 @@ type ( func Plan(root *expr.RootExpr, generation *codegen.Generation) error { inputs := planningInputs(root) rootTypes := newRootTypeSet(root) + methodTypes, err := planMethodTypes(root, generation) + if err != nil { + return err + } for _, service := range root.Services { // The service package record makes NewServicesData a render-only contract: // its scope is unavailable until the generation freezes. @@ -77,7 +89,7 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { seenTypes := make(map[plannedUserType]struct{}) for _, input := range inputs { - if err := planUserTypes(input.attribute, input.service, input.location, generation, rootTypes, seenTypes); err != nil { + if err := planUserTypes(input.attribute, input.service, input.location, generation, rootTypes, methodTypes, seenTypes); err != nil { return err } } @@ -91,6 +103,42 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { return planViews(root, generation, rootTypes) } +// planMethodTypes declares the semantic wrappers created by NormalizeRoot as +// derived service-package declarations. Exact user types in the same package +// are planned separately and therefore keep their authored names. +func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { + planned := make(map[expr.UserType]codegen.DerivedTypeID) + for _, service := range root.Services { + generatedPackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + for _, method := range service.Methods { + attributes := []methodTypeCandidate{ + {method.Payload, "Payload", codegen.NewMethodPayloadTypeID}, + {method.StreamingPayload, "StreamingPayload", codegen.NewMethodStreamingPayloadTypeID}, + {method.Result, "Result", codegen.NewMethodResultTypeID}, + } + if method.HasMixedResults() { + attributes = append(attributes, methodTypeCandidate{ + attribute: method.StreamingResult, + suffix: "StreamingResult", + identity: codegen.NewMethodStreamingResultTypeID, + }) + } + for _, candidate := range attributes { + userType, ok := candidate.attribute.Type.(expr.UserType) + if !ok || userType.ID() != normalizedMethodTypeID(service, method, candidate.suffix) { + continue + } + identity := candidate.identity(userType) + if _, err := generatedPackage.DeclareDerivedType(identity, codegen.Goify(userType.Name(), true)); err != nil { + return nil, err + } + planned[userType.Origin()] = identity + } + } + } + return planned, nil +} + // planningInputs returns the service attributes that can cause service types // to be emitted. Unused root types are deliberately excluded. func planningInputs(root *expr.RootExpr) []plannedAttribute { @@ -128,15 +176,18 @@ func planningInputs(root *expr.RootExpr) []plannedAttribute { // planUserTypes traverses attribute and declares each relocated user type in // the package selected by its own or its enclosing type's metadata. -func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { +func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, methodTypes map[expr.UserType]codegen.DerivedTypeID, seen map[plannedUserType]struct{}) error { if attribute == nil || attribute.Type == expr.Empty { return nil } recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { - return planUserTypes(attribute, service, location, generation, rootTypes, seen) + return planUserTypes(attribute, service, location, generation, rootTypes, methodTypes, seen) } switch actual := attribute.Type.(type) { case expr.UserType: + if _, normalized := methodTypes[actual.Origin()]; normalized { + return recurse(actual.Attribute(), location) + } declaredType := rootTypes.canonical(actual) typeLocation := codegen.UserTypeLocation(actual) if typeLocation == nil { @@ -273,11 +324,11 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) - resultDeclaration, err := serviceTypes.UserType(rootTypes.canonical(resultType)) + resultDeclaration, err := serviceTypes.Type(rootTypes.canonical(resultType)) if err != nil { return err } - if _, err := views.DeclareDerivedType(codegen.NewViewedResultTypeID(resultType), resultDeclaration.Name); err != nil { + if _, err := views.DeclareDerivedType(codegen.NewViewedResultTypeID(resultType), resultDeclaration.Name()); err != nil { return err } } diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 103ac00f10..cd4ce2be14 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -1,33 +1,75 @@ -// This file computes imports from the service expressions rendered into one -// generated Go file. Callers identify the file's package explicitly so a file -// never imports itself and imports from unrelated services cannot leak into it. +// This file plans one immutable import alias per complete package path, then +// computes the exact subset of those imports used by each generated service +// file. Qualified references and import declarations share these bindings. package service import ( + "fmt" "path" "sort" + "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) type ( + // importAliases is the frozen render-model binding from complete import paths + // to their unique Go qualifiers. + importAliases struct { + bindings map[string]importBinding + } + + // importBinding records the allocated qualifier and whether the import must + // spell it explicitly in a generated header. + importBinding struct { + name string + preferred string + explicit bool + } + + // importAliasCandidate records one package path before deterministic alias + // allocation. Fixed generator imports receive priority over design metadata. + importAliasCandidate struct { + preferred string + explicit bool + fixed bool + } + + // importAliasPlan collects all package paths before any render string is + // produced. + importAliasPlan struct { + candidates map[string]importAliasCandidate + } + // importCollector accumulates the imports referenced by one generated Go // file while traversing recursive service type definitions. importCollector struct { + aliases *importAliases genpkg string outputPackage string - importsByPath map[string]*codegen.ImportSpec + paths map[string]struct{} + legacy map[string]*codegen.ImportSpec } ) // AttributeImports returns the generated-type and struct:field:type imports -// referenced by attributes. Pass a named user type attribute when the file -// references that declaration. Pass the user type's underlying attribute when -// the file emits its definition. outputPackage is the full Go import path of -// the file receiving the imports. +// referenced by attributes using their preferred, unshared aliases. Transport +// generators retain this contract until their service-side references move to +// the declaration resolver in Task 5. func AttributeImports(genpkg, outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { - collector := newImportCollector(genpkg, outputPackage) + collector := newImportCollector(nil, genpkg, outputPackage) + for _, attribute := range attributes { + collector.collect(attribute) + } + return collector.imports() +} + +// AttributeImports returns the exact generated-type and metadata imports +// referenced by attributes using the frozen aliases shared with service type +// references. +func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { + collector := newImportCollector(d.aliases, d.generation.GenPkg, outputPackage) for _, attribute := range attributes { collector.collect(attribute) } @@ -53,13 +95,231 @@ func serviceReferenceAttributes(service *expr.ServiceExpr) []*expr.AttributeExpr return attributes } +// newImportAliases scans every participating design root plus the explicit +// root being rendered, then freezes deterministic aliases before service +// analysis creates type-reference strings. +func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { + plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} + if err := plan.addFixedImports(); err != nil { + return nil, err + } + seenRoots := make(map[*expr.RootExpr]struct{}, len(generation.Roots)+1) + for _, evaluated := range generation.Roots { + design, ok := evaluated.(*expr.RootExpr) + if !ok { + continue + } + seenRoots[design] = struct{}{} + if err := plan.addRoot(design, generation.GenPkg); err != nil { + return nil, err + } + } + if _, ok := seenRoots[root]; !ok { + if err := plan.addRoot(root, generation.GenPkg); err != nil { + return nil, err + } + } + return plan.freeze(), nil +} + +// addFixedImports reserves qualifiers used directly by service and view +// templates before metadata-selected packages compete for those names. +func (p *importAliasPlan) addFixedImports() error { + fixed := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("context"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaImport("security"), + } + for _, spec := range fixed { + if err := p.add(spec.Path, spec.Name, spec.Name != "", true); err != nil { + return err + } + } + return nil +} + +// addRoot collects generated package locations and metadata imports reachable +// from one complete service design. +func (p *importAliasPlan) addRoot(root *expr.RootExpr, genpkg string) error { + for _, service := range root.Services { + servicePath := servicePackagePath(genpkg, service) + serviceName := strings.ToLower(codegen.Goify(service.Name, false)) + if err := p.add(servicePath, serviceName, true, false); err != nil { + return err + } + if err := p.add(servicePath+"/views", serviceName+"views", true, false); err != nil { + return err + } + } + seen := make(map[expr.UserType]struct{}) + for _, userType := range root.Types { + if err := p.addAttribute(&expr.AttributeExpr{Type: userType}, genpkg, seen); err != nil { + return err + } + } + for _, resultType := range root.ResultTypes { + if err := p.addAttribute(&expr.AttributeExpr{Type: resultType}, genpkg, seen); err != nil { + return err + } + } + for _, service := range root.Services { + for _, attribute := range serviceReferenceAttributes(service) { + if err := p.addAttribute(attribute, genpkg, seen); err != nil { + return err + } + } + } + return nil +} + +// addAttribute recursively records every explicit generated location and +// struct:field:type import reachable from attribute. +func (p *importAliasPlan) addAttribute(attribute *expr.AttributeExpr, genpkg string, seen map[expr.UserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := codegen.GetMetaType(attribute); spec != nil { + if err := p.add(spec.Path, spec.Name, spec.Name != "", false); err != nil { + return err + } + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + if err := p.add( + path.Join(genpkg, location.RelImportPath), + location.PackageName(), + true, + false, + ); err != nil { + return err + } + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return p.addAttribute(actual.Attribute(), genpkg, seen) + case *expr.Object: + for _, named := range *actual { + if err := p.addAttribute(named.Attribute, genpkg, seen); err != nil { + return err + } + } + case *expr.Array: + return p.addAttribute(actual.ElemType, genpkg, seen) + case *expr.Map: + if err := p.addAttribute(actual.KeyType, genpkg, seen); err != nil { + return err + } + return p.addAttribute(actual.ElemType, genpkg, seen) + case *expr.Union: + for _, named := range actual.Values { + if err := p.addAttribute(named.Attribute, genpkg, seen); err != nil { + return err + } + } + } + return nil +} + +// add records one complete import path and rejects contradictory preferred +// package names before aliases are allocated. +func (p *importAliasPlan) add(importPath, preferred string, explicit, fixed bool) error { + if importPath == "" { + return nil + } + if preferred == "" { + preferred = path.Base(importPath) + } + if existing, ok := p.candidates[importPath]; ok { + if existing.preferred != preferred { + return fmt.Errorf( + "import path %q cannot use both package names %q and %q", + importPath, + existing.preferred, + preferred, + ) + } + existing.explicit = existing.explicit || explicit + existing.fixed = existing.fixed || fixed + p.candidates[importPath] = existing + return nil + } + p.candidates[importPath] = importAliasCandidate{ + preferred: preferred, + explicit: explicit, + fixed: fixed, + } + return nil +} + +// freeze allocates aliases in fixed-priority, full-path order and returns the +// immutable lookup used throughout rendering. +func (p *importAliasPlan) freeze() *importAliases { + paths := make([]string, 0, len(p.candidates)) + for importPath := range p.candidates { + paths = append(paths, importPath) + } + sort.Slice(paths, func(i, j int) bool { + left, right := p.candidates[paths[i]], p.candidates[paths[j]] + if left.fixed != right.fixed { + return left.fixed + } + return paths[i] < paths[j] + }) + scope := codegen.NewNameScope() + bindings := make(map[string]importBinding, len(paths)) + for _, importPath := range paths { + candidate := p.candidates[importPath] + bindings[importPath] = importBinding{ + name: scope.Unique(candidate.preferred), + preferred: candidate.preferred, + explicit: candidate.explicit, + } + } + scope.Freeze() + return &importAliases{bindings: bindings} +} + +// name returns the frozen qualifier for importPath and panics when rendering +// asks for a package that was absent from alias planning. +func (a *importAliases) name(importPath string) string { + binding, ok := a.bindings[importPath] + if !ok { + panic(fmt.Sprintf("import path %q has no planned alias", importPath)) + } + return binding.name +} + +// spec returns the frozen import declaration for importPath. +func (a *importAliases) spec(importPath string) *codegen.ImportSpec { + binding, ok := a.bindings[importPath] + if !ok { + panic(fmt.Sprintf("import path %q has no planned alias", importPath)) + } + spec := &codegen.ImportSpec{Path: importPath} + if binding.explicit || binding.name != binding.preferred { + spec.Name = binding.name + } + return spec +} + // newImportCollector creates a file-scoped collector that omits imports of the // package containing the generated file. -func newImportCollector(genpkg, outputPackage string) *importCollector { +func newImportCollector(aliases *importAliases, genpkg, outputPackage string) *importCollector { return &importCollector{ + aliases: aliases, genpkg: genpkg, outputPackage: outputPackage, - importsByPath: make(map[string]*codegen.ImportSpec), + paths: make(map[string]struct{}), + legacy: make(map[string]*codegen.ImportSpec), } } @@ -96,12 +356,12 @@ func (c *importCollector) addLocation(location *codegen.Location) { return } importPath := path.Join(c.genpkg, location.RelImportPath) - if importPath == c.outputPackage { - return - } - c.importsByPath[importPath] = &codegen.ImportSpec{ - Name: location.PackageName(), - Path: importPath, + if importPath != c.outputPackage { + c.paths[importPath] = struct{}{} + c.legacy[importPath] = &codegen.ImportSpec{ + Name: location.PackageName(), + Path: importPath, + } } } @@ -109,23 +369,27 @@ func (c *importCollector) addLocation(location *codegen.Location) { // the metadata refers to the package currently being emitted. func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { _, spec := codegen.GetMetaType(attribute) - if spec == nil || spec.Path == c.outputPackage { - return + if spec != nil && spec.Path != c.outputPackage { + c.paths[spec.Path] = struct{}{} + c.legacy[spec.Path] = spec } - c.importsByPath[spec.Path] = spec } // imports returns a deterministic snapshot of the packages collected for one // generated file. func (c *importCollector) imports() []*codegen.ImportSpec { - paths := make([]string, 0, len(c.importsByPath)) - for importPath := range c.importsByPath { + paths := make([]string, 0, len(c.paths)) + for importPath := range c.paths { paths = append(paths, importPath) } sort.Strings(paths) imports := make([]*codegen.ImportSpec, len(paths)) for i, importPath := range paths { - imports[i] = c.importsByPath[importPath] + if c.aliases != nil { + imports[i] = c.aliases.spec(importPath) + continue + } + imports[i] = c.legacy[importPath] } return imports } diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go new file mode 100644 index 0000000000..951a85cd73 --- /dev/null +++ b/codegen/service/imports_test.go @@ -0,0 +1,121 @@ +// This file verifies that service import subsets and qualified references use +// one deterministic full-path alias binding across every render analysis. +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestImportAliasesIncludeExplicitAnalysisRoot verifies that plugin-local +// analysis sees imports from the root passed to NewServicesData even when that +// root is not listed in the generation's evaluated roots. +func TestImportAliasesIncludeExplicitAnalysisRoot(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "example.com/local/shared", "shared") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) + + services, err := NewServicesData(root, generation) + require.NoError(t, err) + aliases := services.aliases + require.Equal(t, "shared", aliases.name("example.com/local/shared")) + require.Equal(t, &codegen.ImportSpec{ + Name: "shared", + Path: "example.com/local/shared", + }, aliases.spec("example.com/local/shared")) +} + +// TestImportAliasesReserveFixedJSON verifies that the union codec's +// encoding/json qualifier wins before a metadata package requests the same +// preferred name. +func TestImportAliasesReserveFixedJSON(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "example.com/custom/json", "json") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := codegen.NewGeneration("generated.local/gen", nil) + + aliases, err := newImportAliases(root, generation) + require.NoError(t, err) + require.Equal(t, "json", aliases.name("encoding/json")) + require.Equal(t, "json2", aliases.name("example.com/custom/json")) +} + +// TestUnionFieldReferencesUseFixedImportAliases verifies that the qualifier in +// a union field type and the import declaration come from the same frozen path +// binding when encoding/json already owns the preferred json name. +func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { + plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} + require.NoError(t, plan.addFixedImports()) + require.NoError(t, plan.add("generated.local/gen/values", "values", true, false)) + require.NoError(t, plan.add("example.com/custom/json", "json", true, false)) + aliases := plan.freeze() + service := &expr.ServiceExpr{Name: "Values"} + branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ + "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, + }} + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{{ + Name: "external", + Attribute: branch, + }}, + } + generation := codegen.NewGeneration("generated.local/gen", nil) + generatedPackage := generation.GeneratedPackage("generated.local/gen/values") + _, err := generatedPackage.DeclareUnion(union) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + declaration, err := generatedPackage.Union(union) + require.NoError(t, err) + data, err := buildUnionTypeData( + union, + declaration, + newServiceResolver(generation, aliases, service, "generated.local/gen/values"), + nil, + false, + func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { + return generatedPackage.UnionBranch(union, branch.Name) + }, + ) + require.NoError(t, err) + require.Equal(t, "json2.Value", data.Fields[0].FieldType) + + collector := newImportCollector(aliases, generation.GenPkg, "generated.local/gen/values") + collector.collect(branch) + header := codegen.Header( + "Union types", + "values", + append([]*codegen.ImportSpec{codegen.SimpleImport("encoding/json")}, collector.imports()...), + ) + var rendered strings.Builder + require.NoError(t, header.Write(&rendered)) + require.Contains(t, rendered.String(), `"encoding/json"`) + require.Contains(t, rendered.String(), `json2 "example.com/custom/json"`) +} diff --git a/codegen/service/service.go b/codegen/service/service.go index 602a98f9a3..761e17961f 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -168,6 +168,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa } outputPackage := genpkg + "/" + svcName attributes := serviceReferenceAttributes(service) + attributes = append(attributes, normalizedMethodDefinitions(service)...) for _, userType := range svc.userTypes { if userType.Loc == nil { attributes = append(attributes, userType.Type.Attribute()) @@ -178,7 +179,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa attributes = append(attributes, errorType.Type.Attribute()) } } - imports = append(imports, AttributeImports(genpkg, outputPackage, attributes...)...) + imports = append(imports, services.AttributeImports(outputPackage, attributes...)...) header := codegen.Header(service.Name+" service", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "service", @@ -207,10 +208,52 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa return append(files, InterceptorsFiles(genpkg, service, services)...) } +// normalizedMethodDefinitions returns the underlying object definitions +// emitted for semantic method wrappers in service.go. Their nested references +// contribute imports even though endpoint and client files stop at the wrapper +// declaration. +func normalizedMethodDefinitions(service *expr.ServiceExpr) []*expr.AttributeExpr { + var definitions []*expr.AttributeExpr + for _, method := range service.Methods { + definitions = append(definitions, normalizedMethodDefinitionsFor(method)...) + } + return definitions +} + +// normalizedMethodDefinitionsFor returns the raw object definitions emitted +// for one method so service.go imports their nested references. +func normalizedMethodDefinitionsFor(method *expr.MethodExpr) []*expr.AttributeExpr { + var definitions []*expr.AttributeExpr + definitions = appendNormalizedMethodDefinition(definitions, method, method.Payload, "Payload") + definitions = appendNormalizedMethodDefinition(definitions, method, method.StreamingPayload, "StreamingPayload") + definitions = appendNormalizedMethodDefinition(definitions, method, method.Result, "Result") + if method.HasMixedResults() { + definitions = appendNormalizedMethodDefinition(definitions, method, method.StreamingResult, "StreamingResult") + } + return definitions +} + +// appendNormalizedMethodDefinition appends the underlying object only when +// attribute is the semantic wrapper created for the requested method role. +func appendNormalizedMethodDefinition(definitions []*expr.AttributeExpr, method *expr.MethodExpr, attribute *expr.AttributeExpr, suffix string) []*expr.AttributeExpr { + if attribute == nil { + return definitions + } + userType, ok := attribute.Type.(expr.UserType) + if !ok || userType.ID() != normalizedMethodTypeID(method.Service, method, suffix) { + return definitions + } + return append(definitions, userType.Attribute()) +} + // generatedPackageFiles renders each relocated user type in its configured // file and one sorted unions.go for every package that owns unions. func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.File { packages := aggregateGeneratedPackages(analyses) + if len(packages) == 0 { + return nil + } + aliases := analyses[0].aliases packagePaths := make([]string, 0, len(packages)) for packagePath := range packages { packagePaths = append(packagePaths, packagePath) @@ -233,13 +276,13 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F for _, filePath := range filePaths { generatedTypes := typesByFile[filePath] sort.Slice(generatedTypes, func(i, j int) bool { - return generatedTypes[i].declaration.Name < generatedTypes[j].declaration.Name + return generatedTypes[i].declaration.Name() < generatedTypes[j].declaration.Name() }) imports := []*codegen.ImportSpec{ codegen.SimpleImport("fmt"), codegen.GoaImport(""), } - collector := newImportCollector(genpkg, packagePath) + collector := newImportCollector(aliases, genpkg, packagePath) for _, generatedType := range generatedTypes { collector.collect(generatedType.userType.Attribute()) } @@ -270,14 +313,11 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F codegen.SimpleImport("fmt"), codegen.GoaImport(""), } - collector := newImportCollector(genpkg, packagePath) + collector := newImportCollector(aliases, genpkg, packagePath) for _, union := range unions { - for _, named := range union.source.Values { - if userType, ok := named.Attribute.Type.(expr.UserType); ok && codegen.UserTypeLocation(userType) == nil { - collector.collect(userType.Attribute()) - continue - } - collector.collect(named.Attribute) + for _, field := range union.Fields { + collector.collect(field.reference) + collector.collect(field.definition) } } imports = append(imports, collector.imports()...) diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index c50c8ffe7c..cfdd15ae3a 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -40,6 +40,7 @@ type ( Services map[string]*Data generation *codegen.Generation + aliases *importAliases packages map[string]*generatedPackageData rootTypes *rootTypeSet } @@ -123,6 +124,9 @@ type ( PayloadDef string // PayloadRef is a reference to the payload type if any, PayloadRef string + // PayloadDeclaration is the immutable generated declaration for a named + // payload type. It is nil for primitive payloads. + PayloadDeclaration *codegen.TypeDeclaration // PayloadDesc is the payload type description if any. PayloadDesc string // PayloadEx is an example of a valid payload value. @@ -135,6 +139,9 @@ type ( StreamingPayloadDef string // StreamingPayloadRef is a reference to the streaming payload type if any. StreamingPayloadRef string + // StreamingPayloadDeclaration is the immutable generated declaration for + // a named streaming payload type. It is nil for primitive payloads. + StreamingPayloadDeclaration *codegen.TypeDeclaration // StreamingPayloadDesc is the streaming payload type description if any. StreamingPayloadDesc string // StreamingPayloadEx is an example of a valid streaming payload value. @@ -145,6 +152,9 @@ type ( StreamingResultDef string // StreamingResultRef is the reference to the streaming result type if any. StreamingResultRef string + // StreamingResultDeclaration is the immutable generated declaration for a + // named streaming result type. It is nil for primitive results. + StreamingResultDeclaration *codegen.TypeDeclaration // StreamingResultDesc is the streaming result type description if any. StreamingResultDesc string // StreamingResultEx is an example of a valid streaming result value. @@ -158,6 +168,9 @@ type ( ResultDef string // ResultRef is the reference to the result type if any. ResultRef string + // ResultDeclaration is the immutable generated declaration for a named + // result type. It is nil for primitive results. + ResultDeclaration *codegen.TypeDeclaration // ResultDesc is the result type description if any. ResultDesc string // ResultEx is an example of a valid result value. @@ -422,8 +435,8 @@ type ( // UserTypeData contains the data describing a user-defined type. UserTypeData struct { - // Declaration is the generated-package record for a relocated type. It - // is nil for a type emitted in its service package or views package. + // Declaration is the immutable generated-package record that owns this + // type in a service, views, or relocated package. Declaration *codegen.TypeDeclaration // Name is the type name. Name string @@ -444,8 +457,8 @@ type ( // UnionTypeData describes a generated sum-type union for a service. UnionTypeData struct { - // Declaration is the generated-package record for a relocated union. It - // is nil for a union emitted in its service package or views package. + // Declaration is the immutable generated-package record that owns this + // union in a service, views, or relocated package. Declaration *codegen.UnionDeclaration // Name is the Go type name of the union struct. Name string @@ -460,8 +473,6 @@ type ( TypeKey string // ValueKey is the value field name for JSON marshaling (defaults to "value"). ValueKey string - - source *expr.Union } // UnionFieldData describes a single branch of a union. @@ -487,6 +498,9 @@ type ( PrimitiveAliasType string // TypeTag is the JSON "type" discriminator value for this branch. TypeTag string + + reference *expr.AttributeExpr + definition *expr.AttributeExpr } // SchemeData describes a single security scheme. @@ -681,10 +695,15 @@ type ( // NewServicesData analyzes root using declarations frozen by generation. // Call Plan for every participating root and freeze generation first. func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*ServicesData, error) { + aliases, err := newImportAliases(root, generation) + if err != nil { + return nil, err + } data := &ServicesData{ Root: root, Services: make(map[string]*Data), generation: generation, + aliases: aliases, packages: make(map[string]*generatedPackageData), rootTypes: newRootTypeSet(root), } @@ -809,6 +828,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) serviceResolver := newServiceResolver( d.generation, + d.aliases, service, servicePackagePath(d.generation.GenPkg, service), ) @@ -881,7 +901,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { identity := codegen.NewProjectedTypeID(pair.source) viewDerived[pair.projected.Origin()] = identity } - viewResolver := newViewResolver(d.generation, service, viewDerived) + viewResolver := newViewResolver(d.generation, d.aliases, service, viewDerived) for _, pair := range pairs { identity := codegen.NewProjectedTypeID(pair.source) declaration, err := views.DerivedType(identity) @@ -1004,7 +1024,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projAtt, viewspkg, serviceResolver, - newViewResolver(d.generation, service, viewDerived), + newViewResolver(d.generation, d.aliases, service, viewDerived), viewedDeclaration, ) found := false @@ -1195,7 +1215,7 @@ func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.Servic data = append(data, &UserTypeData{ Declaration: declaration, Name: dt.Name(), - VarName: declaration.Name, + VarName: declaration.Name(), Description: dt.Attribute().Description, Def: definitionResolver.Def(dt.Attribute(), false, true), Ref: definitionResolver.Ref(at, ""), @@ -1343,28 +1363,33 @@ func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, at primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) _, isUserType := nat.Attribute.Type.(expr.UserType) emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && attributor.Package(nat.Attribute) == "" + var definition *expr.AttributeExpr + if _, emitsAlias := branchDeclaration.Type(); emitsAlias { + definition = nat.Attribute.Type.(expr.UserType).Attribute() + } fields[i] = &UnionFieldData{ Name: nat.Name, - KindConst: branchDeclaration.KindConst, - Constructor: branchDeclaration.Constructor, + KindConst: branchDeclaration.KindConst(), + Constructor: branchDeclaration.Constructor(), FieldName: fieldName, FieldType: fieldType, Nilable: codegen.IsNilable(nat.Attribute.Type), EmitPrimitiveAlias: emitPrimitiveAlias, PrimitiveAliasType: primitiveAliasType, TypeTag: nat.Name, + reference: nat.Attribute, + definition: definition, } } return &UnionTypeData{ Declaration: declaration, - Name: declaration.Name, - KindName: declaration.KindName, + Name: declaration.Name(), + KindName: declaration.KindName(), Fields: fields, Loc: loc, TypeKey: u.GetTypeKey(), ValueKey: u.GetValueKey(), - source: u, }, nil } @@ -1410,11 +1435,25 @@ func serviceTypeData(attribute *expr.AttributeExpr, resolver *declarationResolve entered := resolver.Enter(attribute).(*declarationResolver) declaration := entered.userType(entered.currentPath, userType) definitionResolver := entered.inOutputPackage(entered.currentPath) - return declaration.Name, + return declaration.Name(), definitionResolver.Def(userType.Attribute(), false, true), resolver.Ref(attribute, "") } +// serviceTypeDeclaration returns the frozen declaration for a named method +// type. Primitive method types do not own generated declarations. +func serviceTypeDeclaration(attribute *expr.AttributeExpr, resolver *declarationResolver) *codegen.TypeDeclaration { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + entered := resolver.Enter(attribute).(*declarationResolver) + return entered.userType(entered.currentPath, userType) +} + // buildErrorInitData creates the data needed to generate code around endpoint error return values. func buildErrorInitData(er *expr.ErrorExpr, resolver *declarationResolver) *ErrorInitData { _, temporary := er.Meta["goa:error:temporary"] @@ -1547,6 +1586,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc PayloadLoc: payloadLoc, PayloadDef: payloadDef, PayloadRef: payloadRef, + PayloadDeclaration: serviceTypeDeclaration(m.Payload, resolver), PayloadDesc: payloadDesc, PayloadEx: payloadEx, PayloadDefault: m.Payload.DefaultValue, @@ -1554,6 +1594,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc ResultLoc: resultLoc, ResultDef: resultDef, ResultRef: resultRef, + ResultDeclaration: serviceTypeDeclaration(m.Result, resolver), ResultDesc: resultDesc, ResultEx: resultEx, Errors: errors, @@ -1597,6 +1638,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam srname, data.StreamingResultDef, srref = serviceTypeData(m.StreamingResult, resolver) data.StreamingResult = srname data.StreamingResultRef = srref + data.StreamingResultDeclaration = serviceTypeDeclaration(m.StreamingResult, resolver) data.StreamingResultDesc = m.StreamingResult.Description if data.StreamingResultDesc == "" { data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", @@ -1607,6 +1649,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { spayloadName, spayloadDef, spayloadRef = serviceTypeData(m.StreamingPayload, resolver) + data.StreamingPayloadDeclaration = serviceTypeDeclaration(m.StreamingPayload, resolver) spayloadDesc = m.StreamingPayload.Description if spayloadDesc == "" { spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", @@ -2045,7 +2088,7 @@ func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, ser typeInits []*InitData views []*ViewData - varname = declaration.Name + varname = declaration.Name() pt = projected.Type.(expr.UserType) ) if _, isrt := pt.(*expr.ResultTypeExpr); isrt { @@ -2107,7 +2150,7 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, viewName = v } projectedDeclaration := viewResolver.userType(viewResolver.currentPath, projected.Type.(expr.UserType)) - views := buildViews(rt, declaration.Name) + views := buildViews(rt, declaration.Name()) // build validation data resvar, _, serviceRef := serviceTypeData(att, serviceResolver) @@ -2115,7 +2158,7 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, wrapperResolver := viewResolver.bindDerived(projT, codegen.NewViewedResultTypeID(rt)) resref := wrapperResolver.refDeclaration(declaration, att.Type) data := map[string]any{ - "Projected": projectedDeclaration.Name, + "Projected": projectedDeclaration.Name(), "ArgVar": "result", "Source": "result", "Views": views, @@ -2144,7 +2187,7 @@ func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, "ReturnTypeRef": vresref, "IsCollection": isarr, "TargetType": serviceViewResolver.Name(&expr.AttributeExpr{Type: projT}, "", false, true), - "InitName": "new" + projectedDeclaration.Name, + "InitName": "new" + projectedDeclaration.Name(), } buf = &bytes.Buffer{} if err := initTypeCodeTmpl.Execute(buf, data); err != nil { @@ -2277,7 +2320,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, v } wname := projected.Type.Name() if toResult { - wname = projectedDeclaration.Name + wname = projectedDeclaration.Name() } // viewed is the projected type narrowed down to the view attributes. viewed := &expr.AttributeExpr{ @@ -2321,7 +2364,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, v elementInit = serviceElementResolver.userType( serviceElementResolver.currentPath, serviceElement.Type.(expr.UserType), - ).Name + ).Name() } code, helpers := buildConstructorCode( viewed, @@ -2345,7 +2388,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, v } else { srcCtx := declarationContext(serviceResolver, false) tgtCtx := declarationContext(viewedResolver, true) - tname := projectedDeclaration.Name + tname := projectedDeclaration.Name() name := "new" + tname if view.Name != expr.DefaultView { name += codegen.Goify(view.Name, true) @@ -2353,7 +2396,7 @@ func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, v elementInit := "" if parr != nil { projectedElement := parr.ElemType.Type.(expr.UserType) - elementInit = viewResolver.userType(viewResolver.currentPath, projectedElement).Name + elementInit = viewResolver.userType(viewResolver.currentPath, projectedElement).Name() } code, helpers := buildConstructorCode( att, diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index ff27715c9e..0ec5aa1409 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -24,7 +24,12 @@ func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { data, err := buildUnionTypeData( union, declaration, - newServiceResolver(generation, &expr.ServiceExpr{Name: "service"}, "gen/service"), + newServiceResolver( + generation, + aliasesForTest(t, "gen/service"), + &expr.ServiceExpr{Name: "service"}, + "gen/service", + ), &codegen.Location{RelImportPath: "gen/service"}, false, func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index c47756e89a..c9e901871d 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -57,14 +57,15 @@ func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { loc := &codegen.Location{ RelImportPath: "gen/service", } - forwardNames := collectServiceUnionTypeNames(forward, loc) - reverseNames := collectServiceUnionTypeNames(reverse, loc) + forwardNames := collectServiceUnionTypeNames(t, forward, loc) + reverseNames := collectServiceUnionTypeNames(t, reverse, loc) require.Len(t, forwardNames, 2) require.Equal(t, forwardNames, reverseNames) } -func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location) map[string]string { +func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *codegen.Location) map[string]string { + t.Helper() service := &expr.ServiceExpr{Name: "test"} generation := codegen.NewGeneration("generated.local/gen", nil) generatedPackage := generation.GeneratedPackage( @@ -82,11 +83,17 @@ func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location } services := &ServicesData{ generation: generation, + aliases: aliasesForTest(t, generatedPackagePath(generation.GenPkg, service, loc)), packages: make(map[string]*generatedPackageData), } seen := make(map[expr.UserType]struct{}) unionByHash := make(map[unionDataKey]*UnionTypeData) - resolver := newServiceResolver(generation, service, generatedPackagePath(generation.GenPkg, service, loc)) + resolver := newServiceResolver( + generation, + services.aliases, + service, + generatedPackagePath(generation.GenPkg, service, loc), + ) if err := services.collectUnionTypes(att, service, resolver, loc, unionByHash, seen, false); err != nil { panic(err) } diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 950b463248..17b127db7e 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -72,6 +72,38 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { require.ErrorContains(t, err, "frozen") } +// TestPlanOwnsNormalizedMethodNames verifies that semantic wrappers receive +// names from the service package catalog and collide only with local exact +// declarations. +func TestPlanOwnsNormalizedMethodNames(t *testing.T) { + var local expr.UserType + root := codegen.RunDSL(t, func() { + local = dsl.Type("UsePayload", func() { + dsl.Attribute("existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + codegen.NormalizeRoot(root) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) + + service := root.Service("Values") + wrapper := service.Method("Use").Payload.Type.(expr.UserType) + declaration, err := generation.GeneratedPackage("generated.local/gen/values").Type(wrapper) + require.NoError(t, err) + require.Equal(t, "UsePayload2", declaration.Name()) +} + // TestServicesDataUsesRebuiltViewDeclarations verifies that planning and // rendering can rebuild view expressions while sharing frozen declarations. func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { @@ -107,8 +139,8 @@ func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { require.Len(t, service.viewedResultTypes, 1) require.Same(t, plannedProjected, service.projectedTypes[0].Declaration) require.Same(t, plannedViewed, service.viewedResultTypes[0].Declaration) - require.Equal(t, "ValueView", plannedProjected.Name) - require.Equal(t, "Value", plannedViewed.Name) + require.Equal(t, "ValueView", plannedProjected.Name()) + require.Equal(t, "Value", plannedViewed.Name()) } func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { @@ -255,8 +287,8 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T require.NotSame(t, exactDeclaration, branchDeclaration) require.NoError(t, generation.Freeze()) - require.Equal(t, "ValueText", exactDeclaration.Name) - require.Equal(t, "ValueText2", branchDeclaration.Name) + require.Equal(t, "ValueText", exactDeclaration.Name()) + require.Equal(t, "ValueText2", branchDeclaration.Name()) services, err := NewServicesData(root, generation) require.NoError(t, err) typeFile := findFile( diff --git a/codegen/service/views.go b/codegen/service/views.go index 7b00bb7a6e..401ab5a385 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -33,7 +33,7 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) unionByHash := make(map[unionDataKey]*UnionTypeData) seenUnions := make(map[expr.UserType]struct{}) viewLoc := &codegen.Location{RelImportPath: "views"} - resolver := newViewResolver(services.generation, service, svc.viewDerived) + resolver := newViewResolver(services.generation, services.aliases, service, svc.viewDerived) for _, t := range svc.projectedTypes { if err := services.collectUnionTypes( &expr.AttributeExpr{Type: t.Type}, @@ -75,7 +75,7 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) for _, projected := range svc.projectedTypes { attributes = append(attributes, projected.Type.Attribute()) } - imports = append(imports, AttributeImports(genpkg, outputPackage, attributes...)...) + imports = append(imports, services.AttributeImports(outputPackage, attributes...)...) header := codegen.Header(service.Name+" views", "views", imports) sections := []*codegen.SectionTemplate{header} diff --git a/codegen/validation.go b/codegen/validation.go index 30c3536429..ce1a5bab40 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -96,9 +96,9 @@ func ValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *Attribut return recurseValidationCode(att, put, attCtx, req, alias, view, target, target, nil).String() } -func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, context string, seen map[string]*bytes.Buffer) *bytes.Buffer { +func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, context string, seen map[expr.UserType]*bytes.Buffer) *bytes.Buffer { if seen == nil { - seen = make(map[string]*bytes.Buffer) + seen = make(map[expr.UserType]*bytes.Buffer) } var ( buf = new(bytes.Buffer) @@ -111,10 +111,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // so alias types shouldn't use the recursion guard. Only non-alias user // types need cycle protection. if isUT && !alias { - if buf, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if buf, ok := seen[origin]; ok { return buf } - seen[ut.ID()] = buf + seen[origin] = buf } newline := func() { @@ -263,7 +264,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A return buf } -func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[string]*bytes.Buffer) string { +func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { ut, isUT := att.Type.(expr.UserType) if !isUT { code := recurseValidationCode(att, put, ctx, req, false, view, target, context, seen).String() diff --git a/codegen/validation_test.go b/codegen/validation_test.go index 0c373a3e7e..9f4375c318 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -1,6 +1,9 @@ +// This file verifies generated validation code for nested attributes, user +// types, unions, and declaration origins. package codegen import ( + "bytes" "strings" "testing" @@ -131,6 +134,29 @@ func TestRecursiveValidationWithCycleGuard(t *testing.T) { } } +// TestRecursiveValidationDistinguishesEqualUIDOrigins verifies that compiler +// copies share recursion state only through their exact declaration origin; +// unrelated user types with the same semantic UID receive distinct buffers. +func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, + TypeName: "Second", + UID: "shared", + } + ctx := NewAttributeContext(false, false, false, "", NewNameScope()) + seen := make(map[expr.UserType]*bytes.Buffer) + + firstBuffer := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", "first", seen) + secondBuffer := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", "second", seen) + require.NotSame(t, firstBuffer, secondBuffer) + require.Len(t, seen, 2) +} + // TestMultipleAliasTypesInSameStruct tests that multiple fields with the same // alias type can be validated independently. Previously, the recursion guard // would incorrectly block validation of the second field. diff --git a/expr/dup.go b/expr/dup.go index e396adf408..77b5c4f2e4 100644 --- a/expr/dup.go +++ b/expr/dup.go @@ -1,3 +1,5 @@ +// This file copies design data types while preserving declaration provenance, +// so compiler-created graphs can still resolve their original generated names. package expr import ( diff --git a/expr/result_type.go b/expr/result_type.go index c5f3a98f19..5188161b23 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -1,3 +1,5 @@ +// This file defines result types and views, including the declaration origin +// retained when code generation rebuilds projected result graphs. package expr import ( diff --git a/expr/types.go b/expr/types.go index 21b2a833fb..d7f17657ff 100644 --- a/expr/types.go +++ b/expr/types.go @@ -1,3 +1,5 @@ +// This file defines Goa's core design data types and the structural operations +// used by evaluation, validation, and code generation. package expr import ( diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index fb3f0fd690..b2fd99c0c8 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -1,3 +1,5 @@ +// This file analyzes gRPC endpoint designs into the immutable data consumed by +// protobuf message, client, server, conversion, and validation templates. package codegen import ( @@ -583,12 +585,12 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { ) md := svc.Method(e.Name()) if e.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Payload, - md.PayloadLoc.PackageNameOrDefault(svc.PkgName)) + pkg := md.PayloadLoc.PackageNameOrDefault(svc.PkgName) + payloadRef = methodTypeRef(e.MethodExpr.Payload, md.PayloadDeclaration, pkg, svc.Scope) } if e.MethodExpr.Result.Type != expr.Empty { - resultRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Result, - md.ResultLoc.PackageNameOrDefault(svc.PkgName)) + pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) + resultRef = methodTypeRef(e.MethodExpr.Result, md.ResultDeclaration, pkg, svc.Scope) } if md.ViewedResult != nil { viewedResultRef = md.ViewedResult.FullRef @@ -959,8 +961,9 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE } svc := sd.Service - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) + method := svc.Method(e.MethodExpr.Name) + pkg := method.PayloadLoc.PackageNameOrDefault(svc.PkgName) + svcCtx := methodTypeContext(payload, method.PayloadDeclaration, pkg, svc.Scope) if svr { // server side data := d.buildInitData(request, payload, "message", "v", svcCtx, false, false, sd) @@ -971,8 +974,8 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE return &ConvertData{ SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), Init: data, Validation: addValidation(request, "message", sd, true), } @@ -982,8 +985,8 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, false, sd) data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ - SrcName: svc.Scope.GoFullTypeName(payload, pkg), - SrcRef: svc.Scope.GoFullTypeRef(payload, pkg), + SrcName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + SrcRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), Init: data, @@ -1020,15 +1023,16 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi Metadata: md, } if expr.IsObject(payload.Type) { - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) + method := svc.Method(e.MethodExpr.Name) + pkg := method.PayloadLoc.PackageNameOrDefault(svc.PkgName) + svcCtx := methodTypeContext(payload, method.PayloadDeclaration, pkg, svc.Scope) init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, false, sd) init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) init.Args = append(init.Args, initArgsFromMetadata(md)...) data.ServerConvert = &ConvertData{ - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), Init: init, } } @@ -1476,6 +1480,31 @@ func serviceTypeContext(pkg string, scope *codegen.NameScope) *codegen.Attribute return codegen.NewAttributeContext(false, false, true, pkg, scope) } +// methodTypeContext binds a named method wrapper to its frozen declaration and +// preserves the existing service scope for primitive method types. +func methodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { + if declaration == nil { + return serviceTypeContext(pkg, scope) + } + return service.NewMethodTypeContext(attribute, declaration, pkg, scope) +} + +// methodTypeRef returns the frozen reference for a named method type and +// preserves the existing spelling for primitive method types. +func methodTypeRef(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { + if declaration == nil { + return scope.GoFullTypeRef(attribute, pkg) + } + name := declaration.Name() + if pkg != "" { + name = pkg + "." + name + } + if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { + return "*" + name + } + return name +} + // resultContext returns the method result attribute and the result context for the given // endpoint. func resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData) (*expr.AttributeExpr, *codegen.AttributeContext) { @@ -1487,7 +1516,7 @@ func resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData) (*expr.AttributeEx return vresAtt, codegen.NewAttributeContext(true, false, true, svc.ViewsPkg, svc.ViewScope) } pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) - return e.MethodExpr.Result, serviceTypeContext(pkg, svc.Scope) + return e.MethodExpr.Result, methodTypeContext(e.MethodExpr.Result, md.ResultDeclaration, pkg, svc.Scope) } // getPrimitive returns the primitive expression if the given expression is an alias to one diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 174a588134..fdfb52a63c 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1,3 +1,5 @@ +// This file analyzes HTTP endpoint designs into the immutable data consumed by +// HTTP client, server, body, validation, and streaming templates. package codegen import ( @@ -909,7 +911,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } pkg = method.PayloadLoc.PackageNameOrDefault(svc.PkgName) if len(routes[0].PathInit.ClientArgs) > 0 && httpEndpoint.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(httpEndpoint.MethodExpr.Payload, pkg) + payloadRef = methodTypeRef(httpEndpoint.MethodExpr.Payload, method.PayloadDeclaration, pkg, svc.Scope) } } data := map[string]any{ @@ -1208,7 +1210,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD httpsvrctx = httpContext(sd.Scope, true, true) httpclictx = httpContext(sd.Scope, true, false) pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + svcctx = methodTypeContext(payload, ep.PayloadDeclaration, pkg, svc.Scope) request *RequestData mapQueryParam *ParamData @@ -1339,7 +1341,11 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // Raw payload object has type name prefixed with endpoint name. No need to // prefix the type name again. if strings.HasPrefix(p, n) { - p = svc.Scope.HashedUnique(payload.Type, p) + if ep.PayloadDeclaration != nil { + p = ep.PayloadDeclaration.Name() + } else { + p = svc.Scope.HashedUnique(payload.Type, p) + } name = fmt.Sprintf("New%s", p) } else { name = fmt.Sprintf("New%s%s", n, p) @@ -1533,8 +1539,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ServerArgs: serverArgs, ClientArgs: clientArgs, CLIArgs: cliArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(payload, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(payload, pkg), + ReturnTypeName: methodTypeName(payload, ep.PayloadDeclaration, pkg, svc.Scope), + ReturnTypeRef: methodTypeRef(payload, ep.PayloadDeclaration, pkg, svc.Scope), ReturnIsStruct: isObject, ReturnTypeAttribute: codegen.Goify(origin, true), ReturnTypePkg: pkg, @@ -1551,8 +1557,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ref string ) if payload.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(payload, pkg) - ref = svc.Scope.GoFullTypeRef(payload, pkg) + name = methodTypeName(payload, ep.PayloadDeclaration, pkg, svc.Scope) + ref = methodTypeRef(payload, ep.PayloadDeclaration, pkg, svc.Scope) } if init == nil { if o := expr.AsObject(e.Params.Type); o != nil && len(*o) > 0 { @@ -1604,8 +1610,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa view = v } if result.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(result, pkg) - ref = svc.Scope.GoFullTypeRef(result, pkg) + name = methodTypeName(result, ep.ResultDeclaration, pkg, svc.Scope) + ref = methodTypeRef(result, ep.ResultDeclaration, pkg, svc.Scope) } var ( @@ -1666,7 +1672,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A pkg = md.ResultLoc.PackageNameOrDefault(svc.PkgName) httpclictx = httpContext(sd.Scope, false, false) scope = svc.Scope - svcctx = serviceContext(pkg, sd.Service.Scope) + svcctx = methodTypeContext(result, md.ResultDeclaration, pkg, svc.Scope) ) { if viewed { @@ -1779,8 +1785,8 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A helpers []*codegen.TransformFunctionData ) { - tname = svc.Scope.GoFullTypeName(result, pkg) - tref = svc.Scope.GoFullTypeRef(result, pkg) + tname = methodTypeName(result, md.ResultDeclaration, pkg, svc.Scope) + tref = methodTypeRef(result, md.ResultDeclaration, pkg, svc.Scope) if viewed { tname = svc.ViewScope.GoFullTypeName(result, svc.ViewsPkg) tref = svc.ViewScope.GoFullTypeRef(result, svc.ViewsPkg) @@ -1791,7 +1797,11 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // Raw result object has type name prefixed with endpoint name. No need to // prefix the type name again. if strings.HasPrefix(r, n) { - r = scope.HashedUnique(result.Type, r) + if md.ResultDeclaration != nil { + r = md.ResultDeclaration.Name() + } else { + r = scope.HashedUnique(result.Type, r) + } name = fmt.Sprintf("New%s%s", r, status) } else { name = fmt.Sprintf("New%s%s%s", n, r, status) @@ -2129,9 +2139,9 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * svc = sd.Service httpctx = httpContext(sd.Scope, true, svr) - ep = sd.Service.Method(e.Name()) - pkg = ep.PayloadLoc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + ep = svc.Method(e.Name()) + pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) + svcctx = methodTypeContext(att, methodTypeDeclaration(e.MethodExpr, ep, att), pkg, svc.Scope) ) name = body.Type.Name() ref = sd.Scope.GoTypeRef(body) @@ -2202,7 +2212,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * AttributeData: &AttributeData{ Name: "payload", VarName: sourceVar, - TypeRef: svc.Scope.GoFullTypeRef(att, pkg), + TypeRef: methodTypeRef(att, methodTypeDeclaration(e.MethodExpr, ep, att), pkg, svc.Scope), Type: att.Type, Validate: validateDef, Example: att.Example(sds.Root.API.ExampleGenerator), @@ -2258,8 +2268,9 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo svc = sd.Service httpctx = httpContext(sd.Scope, false, svr) - pkg = loc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + pkg = loc.PackageNameOrDefault(svc.PkgName) + method = svc.Method(e.Name()) + svcctx = methodTypeContext(att, methodTypeDeclaration(e.MethodExpr, method, att), pkg, svc.Scope) ) // Project the response body when the design fixes the response to a single // view so the generated transport code uses the effective wire shape. @@ -2399,7 +2410,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo if view != nil { ref += ".Projected" } - tref := svc.Scope.GoFullTypeRef(att, pkg) + tref := methodTypeRef(att, methodTypeDeclaration(e.MethodExpr, method, att), pkg, svc.Scope) if view != nil { tref = svc.ViewScope.GoFullTypeRef(att, svc.ViewsPkg) } @@ -2883,6 +2894,72 @@ func serviceContext(pkg string, scope *codegen.NameScope) *codegen.AttributeCont return codegen.NewAttributeContext(false, false, true, pkg, scope) } +// methodTypeContext binds a named method wrapper to its frozen declaration and +// preserves the existing service scope for primitive method types. +func methodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { + if declaration == nil { + return serviceContext(pkg, scope) + } + return service.NewMethodTypeContext(attribute, declaration, pkg, scope) +} + +// methodTypeName returns the frozen name for a named method type and preserves +// the existing spelling for primitive method types. +func methodTypeName(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { + if declaration == nil { + return scope.GoFullTypeName(attribute, pkg) + } + if pkg == "" { + return declaration.Name() + } + return pkg + "." + declaration.Name() +} + +// methodTypeRef returns the frozen reference for a named method type and +// preserves the existing spelling for primitive method types. +func methodTypeRef(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { + if declaration == nil { + return scope.GoFullTypeRef(attribute, pkg) + } + name := methodTypeName(attribute, declaration, pkg, scope) + if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { + return "*" + name + } + return name +} + +// methodTypeDeclaration returns the frozen declaration associated with an +// endpoint method attribute. Error and wire attributes do not match one. +func methodTypeDeclaration(method *expr.MethodExpr, data *service.MethodData, attribute *expr.AttributeExpr) *codegen.TypeDeclaration { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + if methodTypeMatches(method.Payload, userType) { + return data.PayloadDeclaration + } + if methodTypeMatches(method.StreamingPayload, userType) { + return data.StreamingPayloadDeclaration + } + if methodTypeMatches(method.Result, userType) { + return data.ResultDeclaration + } + if methodTypeMatches(method.StreamingResult, userType) { + return data.StreamingResultDeclaration + } + return nil +} + +// methodTypeMatches reports whether candidate has the exact source origin used +// by a named method attribute. +func methodTypeMatches(candidate *expr.AttributeExpr, userType expr.UserType) bool { + if candidate == nil { + return false + } + candidateType, ok := candidate.Type.(expr.UserType) + return ok && candidateType.Origin() == userType.Origin() +} + // viewContext returns an attribute context for projected types. func viewContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { return codegen.NewAttributeContext(true, false, true, pkg, scope) diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 7de1251120..372bd4dac0 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -92,7 +92,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ) md := ed.Method svc := sd.Service - svcctx := serviceContext(sd.Service.PkgName, sd.Service.Scope) + svcctx := methodTypeContext(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) svrSendTypeName := ed.Result.Name svrSendTypeRef := ed.Result.Ref svrSendDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) @@ -101,8 +101,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin cliRecvWithContextDesc := fmt.Sprintf("%s reads instances of %q from the %q endpoint websocket connection with context.", md.ClientStream.RecvWithContextName, svrSendTypeName, md.Name) if e.MethodExpr.Stream == expr.ClientStreamKind || e.MethodExpr.Stream == expr.BidirectionalStreamKind { streamBody := sd.bodies.streaming(e) - svrRecvTypeName = sd.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName) - svrRecvTypeRef = sd.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName) + svrRecvTypeName = methodTypeName(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) + svrRecvTypeRef = methodTypeRef(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type @@ -168,8 +168,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin Name: name, Description: desc, ServerArgs: serverArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName), - ReturnTypeRef: svc.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName), + ReturnTypeName: methodTypeName(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope), + ReturnTypeRef: methodTypeRef(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope), ReturnIsStruct: expr.IsObject(e.MethodExpr.StreamingPayload.Type), ReturnTypePkg: svc.PkgName, ServerCode: serverCode, From 14b1a3c415ebc3ac3eccd203552f19e826cf3c25 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 03:07:48 -0700 Subject: [PATCH 18/43] codegen: make service aliases generation-owned --- codegen/example/example_server_test.go | 3 +- codegen/generated_types.go | 146 +++++++++--- codegen/generated_types_test.go | 47 ++++ codegen/generation.go | 10 +- .../service_union_package_scope_test.go | 51 ++++ codegen/import_aliases.go | 163 +++++++++++++ codegen/normalize.go | 23 +- codegen/service/client.go | 11 +- codegen/service/convert.go | 49 ++-- codegen/service/declaration_resolver_test.go | 8 +- codegen/service/endpoint.go | 15 +- codegen/service/example_interceptors.go | 29 ++- codegen/service/example_interceptors_test.go | 2 +- codegen/service/example_svc.go | 44 ++-- codegen/service/example_svc_test.go | 2 +- codegen/service/generated_package.go | 33 ++- codegen/service/imports.go | 213 +++++------------ codegen/service/imports_test.go | 218 ++++++++++++++++-- codegen/service/interceptors.go | 30 +-- codegen/service/service.go | 82 +++---- codegen/service/service_data.go | 9 +- .../templates/example_service_init.go.tpl | 2 +- .../templates/jsonrpc_handle_stream.go.tpl | 2 +- .../testdata/dedup_event_marker_dsls.go | 34 +-- .../api_interceptor_service_client.golden | 5 +- .../api_interceptor_service_server.golden | 5 +- .../chained_interceptor_service_client.golden | 9 +- .../chained_interceptor_service_server.golden | 9 +- .../client_interceptor_service_client.golden | 5 +- ...ultiple_interceptors_service_client.golden | 7 +- ...ultiple_interceptors_service_server.golden | 7 +- ...rvices_interceptors_service2_client.golden | 7 +- ...rvices_interceptors_service2_server.golden | 7 +- ...ervices_interceptors_service_client.golden | 7 +- ...ervices_interceptors_service_server.golden | 7 +- ..._interceptor_by_name_service_server.golden | 5 +- .../server_interceptor_service_server.golden | 5 +- codegen/service/views.go | 13 +- codegen/validation_test.go | 45 +++- grpc/codegen/testing.go | 3 +- http/codegen/testing.go | 3 +- jsonrpc/codegen/testing.go | 3 +- 42 files changed, 905 insertions(+), 473 deletions(-) create mode 100644 codegen/import_aliases.go diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 79033887f1..90905d9ab2 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -14,6 +14,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" ) // updateGolden is true when -w is passed to `go test`, e.g. `go test ./... -w` @@ -61,7 +62,7 @@ func TestExampleServerFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - generation := codegen.NewGeneration("goa.design/goa/example", nil) + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) require.NoError(t, service.Plan(root, generation)) require.NoError(t, generation.Freeze()) services, err := service.NewServicesData(root, generation) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 87f5aef1bc..f716c8fd7c 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -33,6 +33,15 @@ type ( kind derivedTypeKind } + // MethodTypeIdentity identifies one closed normalized service method role. + // It supplies both the semantic expression UID and the compiler declaration + // kind used for the wrapper created from a raw object. + MethodTypeIdentity struct { + serviceName string + methodName string + kind derivedTypeKind + } + // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { @@ -113,28 +122,42 @@ func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { return newDerivedTypeID(source, viewedResultTypeKind) } -// NewMethodPayloadTypeID returns the generated declaration identity for a raw -// object wrapped as a service method payload. -func NewMethodPayloadTypeID(source expr.UserType) DerivedTypeID { - return newDerivedTypeID(source, methodPayloadTypeKind) +// NewMethodPayloadIdentity returns the identity of a method payload wrapper. +func NewMethodPayloadIdentity(serviceName, methodName string) MethodTypeIdentity { + return newMethodTypeIdentity(serviceName, methodName, methodPayloadTypeKind) +} + +// NewMethodStreamingPayloadIdentity returns the identity of a method streaming +// payload wrapper. +func NewMethodStreamingPayloadIdentity(serviceName, methodName string) MethodTypeIdentity { + return newMethodTypeIdentity(serviceName, methodName, methodStreamingPayloadTypeKind) +} + +// NewMethodResultIdentity returns the identity of a method result wrapper. +func NewMethodResultIdentity(serviceName, methodName string) MethodTypeIdentity { + return newMethodTypeIdentity(serviceName, methodName, methodResultTypeKind) +} + +// NewMethodStreamingResultIdentity returns the identity of a method streaming +// result wrapper. +func NewMethodStreamingResultIdentity(serviceName, methodName string) MethodTypeIdentity { + return newMethodTypeIdentity(serviceName, methodName, methodStreamingResultTypeKind) } -// NewMethodStreamingPayloadTypeID returns the generated declaration identity -// for a raw object wrapped as a service method streaming payload. -func NewMethodStreamingPayloadTypeID(source expr.UserType) DerivedTypeID { - return newDerivedTypeID(source, methodStreamingPayloadTypeKind) +// Name returns the semantic wrapper name assigned during normalization. +func (i MethodTypeIdentity) Name() string { + return Goify(i.methodName, true) + i.kind.methodSuffix() } -// NewMethodResultTypeID returns the generated declaration identity for a raw -// object wrapped as a service method result. -func NewMethodResultTypeID(source expr.UserType) DerivedTypeID { - return newDerivedTypeID(source, methodResultTypeKind) +// UID returns the semantic expression identifier assigned during +// normalization. +func (i MethodTypeIdentity) UID() string { + return i.serviceName + "#" + i.Name() } -// NewMethodStreamingResultTypeID returns the generated declaration identity -// for a raw object wrapped as a service method streaming result. -func NewMethodStreamingResultTypeID(source expr.UserType) DerivedTypeID { - return newDerivedTypeID(source, methodStreamingResultTypeKind) +// Matches reports whether userType was normalized for this exact method role. +func (i MethodTypeIdentity) Matches(userType expr.UserType) bool { + return userType.ID() == i.UID() } // Name returns the unqualified Go declaration name. It is empty until the @@ -216,17 +239,19 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara name, ) } - p.scope.HashedUnique(userType, name, "") declaration := &TypeDeclaration{name: name, packagePath: p.path} + if err := p.bindType(origin, declaration); err != nil { + return nil, err + } + p.scope.HashedUnique(userType, name, "") p.userTypes[origin] = declaration - p.typeBindings[origin] = declaration p.userTypeNames[name] = userType.Name() return declaration, nil } -// DeclareDerivedType records one generated view declaration. Rebuilding the -// projected expression from a copy of the same source origin returns the same -// declaration record. +// DeclareDerivedType records one declaration produced by a closed compiler +// transformation. Rebuilding it from the same source origin returns the same +// canonical declaration record. func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name string) (*TypeDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) @@ -254,12 +279,8 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin } declaration := &TypeDeclaration{packagePath: p.path} if identity.kind.isMethodType() { - if _, ok := p.typeBindings[identity.origin]; ok { - return nil, fmt.Errorf( - "user type %q is already bound to another declaration in generated package %q", - identity.origin.Name(), - p.path, - ) + if err := p.bindType(identity.origin, declaration); err != nil { + return nil, err } } p.derivedTypes[identity] = &derivedTypeDeclaration{ @@ -268,12 +289,24 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin order: order, } p.derivedKeys[order] = identity - if identity.kind.isMethodType() { - p.typeBindings[identity.origin] = declaration - } return declaration, nil } +// DeclareMethodType records the declaration created for identity from source +// and returns the derived identity used for later lookup. +func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source expr.UserType) (*TypeDeclaration, DerivedTypeID, error) { + if !identity.Matches(source) { + return nil, DerivedTypeID{}, fmt.Errorf( + "user type %q does not match method wrapper %q", + source.Name(), + identity.UID(), + ) + } + derived := newDerivedTypeID(source, identity.kind) + declaration, err := p.DeclareDerivedType(derived, identity.Name()) + return declaration, derived, err +} + // DeclareUnion records union's emitted definition and returns the same // declaration for unions with the same emitted identity. The declaration name // remains empty until the owning generation freezes its package catalogs. @@ -334,21 +367,18 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName name, ) } - origin := userType.Origin() - if existing, ok := p.typeBindings[origin]; ok && existing != branch.branchType { - return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) + if err := p.bindType(userType.Origin(), branch.branchType); err != nil { + return nil, err } - p.typeBindings[origin] = branch.branchType return branch.branchType, nil } declaration := &TypeDeclaration{packagePath: p.path} origin := userType.Origin() - if existing, ok := p.typeBindings[origin]; ok && existing != declaration { - return nil, fmt.Errorf("user type %q is already bound to another declaration in generated package %q", userType.Name(), p.path) + if err := p.bindType(origin, declaration); err != nil { + return nil, err } branch.branchType = declaration branch.typeName = Goify(userType.Name(), true) - p.typeBindings[origin] = declaration return declaration, nil } @@ -487,6 +517,24 @@ func (p *GeneratedPackage) freeze() { p.frozen = true } +// bindType gives one exact expression origin one canonical package +// declaration. Repeating the same binding is harmless; claiming the origin for +// another record is a planning error. +func (p *GeneratedPackage) bindType(origin expr.UserType, declaration *TypeDeclaration) error { + if existing, ok := p.typeBindings[origin]; ok { + if existing == declaration { + return nil + } + return fmt.Errorf( + "user type %q is already bound to another declaration in generated package %q", + origin.Name(), + p.path, + ) + } + p.typeBindings[origin] = declaration + return nil +} + // newDerivedTypeID validates and records the exact declaration origin used by // independently rebuilt planning and rendering graphs. func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID { @@ -496,6 +544,30 @@ func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID return DerivedTypeID{origin: source.Origin(), kind: kind} } +// newMethodTypeIdentity constructs one of the four compiler-owned method roles. +func newMethodTypeIdentity(serviceName, methodName string, kind derivedTypeKind) MethodTypeIdentity { + if !kind.isMethodType() { + panic("method type identity requires a method role") + } + return MethodTypeIdentity{serviceName: serviceName, methodName: methodName, kind: kind} +} + +// methodSuffix returns the semantic suffix for one closed method wrapper kind. +func (k derivedTypeKind) methodSuffix() string { + switch k { + case methodPayloadTypeKind: + return "Payload" + case methodStreamingPayloadTypeKind: + return "StreamingPayload" + case methodResultTypeKind: + return "Result" + case methodStreamingResultTypeKind: + return "StreamingResult" + default: + panic("derived type kind is not a method role") + } +} + // isMethodType reports whether the derived declaration names a raw method // object wrapper in the service package. func (k derivedTypeKind) isMethodType() bool { diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 3e183db76b..15eafad495 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -410,6 +410,53 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { require.ErrorContains(t, err, "frozen") } +// TestGeneratedPackageRejectsConflictingOriginBindings verifies that exact +// and compiler-derived declarations cannot claim the same expression origin +// in either declaration order. +func TestGeneratedPackageRejectsConflictingOriginBindings(t *testing.T) { + for _, derivedFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("derived first %t", derivedFirst), func(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/values") + wrapper := generatedUserType("ReadPayload", "Values#ReadPayload") + identity := NewMethodPayloadIdentity("Values", "Read") + + if derivedFirst { + _, _, err := types.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + _, err = types.DeclareUserType(wrapper) + require.ErrorContains(t, err, "already bound") + return + } + + _, err := types.DeclareUserType(wrapper) + require.NoError(t, err) + _, _, err = types.DeclareMethodType(identity, wrapper) + require.ErrorContains(t, err, "already bound") + }) + } +} + +// TestMethodTypeIdentityMatchesNormalizedWrapper verifies that normalization +// and declaration planning share the same closed method-role identity. +func TestMethodTypeIdentityMatchesNormalizedWrapper(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity := NewMethodPayloadIdentity("Values", "Read") + + require.Equal(t, "ReadPayload", identity.Name()) + require.Equal(t, "Values#ReadPayload", identity.UID()) + require.True(t, identity.Matches(wrapper)) +} + // TestGenerationCatalogsAreIsolated verifies that standalone generation runs // do not share declaration records or name reservations. func TestGenerationCatalogsAreIsolated(t *testing.T) { diff --git a/codegen/generation.go b/codegen/generation.go index b4a561c4ef..1c3263bebe 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -18,8 +18,10 @@ type ( // Roots contains the evaluated DSL roots participating in the run. Roots []eval.Root - packages map[string]*GeneratedPackage - frozen bool + packages map[string]*GeneratedPackage + importPlan *importAliasPlan + imports map[string]importAliasBinding + frozen bool } ) @@ -29,6 +31,9 @@ func NewGeneration(genpkg string, roots []eval.Root) *Generation { GenPkg: genpkg, Roots: append([]eval.Root(nil), roots...), packages: make(map[string]*GeneratedPackage), + importPlan: &importAliasPlan{ + candidates: make(map[string]*importAliasCandidate), + }, } } @@ -56,6 +61,7 @@ func (g *Generation) Freeze() error { for _, generatedPackage := range g.packages { generatedPackage.freeze() } + g.freezeImports() g.frozen = true return nil } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index fac720a85d..f0ca11db67 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -562,6 +562,57 @@ func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { runGeneratedTests(t, dir) } +// TestServiceAndExamplesCompileWithImportQualifierCollisions verifies that +// fixed packages, generated service packages, generated views packages, and +// metadata packages share one path-owned qualifier mapping. +func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "generated.local/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "generated.local/custom/values", "values") + }) + dsl.Result(result) + }) + }) + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "generated.local/custom/strings", "strings") + }) + }) + }) + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, servicecodegen.Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := servicecodegen.NewServicesData(root, generation) + require.NoError(t, err) + + dir := t.TempDir() + files, err := Service(generation) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleServiceFiles(generation.GenPkg, root, services)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + writeStubPackage(t, filepath.Join(dir, "custom", "strings"), "strings") + writeStubPackage(t, filepath.Join(dir, "custom", "values"), "values") + writeStubPackage(t, filepath.Join(dir, "custom", "views"), "valuesviews") + runGeneratedTests(t, dir) +} + // unusedRelocatedValueRoot declares a relocated type that no service reaches // and does not force generation. It must not reserve a generated package name. func unusedRelocatedValueRoot() func() { diff --git a/codegen/import_aliases.go b/codegen/import_aliases.go new file mode 100644 index 0000000000..209a7fabbb --- /dev/null +++ b/codegen/import_aliases.go @@ -0,0 +1,163 @@ +// This file owns the import qualifiers shared by every file rendered in one +// generation. Generators declare complete package paths during planning, then +// render headers and references from the immutable bindings after freeze. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/eval" +) + +type ( + // importAliasPlan records every preferred spelling for a complete package + // path before qualifiers are allocated. + importAliasPlan struct { + candidates map[string]*importAliasCandidate + } + + // importAliasCandidate retains generator-reserved and design-preferred names + // separately so generator template imports take priority deterministically. + importAliasCandidate struct { + reserved map[string]bool + preferred map[string]bool + } + + // importAliasBinding records the qualifier and whether its import declaration + // must spell that qualifier explicitly. + importAliasBinding struct { + name string + explicit bool + } +) + +// ReserveImport declares a generator-owned import. Its spelling takes +// priority over design metadata that names the same path differently. +func (g *Generation) ReserveImport(spec *ImportSpec) error { + return g.declareImport(spec, true) +} + +// DeclareImport declares a design-owned import. Repeated declarations of one +// complete path are merged before freeze. +func (g *Generation) DeclareImport(spec *ImportSpec) error { + return g.declareImport(spec, false) +} + +// Import returns the frozen import declaration for importPath. It panics when +// called before freeze or for a path that planning did not declare. +func (g *Generation) Import(importPath string) *ImportSpec { + binding := g.importBinding(importPath) + return &ImportSpec{Name: explicitImportName(importPath, binding), Path: importPath} +} + +// ImportName returns the frozen Go qualifier for importPath. It panics when +// called before freeze or for a path that planning did not declare. +func (g *Generation) ImportName(importPath string) string { + return g.importBinding(importPath).name +} + +// HasRoot reports whether root is one of the exact evaluated roots registered +// when the generation was constructed. +func (g *Generation) HasRoot(root eval.Root) bool { + for _, registered := range g.Roots { + if registered == root { + return true + } + } + return false +} + +// declareImport merges one path spelling into the generation plan. +func (g *Generation) declareImport(spec *ImportSpec, reserved bool) error { + if g.frozen { + return fmt.Errorf("generation imports are frozen") + } + importPath, preferred := spec.Path, spec.Name + if importPath == "" { + return nil + } + if preferred == "" { + preferred = path.Base(importPath) + } + candidate, ok := g.importPlan.candidates[importPath] + if !ok { + candidate = &importAliasCandidate{ + reserved: make(map[string]bool), + preferred: make(map[string]bool), + } + g.importPlan.candidates[importPath] = candidate + } + spellings := candidate.preferred + if reserved { + spellings = candidate.reserved + } + spellings[preferred] = spellings[preferred] || spec.Name != "" + return nil +} + +// freezeImports allocates qualifiers in generator-priority, full-path order. +func (g *Generation) freezeImports() { + paths := make([]string, 0, len(g.importPlan.candidates)) + for importPath := range g.importPlan.candidates { + paths = append(paths, importPath) + } + sort.Slice(paths, func(i, j int) bool { + left := len(g.importPlan.candidates[paths[i]].reserved) > 0 + right := len(g.importPlan.candidates[paths[j]].reserved) > 0 + if left != right { + return left + } + return paths[i] < paths[j] + }) + scope := NewNameScope() + g.imports = make(map[string]importAliasBinding, len(paths)) + for _, importPath := range paths { + candidate := g.importPlan.candidates[importPath] + spellings := candidate.preferred + if len(candidate.reserved) > 0 { + spellings = candidate.reserved + } + preferred, explicit := firstImportSpelling(spellings) + name := scope.Unique(preferred) + g.imports[importPath] = importAliasBinding{ + name: name, + explicit: explicit || name != path.Base(importPath), + } + } + scope.Freeze() +} + +// importBinding returns one planned binding after generation freeze. +func (g *Generation) importBinding(importPath string) importAliasBinding { + if !g.frozen { + panic("generation imports requested before freeze") + } + binding, ok := g.imports[importPath] + if !ok { + panic(fmt.Sprintf("import path %q has no planned alias", importPath)) + } + return binding +} + +// firstImportSpelling returns the lexicographically first spelling so plan +// registration order cannot affect generated qualifiers. +func firstImportSpelling(spellings map[string]bool) (string, bool) { + names := make([]string, 0, len(spellings)) + for name := range spellings { + names = append(names, name) + } + sort.Strings(names) + name := names[0] + return name, spellings[name] +} + +// explicitImportName omits a redundant alias unless planning or collision +// resolution requires one. +func explicitImportName(importPath string, binding importAliasBinding) string { + if binding.explicit || binding.name != path.Base(importPath) { + return binding.name + } + return "" +} diff --git a/codegen/normalize.go b/codegen/normalize.go index f71bcae0af..2a6dfa0515 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -22,27 +22,18 @@ func NormalizeRoot(root *expr.RootExpr) { // one service without consulting or mutating any Go name scope. func normalizeService(service *expr.ServiceExpr) { for _, method := range service.Methods { - name := Goify(method.Name, true) - normalizeMethodAttribute(method.Payload, name+"Payload", service.Name+"#"+name+"Payload") - normalizeMethodAttribute( - method.StreamingPayload, - name+"StreamingPayload", - service.Name+"#"+name+"StreamingPayload", - ) - normalizeMethodAttribute(method.Result, name+"Result", service.Name+"#"+name+"Result") + normalizeMethodAttribute(method.Payload, NewMethodPayloadIdentity(service.Name, method.Name)) + normalizeMethodAttribute(method.StreamingPayload, NewMethodStreamingPayloadIdentity(service.Name, method.Name)) + normalizeMethodAttribute(method.Result, NewMethodResultIdentity(service.Name, method.Name)) if method.HasMixedResults() { - normalizeMethodAttribute( - method.StreamingResult, - name+"StreamingResult", - service.Name+"#"+name+"StreamingResult", - ) + normalizeMethodAttribute(method.StreamingResult, NewMethodStreamingResultIdentity(service.Name, method.Name)) } } } // normalizeMethodAttribute gives a raw method object its semantic identity. // Existing named and non-object method types remain unchanged. -func normalizeMethodAttribute(attribute *expr.AttributeExpr, name, id string) { +func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity) { if attribute == nil { return } @@ -51,7 +42,7 @@ func normalizeMethodAttribute(attribute *expr.AttributeExpr, name, id string) { } attribute.Type = &expr.UserTypeExpr{ AttributeExpr: expr.DupAtt(attribute), - TypeName: name, - UID: id, + TypeName: identity.Name(), + UID: identity.UID(), } } diff --git a/codegen/service/client.go b/codegen/service/client.go index aa3a095077..6446e5f72f 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -24,12 +24,11 @@ func ClientFile(genpkg string, service *expr.ServiceExpr, services *ServicesData sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - codegen.GoaImport(""), - } - imports = append(imports, services.AttributeImports(outputPackage, serviceReferenceAttributes(service)...)...) + imports := services.fileImports(outputPackage, []string{ + "context", + "io", + codegen.GoaImport("").Path, + }, serviceReferenceAttributes(service)...) header := codegen.Header(service.Name+" client", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "client-struct", diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 2bbcd75e6b..8fe5742e6e 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -151,31 +151,38 @@ func generateConvertFileForPath( } } - // Retrieve external packages info - ppm := make(map[string]string) + // Collect the complete external package paths referenced by this file. + externalPaths := make(map[string]struct{}) for _, c := range conversions { - pkgImport, alias, err := getExternalTypeInfo(c.External) + pkgImport, _, err := getExternalTypeInfo(c.External) if err != nil { return nil, err } - ppm[pkgImport] = alias + externalPaths[pkgImport] = struct{}{} } for _, c := range creations { - pkgImport, alias, err := getExternalTypeInfo(c.External) + pkgImport, _, err := getExternalTypeInfo(c.External) if err != nil { return nil, err } - ppm[pkgImport] = alias + externalPaths[pkgImport] = struct{}{} } - pkgs := make([]*codegen.ImportSpec, 0, len(ppm)+2) - for pp, alias := range ppm { - pkgs = append(pkgs, &codegen.ImportSpec{Name: alias, Path: pp}) + paths := make([]string, 0, len(externalPaths)) + for importPath := range externalPaths { + paths = append(paths, importPath) } - // Build header section - pkgs = append(pkgs, &codegen.ImportSpec{Path: "context"}, codegen.GoaImport("")) + outputPath := servicePackagePath(services.generation.GenPkg, service) + first := append(append([]*expr.TypeMap(nil), conversions...), creations...)[0] + if loc := codegen.UserTypeLocation(first.User); loc != nil { + outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) + } sections := []*codegen.SectionTemplate{ - codegen.Header(service.Name+" service type conversion functions", convertPkgName, pkgs), + codegen.Header( + service.Name+" service type conversion functions", + convertPkgName, + services.fileImports(outputPath, paths), + ), } var ( @@ -190,10 +197,11 @@ func generateConvertFileForPath( return nil, err } t := reflect.TypeOf(c.External) - tgtPkg := t.String() - if idx := strings.Index(tgtPkg, "."); idx != -1 { - tgtPkg = tgtPkg[:idx] + pkgImport, _, err := getExternalTypeInfo(c.External) + if err != nil { + return nil, err } + tgtPkg := services.aliases.name(pkgImport) outputPath := servicePackagePath(services.generation.GenPkg, service) if loc := codegen.UserTypeLocation(c.User); loc != nil { @@ -217,7 +225,7 @@ func generateConvertFileForPath( transFuncs = codegen.AppendHelpers(transFuncs, tf) base := "ConvertTo" + t.Name() name := uniquify(base, names) - ref := t.String() + ref := tgtPkg + "." + t.Name() if expr.IsObject(c.User) { ref = "*" + ref } @@ -242,10 +250,11 @@ func generateConvertFileForPath( return nil, err } t := reflect.TypeOf(c.External) - srcPkg := t.String() - if idx := strings.Index(srcPkg, "."); idx != -1 { - srcPkg = srcPkg[:idx] + pkgImport, _, err := getExternalTypeInfo(c.External) + if err != nil { + return nil, err } + srcPkg := services.aliases.name(pkgImport) srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) tgtAtt := &expr.AttributeExpr{Type: c.User} @@ -267,7 +276,7 @@ func generateConvertFileForPath( transFuncs = codegen.AppendHelpers(transFuncs, tf) base := "CreateFrom" + t.Name() name := uniquify(base, names) - ref := t.String() + ref := srcPkg + "." + t.Name() if expr.IsObject(c.User) { ref = "*" + ref } diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index e977ee83a8..0efbda9bc0 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -173,12 +173,12 @@ func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { // service analysis for the package paths exercised by a focused resolver test. func aliasesForTest(t *testing.T, paths ...string) *importAliases { t.Helper() - plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} - require.NoError(t, plan.addFixedImports()) + generation := codegen.NewGeneration("generated.local/gen", nil) for _, importPath := range paths { - require.NoError(t, plan.add(importPath, codegen.Goify(path.Base(importPath), false), true, false)) + require.NoError(t, generation.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) } - return plan.freeze() + require.NoError(t, generation.Freeze()) + return &importAliases{generation: generation} } // resolverUserType constructs one exact declaration for resolver tests. diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index 23da84d87d..83fe57d496 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -80,15 +80,12 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "fmt"}, - codegen.GoaImport(""), - codegen.GoaImport("security"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: svc.ViewsPkg}, - } - imports = append(imports, services.AttributeImports(outputPackage, serviceReferenceAttributes(service)...)...) + imports := services.fileImports(outputPackage, []string{ + "context", + "io", + codegen.GoaImport("").Path, + codegen.GoaImport("security").Path, + }, serviceReferenceAttributes(service)...) header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "endpoints-struct", diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index ca33ec0c98..0ba4a7fa81 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -26,10 +26,11 @@ func ExampleInterceptorsFiles(genpkg string, r *expr.RootExpr, services *Service // exampleInterceptorsFile returns the example interceptors for the given service. func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *ServicesData) []*codegen.File { sdata := services.Get(svc.Name) + servicePath := path.Join(genpkg, sdata.PathName) data := map[string]any{ "ServiceName": sdata.Name, "StructName": sdata.StructName, - "PkgName": "interceptors", + "PkgName": services.aliases.name(servicePath), "ServerInterceptors": sdata.ServerInterceptors, "ClientInterceptors": sdata.ClientInterceptors, } @@ -40,13 +41,12 @@ func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *Ser if len(sdata.ServerInterceptors) > 0 { serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") if _, err := os.Stat(serverPath); os.IsNotExist(err) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - } + imports := services.fileImports("", []string{ + "context", + "goa.design/clue/log", + codegen.GoaImport("").Path, + servicePath, + }) files = append(files, &codegen.File{ Path: serverPath, SectionTemplates: []*codegen.SectionTemplate{ @@ -65,13 +65,12 @@ func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *Ser if len(sdata.ClientInterceptors) > 0 { clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") if _, err := os.Stat(clientPath); os.IsNotExist(err) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - } + imports := services.fileImports("", []string{ + "context", + "goa.design/clue/log", + codegen.GoaImport("").Path, + servicePath, + }) files = append(files, &codegen.File{ Path: clientPath, SectionTemplates: []*codegen.SectionTemplate{ diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index 465095d65f..02909938d0 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -88,7 +88,7 @@ func TestExampleInterceptorsFiles(t *testing.T) { require.NotNil(t, root) // Generate files - fs := ExampleInterceptorsFiles("", root, services) + fs := ExampleInterceptorsFiles(services.generation.GenPkg, root, services) require.Len(t, fs, len(c.ExpectedFiles)) // Verify file paths diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index ea1cb0482c..f87b6d489d 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -33,6 +33,14 @@ type ( // by the endpoint implementation. StreamInterface string } + + // exampleServiceData separates the generated service package declaration + // name from the qualifier used by this example file. + exampleServiceData struct { + *Data + // ServicePkg is the canonical generated service package qualifier. + ServicePkg string + } ) // ExampleServiceFiles returns a basic service implementation for every @@ -62,42 +70,44 @@ func ExampleServiceFiles(genpkg string, root *expr.RootExpr, services *ServicesD func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, services *ServicesData, apipkg string) *codegen.File { data := services.Get(svc.Name) svcName := data.PathName + servicePath := path.Join(genpkg, svcName) + servicePkg := services.aliases.name(servicePath) + renderData := &exampleServiceData{Data: data, ServicePkg: servicePkg} fpath := svcName + ".go" if _, err := os.Stat(fpath); !os.IsNotExist(err) { return nil // file already exists, skip it. } - specs := []*codegen.ImportSpec{ - {Path: "io"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: path.Join(genpkg, svcName), Name: data.PkgName}, - {Path: "goa.design/clue/log"}, - {Path: "goa.design/goa/v3/security"}, - } - specs = append(specs, services.AttributeImports(path.Dir(genpkg), serviceReferenceAttributes(svc)...)...) + specs := services.fileImports(path.Dir(genpkg), []string{ + "io", + "context", + "fmt", + "strings", + servicePath, + "goa.design/clue/log", + codegen.GoaImport("security").Path, + }, serviceReferenceAttributes(svc)...) sections := []*codegen.SectionTemplate{ codegen.Header("", apipkg, specs), { Name: "basic-service-struct", Source: serviceTemplates.Read(exampleServiceStructT), - Data: data, + Data: renderData, }, { Name: "basic-service-init", Source: serviceTemplates.Read(exampleServiceInitT), - Data: data, + Data: renderData, }, } if len(data.Schemes) > 0 { sections = append(sections, &codegen.SectionTemplate{ Name: "security-authfuncs", Source: serviceTemplates.Read(exampleSecurityAuthfuncsT), - Data: data, + Data: renderData, }) } resolver := newServiceResolver(services.generation, services.aliases, svc, path.Dir(genpkg)) for _, m := range svc.Methods { - sections = append(sections, basicEndpointSection(m, data, resolver)) + sections = append(sections, basicEndpointSection(m, data, resolver, servicePkg)) } // Add HandleStream method for JSON-RPC WebSocket services (not SSE) @@ -105,7 +115,7 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-handle-stream", Source: serviceTemplates.Read(jsonrpcHandleStreamT), - Data: data, + Data: renderData, }) } @@ -118,7 +128,7 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, // basicEndpointSection returns a starter implementation whose payload and // result references come from the method's frozen generated-package records. -func basicEndpointSection(m *expr.MethodExpr, svcData *Data, resolver *declarationResolver) *codegen.SectionTemplate { +func basicEndpointSection(m *expr.MethodExpr, svcData *Data, resolver *declarationResolver, servicePkg string) *codegen.SectionTemplate { md := svcData.Method(m.Name) ed := &basicEndpointData{ MethodData: md, @@ -140,7 +150,7 @@ func basicEndpointSection(m *expr.MethodExpr, svcData *Data, resolver *declarati } } if md.ServerStream != nil { - ed.StreamInterface = svcData.PkgName + "." + md.ServerStream.Interface + ed.StreamInterface = servicePkg + "." + md.ServerStream.Interface } return &codegen.SectionTemplate{ Name: "basic-endpoint", diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 9fd651e3a8..4834cd6431 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -34,7 +34,7 @@ func TestExampleServiceFiles(t *testing.T) { root := codegen.RunDSL(t, c.DSL) services := mustServicesData(t, root) require.Len(t, root.Services, 3) - fs := ExampleServiceFiles("", root, services) + fs := ExampleServiceFiles(services.generation.GenPkg, root, services) require.Len(t, fs, 3) for _, f := range fs { require.Greater(t, len(f.SectionTemplates), 0) diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index f0a2bbb211..8e1d9c128a 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -5,6 +5,7 @@ package service import ( + "fmt" "path" "path/filepath" "slices" @@ -34,8 +35,7 @@ type ( // declaration identity allocated for it. methodTypeCandidate struct { attribute *expr.AttributeExpr - suffix string - identity func(expr.UserType) codegen.DerivedTypeID + identity codegen.MethodTypeIdentity } // unionBranch identifies a generated user type that exists only to name one @@ -75,6 +75,12 @@ type ( // User types are declared across the complete root before any union so exact // user-authored names always take precedence over generated union names. func Plan(root *expr.RootExpr, generation *codegen.Generation) error { + if !generation.HasRoot(root) { + return rootMembershipError(root) + } + if err := planImports(root, generation); err != nil { + return err + } inputs := planningInputs(root) rootTypes := newRootTypeSet(root) methodTypes, err := planMethodTypes(root, generation) @@ -103,6 +109,12 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { return planViews(root, generation, rootTypes) } +// rootMembershipError reports an attempt to plan or analyze a design root +// that the generation does not own. +func rootMembershipError(root *expr.RootExpr) error { + return fmt.Errorf("service root %p does not belong to the generation", root) +} + // planMethodTypes declares the semantic wrappers created by NormalizeRoot as // derived service-package declarations. Exact user types in the same package // are planned separately and therefore keep their authored names. @@ -112,24 +124,23 @@ func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[e generatedPackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) for _, method := range service.Methods { attributes := []methodTypeCandidate{ - {method.Payload, "Payload", codegen.NewMethodPayloadTypeID}, - {method.StreamingPayload, "StreamingPayload", codegen.NewMethodStreamingPayloadTypeID}, - {method.Result, "Result", codegen.NewMethodResultTypeID}, + {method.Payload, codegen.NewMethodPayloadIdentity(service.Name, method.Name)}, + {method.StreamingPayload, codegen.NewMethodStreamingPayloadIdentity(service.Name, method.Name)}, + {method.Result, codegen.NewMethodResultIdentity(service.Name, method.Name)}, } if method.HasMixedResults() { attributes = append(attributes, methodTypeCandidate{ attribute: method.StreamingResult, - suffix: "StreamingResult", - identity: codegen.NewMethodStreamingResultTypeID, + identity: codegen.NewMethodStreamingResultIdentity(service.Name, method.Name), }) } for _, candidate := range attributes { userType, ok := candidate.attribute.Type.(expr.UserType) - if !ok || userType.ID() != normalizedMethodTypeID(service, method, candidate.suffix) { + if !ok || !candidate.identity.Matches(userType) { continue } - identity := candidate.identity(userType) - if _, err := generatedPackage.DeclareDerivedType(identity, codegen.Goify(userType.Name(), true)); err != nil { + _, identity, err := generatedPackage.DeclareMethodType(candidate.identity, userType) + if err != nil { return nil, err } planned[userType.Origin()] = identity @@ -454,7 +465,7 @@ func generatedPackagePath(genpkg string, service *expr.ServiceExpr, location *co // servicePackagePath returns the actual import path of service's generated Go // package. func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { - return path.Join(genpkg, codegen.SnakeCase(service.Name)) + return path.Join(genpkg, codegen.SnakeCase(codegen.Goify(service.Name, false))) } // generatedPackage returns the root-owned render data for the package selected diff --git a/codegen/service/imports.go b/codegen/service/imports.go index cd4ce2be14..626358f621 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -4,7 +4,6 @@ package service import ( - "fmt" "path" "sort" "strings" @@ -17,29 +16,7 @@ type ( // importAliases is the frozen render-model binding from complete import paths // to their unique Go qualifiers. importAliases struct { - bindings map[string]importBinding - } - - // importBinding records the allocated qualifier and whether the import must - // spell it explicitly in a generated header. - importBinding struct { - name string - preferred string - explicit bool - } - - // importAliasCandidate records one package path before deterministic alias - // allocation. Fixed generator imports receive priority over design metadata. - importAliasCandidate struct { - preferred string - explicit bool - fixed bool - } - - // importAliasPlan collects all package paths before any render string is - // produced. - importAliasPlan struct { - candidates map[string]importAliasCandidate + generation *codegen.Generation } // importCollector accumulates the imports referenced by one generated Go @@ -76,6 +53,20 @@ func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*exp return collector.imports() } +// fileImports returns one canonical import per complete path used by a single +// generated file. Explicit paths and attribute-derived paths are deduplicated +// before their frozen aliases are materialized. +func (d *ServicesData) fileImports(outputPackage string, paths []string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { + collector := newImportCollector(d.aliases, d.generation.GenPkg, outputPackage) + for _, importPath := range paths { + collector.addPath(importPath) + } + for _, attribute := range attributes { + collector.collect(attribute) + } + return collector.imports() +} + // serviceReferenceAttributes returns the method and error attributes whose // named declarations are referenced by service, endpoint, and client files. func serviceReferenceAttributes(service *expr.ServiceExpr) []*expr.AttributeExpr { @@ -95,108 +86,93 @@ func serviceReferenceAttributes(service *expr.ServiceExpr) []*expr.AttributeExpr return attributes } -// newImportAliases scans every participating design root plus the explicit -// root being rendered, then freezes deterministic aliases before service -// analysis creates type-reference strings. +// newImportAliases returns the generation-owned frozen alias binding used by +// service analysis and rendering. func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { - plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} - if err := plan.addFixedImports(); err != nil { - return nil, err - } - seenRoots := make(map[*expr.RootExpr]struct{}, len(generation.Roots)+1) - for _, evaluated := range generation.Roots { - design, ok := evaluated.(*expr.RootExpr) - if !ok { - continue - } - seenRoots[design] = struct{}{} - if err := plan.addRoot(design, generation.GenPkg); err != nil { - return nil, err - } - } - if _, ok := seenRoots[root]; !ok { - if err := plan.addRoot(root, generation.GenPkg); err != nil { - return nil, err - } + if !generation.HasRoot(root) { + return nil, rootMembershipError(root) } - return plan.freeze(), nil + return &importAliases{generation: generation}, nil } -// addFixedImports reserves qualifiers used directly by service and view -// templates before metadata-selected packages compete for those names. -func (p *importAliasPlan) addFixedImports() error { +// planImports registers every fixed and design-selected package path reachable +// from root in the generation-wide alias catalog. +func planImports(root *expr.RootExpr, generation *codegen.Generation) error { fixed := []*codegen.ImportSpec{ codegen.SimpleImport("bytes"), codegen.SimpleImport("context"), codegen.SimpleImport("encoding/json"), codegen.SimpleImport("fmt"), codegen.SimpleImport("io"), + codegen.SimpleImport("strings"), codegen.SimpleImport("unicode/utf8"), + codegen.SimpleImport("goa.design/clue/log"), codegen.GoaImport(""), codegen.GoaImport("security"), } for _, spec := range fixed { - if err := p.add(spec.Path, spec.Name, spec.Name != "", true); err != nil { + if err := generation.ReserveImport(spec); err != nil { return err } } - return nil -} - -// addRoot collects generated package locations and metadata imports reachable -// from one complete service design. -func (p *importAliasPlan) addRoot(root *expr.RootExpr, genpkg string) error { for _, service := range root.Services { - servicePath := servicePackagePath(genpkg, service) + servicePath := servicePackagePath(generation.GenPkg, service) serviceName := strings.ToLower(codegen.Goify(service.Name, false)) - if err := p.add(servicePath, serviceName, true, false); err != nil { + if err := generation.ReserveImport(codegen.NewImport(serviceName, servicePath)); err != nil { return err } - if err := p.add(servicePath+"/views", serviceName+"views", true, false); err != nil { + if err := generation.ReserveImport(codegen.NewImport(serviceName+"views", servicePath+"/views")); err != nil { return err } } seen := make(map[expr.UserType]struct{}) for _, userType := range root.Types { - if err := p.addAttribute(&expr.AttributeExpr{Type: userType}, genpkg, seen); err != nil { + if err := planAttributeImports(&expr.AttributeExpr{Type: userType}, generation, seen); err != nil { return err } } for _, resultType := range root.ResultTypes { - if err := p.addAttribute(&expr.AttributeExpr{Type: resultType}, genpkg, seen); err != nil { + if err := planAttributeImports(&expr.AttributeExpr{Type: resultType}, generation, seen); err != nil { return err } } for _, service := range root.Services { for _, attribute := range serviceReferenceAttributes(service) { - if err := p.addAttribute(attribute, genpkg, seen); err != nil { + if err := planAttributeImports(attribute, generation, seen); err != nil { return err } } } + for _, typeMap := range append(append([]*expr.TypeMap(nil), root.Conversions...), root.Creations...) { + importPath, alias, err := getExternalTypeInfo(typeMap.External) + if err != nil { + return err + } + if err := generation.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { + return err + } + } return nil } -// addAttribute recursively records every explicit generated location and +// planAttributeImports recursively records every explicit generated location and // struct:field:type import reachable from attribute. -func (p *importAliasPlan) addAttribute(attribute *expr.AttributeExpr, genpkg string, seen map[expr.UserType]struct{}) error { +func planAttributeImports(attribute *expr.AttributeExpr, generation *codegen.Generation, seen map[expr.UserType]struct{}) error { if attribute == nil || attribute.Type == expr.Empty { return nil } if _, spec := codegen.GetMetaType(attribute); spec != nil { - if err := p.add(spec.Path, spec.Name, spec.Name != "", false); err != nil { + if err := generation.DeclareImport(spec); err != nil { return err } } switch actual := attribute.Type.(type) { case expr.UserType: if location := codegen.UserTypeLocation(actual); location != nil { - if err := p.add( - path.Join(genpkg, location.RelImportPath), + if err := generation.DeclareImport(codegen.NewImport( location.PackageName(), - true, - false, - ); err != nil { + path.Join(generation.GenPkg, location.RelImportPath), + )); err != nil { return err } } @@ -205,23 +181,23 @@ func (p *importAliasPlan) addAttribute(attribute *expr.AttributeExpr, genpkg str return nil } seen[origin] = struct{}{} - return p.addAttribute(actual.Attribute(), genpkg, seen) + return planAttributeImports(actual.Attribute(), generation, seen) case *expr.Object: for _, named := range *actual { - if err := p.addAttribute(named.Attribute, genpkg, seen); err != nil { + if err := planAttributeImports(named.Attribute, generation, seen); err != nil { return err } } case *expr.Array: - return p.addAttribute(actual.ElemType, genpkg, seen) + return planAttributeImports(actual.ElemType, generation, seen) case *expr.Map: - if err := p.addAttribute(actual.KeyType, genpkg, seen); err != nil { + if err := planAttributeImports(actual.KeyType, generation, seen); err != nil { return err } - return p.addAttribute(actual.ElemType, genpkg, seen) + return planAttributeImports(actual.ElemType, generation, seen) case *expr.Union: for _, named := range actual.Values { - if err := p.addAttribute(named.Attribute, genpkg, seen); err != nil { + if err := planAttributeImports(named.Attribute, generation, seen); err != nil { return err } } @@ -229,86 +205,15 @@ func (p *importAliasPlan) addAttribute(attribute *expr.AttributeExpr, genpkg str return nil } -// add records one complete import path and rejects contradictory preferred -// package names before aliases are allocated. -func (p *importAliasPlan) add(importPath, preferred string, explicit, fixed bool) error { - if importPath == "" { - return nil - } - if preferred == "" { - preferred = path.Base(importPath) - } - if existing, ok := p.candidates[importPath]; ok { - if existing.preferred != preferred { - return fmt.Errorf( - "import path %q cannot use both package names %q and %q", - importPath, - existing.preferred, - preferred, - ) - } - existing.explicit = existing.explicit || explicit - existing.fixed = existing.fixed || fixed - p.candidates[importPath] = existing - return nil - } - p.candidates[importPath] = importAliasCandidate{ - preferred: preferred, - explicit: explicit, - fixed: fixed, - } - return nil -} - -// freeze allocates aliases in fixed-priority, full-path order and returns the -// immutable lookup used throughout rendering. -func (p *importAliasPlan) freeze() *importAliases { - paths := make([]string, 0, len(p.candidates)) - for importPath := range p.candidates { - paths = append(paths, importPath) - } - sort.Slice(paths, func(i, j int) bool { - left, right := p.candidates[paths[i]], p.candidates[paths[j]] - if left.fixed != right.fixed { - return left.fixed - } - return paths[i] < paths[j] - }) - scope := codegen.NewNameScope() - bindings := make(map[string]importBinding, len(paths)) - for _, importPath := range paths { - candidate := p.candidates[importPath] - bindings[importPath] = importBinding{ - name: scope.Unique(candidate.preferred), - preferred: candidate.preferred, - explicit: candidate.explicit, - } - } - scope.Freeze() - return &importAliases{bindings: bindings} -} - // name returns the frozen qualifier for importPath and panics when rendering // asks for a package that was absent from alias planning. func (a *importAliases) name(importPath string) string { - binding, ok := a.bindings[importPath] - if !ok { - panic(fmt.Sprintf("import path %q has no planned alias", importPath)) - } - return binding.name + return a.generation.ImportName(importPath) } // spec returns the frozen import declaration for importPath. func (a *importAliases) spec(importPath string) *codegen.ImportSpec { - binding, ok := a.bindings[importPath] - if !ok { - panic(fmt.Sprintf("import path %q has no planned alias", importPath)) - } - spec := &codegen.ImportSpec{Path: importPath} - if binding.explicit || binding.name != binding.preferred { - spec.Name = binding.name - } - return spec + return a.generation.Import(importPath) } // newImportCollector creates a file-scoped collector that omits imports of the @@ -323,6 +228,14 @@ func newImportCollector(aliases *importAliases, genpkg, outputPackage string) *i } } +// addPath records an explicitly referenced package unless it is the package +// currently being emitted. +func (c *importCollector) addPath(importPath string) { + if importPath != "" && importPath != c.outputPackage { + c.paths[importPath] = struct{}{} + } +} + // collect walks inline shapes but stops at named types because a reference to a // named declaration does not render that declaration's fields in the file. func (c *importCollector) collect(attribute *expr.AttributeExpr) { diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index 951a85cd73..87feba4c64 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -3,6 +3,8 @@ package service import ( + "go/format" + "path" "strings" "testing" @@ -10,13 +12,13 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) -// TestImportAliasesIncludeExplicitAnalysisRoot verifies that plugin-local -// analysis sees imports from the root passed to NewServicesData even when that -// root is not listed in the generation's evaluated roots. -func TestImportAliasesIncludeExplicitAnalysisRoot(t *testing.T) { +// TestPlanRejectsUnregisteredRoot verifies that service planning cannot create +// render state outside the roots owned by its generation. +func TestPlanRejectsUnregisteredRoot(t *testing.T) { root := codegen.RunDSL(t, func() { payload := dsl.Type("Payload", func() { dsl.Attribute("value", dsl.String, func() { @@ -30,17 +32,79 @@ func TestImportAliasesIncludeExplicitAnalysisRoot(t *testing.T) { }) }) generation := codegen.NewGeneration("generated.local/gen", nil) - require.NoError(t, Plan(root, generation)) + require.ErrorContains(t, Plan(root, generation), "does not belong") + require.NoError(t, generation.Freeze()) + _, err := NewServicesData(root, generation) + require.ErrorContains(t, err, "does not belong") +} + +// TestImportAliasesUsePathAsIdentity verifies that generator-owned imports +// retain their canonical qualifier when metadata prefers another spelling for +// the same complete package path. +func TestImportAliasesUsePathAsIdentity(t *testing.T) { + generation := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.ReserveImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generation.DeclareImport(codegen.NewImport("jason", "encoding/json"))) require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation) + aliases := &importAliases{generation: generation} + require.Equal(t, "json", aliases.name("encoding/json")) + require.Equal(t, "encoding/json", aliases.spec("encoding/json").Path) +} + +// TestImportAliasPreferenceIsOrderIndependent verifies that two metadata +// spellings for one path produce the same frozen qualifier in either order. +func TestImportAliasPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.DeclareImport(codegen.NewImport(first, "example.com/value"))) + require.NoError(t, generation.DeclareImport(codegen.NewImport(second, "example.com/value"))) + require.NoError(t, generation.Freeze()) + return generation.ImportName("example.com/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestRegisteredRootsShareImportAliases verifies that every root analysis and +// relocated declaration consumes the one mapping frozen for the generation. +func TestRegisteredRootsShareImportAliases(t *testing.T) { + rootWithPreference := func(serviceName, typeName, preferred string) *expr.RootExpr { + return codegen.RunDSL(t, func() { + payload := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", preferred+".Value", "example.com/shared/value", preferred) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + } + firstRoot := rootWithPreference("First", "FirstPayload", "zeta") + secondRoot := rootWithPreference("Second", "SecondPayload", "alpha") + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{firstRoot, secondRoot}) + require.NoError(t, Plan(firstRoot, generation)) + require.NoError(t, Plan(secondRoot, generation)) + require.NoError(t, generation.Freeze()) + first, err := NewServicesData(firstRoot, generation) require.NoError(t, err) - aliases := services.aliases - require.Equal(t, "shared", aliases.name("example.com/local/shared")) - require.Equal(t, &codegen.ImportSpec{ - Name: "shared", - Path: "example.com/local/shared", - }, aliases.spec("example.com/local/shared")) + second, err := NewServicesData(secondRoot, generation) + require.NoError(t, err) + require.Equal(t, "alpha", first.aliases.name("example.com/shared/value")) + require.Equal(t, first.aliases.name("example.com/shared/value"), second.aliases.name("example.com/shared/value")) + + files := Files(generation.GenPkg, []*ServicesData{first, second}) + for _, name := range []string{"first_payload.go", "second_payload.go"} { + file := findFile(files, path.Join("gen", "types", name)) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `alpha "example.com/shared/value"`) + require.Contains(t, code, "alpha.Value") + } } // TestImportAliasesReserveFixedJSON verifies that the union codec's @@ -59,23 +123,137 @@ func TestImportAliasesReserveFixedJSON(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("generated.local/gen", nil) - + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) aliases, err := newImportAliases(root, generation) require.NoError(t, err) require.Equal(t, "json", aliases.name("encoding/json")) require.Equal(t, "json2", aliases.name("example.com/custom/json")) } +// TestDocumentedJSONMetadataUsesCanonicalAlias verifies that an alternate +// metadata spelling for encoding/json produces one import and one canonical +// qualifier in the generated service definition. +func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("raw", dsl.String, func() { + dsl.Meta("struct:field:type", "jason.RawMessage", "encoding/json", "jason") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + services := mustServicesData(t, root) + file := findFile(Files(services.generation.GenPkg, []*ServicesData{services}), path.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, "json.RawMessage") + require.Equal(t, 1, strings.Count(code, `"encoding/json"`), code) + require.NotContains(t, code, "jason.RawMessage") +} + +// TestExampleServiceUsesCanonicalGeneratedPackageQualifier verifies that a +// metadata package cannot steal the qualifier reserved for a generated +// service package. +func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "example.com/custom/values", "values") + }) + }) + }) + }) + services := mustServicesData(t, root) + servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Values")) + require.Equal(t, "values", services.aliases.name(servicePath)) + require.Equal(t, "values2", services.aliases.name("example.com/custom/values")) + + files := ExampleServiceFiles(services.generation.GenPkg, root, services) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, "values.Service") + require.Contains(t, code, "p values2.Value") +} + +// TestExampleServiceReservesFixedQualifiers verifies that standard library +// and generated service imports retain their template qualifiers when service +// metadata or names request the same spelling. +func TestExampleServiceReservesFixedQualifiers(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "example.com/custom/strings", "strings") + }) + }) + }) + }) + services := mustServicesData(t, root) + servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Fmt")) + servicePkg := services.aliases.name(servicePath) + require.NotEqual(t, "fmt", servicePkg) + require.Equal(t, "strings2", services.aliases.name("example.com/custom/strings")) + + files := ExampleServiceFiles(services.generation.GenPkg, root, services) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, servicePkg+".Service") + require.Contains(t, code, "p strings2.Value") +} + +// TestServiceUsesCanonicalViewsQualifier verifies that design metadata cannot +// steal the qualifier reserved for the generated views package. +func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "example.com/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + services := mustServicesData(t, root) + servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Values")) + viewsPath := servicePath + "/views" + require.Equal(t, "valuesviews", services.aliases.name(viewsPath)) + require.Equal(t, "valuesviews2", services.aliases.name("example.com/custom/views")) + + file := findFile(Files(services.generation.GenPkg, []*ServicesData{services}), path.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, `valuesviews "`+viewsPath+`"`) + require.Contains(t, code, `valuesviews2 "example.com/custom/views"`) +} + // TestUnionFieldReferencesUseFixedImportAliases verifies that the qualifier in // a union field type and the import declaration come from the same frozen path // binding when encoding/json already owns the preferred json name. func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { - plan := &importAliasPlan{candidates: make(map[string]importAliasCandidate)} - require.NoError(t, plan.addFixedImports()) - require.NoError(t, plan.add("generated.local/gen/values", "values", true, false)) - require.NoError(t, plan.add("example.com/custom/json", "json", true, false)) - aliases := plan.freeze() + generation := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.ReserveImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generation.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) + require.NoError(t, generation.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) service := &expr.ServiceExpr{Name: "Values"} branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, @@ -87,11 +265,11 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { Attribute: branch, }}, } - generation := codegen.NewGeneration("generated.local/gen", nil) generatedPackage := generation.GeneratedPackage("generated.local/gen/values") _, err := generatedPackage.DeclareUnion(union) require.NoError(t, err) require.NoError(t, generation.Freeze()) + aliases := &importAliases{generation: generation} declaration, err := generatedPackage.Union(union) require.NoError(t, err) data, err := buildUnionTypeData( diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index e34ca006d4..aff9049455 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -8,21 +8,22 @@ import ( ) // InterceptorsFiles returns the interceptors files for the given service. -func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { +func InterceptorsFiles(genpkg string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { var files []*codegen.File svc := services.Get(service.Name) + outputPackage := genpkg + "/" + svc.PathName // Generate service-specific interceptor files if len(svc.ServerInterceptors) > 0 { - files = append(files, interceptorFile(svc, true)) + files = append(files, interceptorFile(svc, services, outputPackage, true)) } if len(svc.ClientInterceptors) > 0 { - files = append(files, interceptorFile(svc, false)) + files = append(files, interceptorFile(svc, services, outputPackage, false)) } // Generate wrapper file if this service has any interceptors if len(svc.ServerInterceptors) > 0 || len(svc.ClientInterceptors) > 0 { - files = append(files, wrapperFile(svc)) + files = append(files, wrapperFile(svc, services, outputPackage)) } return files @@ -30,7 +31,7 @@ func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesDa // interceptorFile returns the file defining the interceptors. // This method is called twice, once for the server and once for the client. -func interceptorFile(svc *Data, server bool) *codegen.File { +func interceptorFile(svc *Data, services *ServicesData, outputPackage string, server bool) *codegen.File { filename := "client_interceptors.go" template := clientInterceptorsT section := "client-interceptors-type" @@ -67,10 +68,10 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } sections := []*codegen.SectionTemplate{ - codegen.Header(desc, svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - codegen.GoaImport(""), - }), + codegen.Header(desc, svc.PkgName, services.fileImports(outputPackage, []string{ + "context", + codegen.GoaImport("").Path, + })), { Name: section, Source: serviceTemplates.Read(template), @@ -130,15 +131,14 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } // wrapperFile returns the file containing the interceptor wrappers. -func wrapperFile(svc *Data) *codegen.File { +func wrapperFile(svc *Data, services *ServicesData, outputPackage string) *codegen.File { path := filepath.Join(codegen.Gendir, svc.PathName, "interceptor_wrappers.go") var sections []*codegen.SectionTemplate - sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - codegen.GoaImport(""), - })) + sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, services.fileImports(outputPackage, []string{ + "context", + codegen.GoaImport("").Path, + }))) // Generate any interceptor stream wrapper struct types first var wrappedServerStreams, wrappedClientStreams []*StreamInterceptorData diff --git a/codegen/service/service.go b/codegen/service/service.go index 761e17961f..18fa1f2b97 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -159,16 +159,9 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa }) } - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("context"), - codegen.SimpleImport("io"), - codegen.GoaImport(""), - codegen.GoaImport("security"), - codegen.NewImport(svc.ViewsPkg, genpkg+"/"+svcName+"/views"), - } outputPackage := genpkg + "/" + svcName attributes := serviceReferenceAttributes(service) - attributes = append(attributes, normalizedMethodDefinitions(service)...) + attributes = append(attributes, emittedMethodDefinitions(service, svc)...) for _, userType := range svc.userTypes { if userType.Loc == nil { attributes = append(attributes, userType.Type.Attribute()) @@ -179,7 +172,13 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa attributes = append(attributes, errorType.Type.Attribute()) } } - imports = append(imports, services.AttributeImports(outputPackage, attributes...)...) + imports := services.fileImports(outputPackage, []string{ + "context", + "io", + codegen.GoaImport("").Path, + codegen.GoaImport("security").Path, + outputPackage + "/views", + }, attributes...) header := codegen.Header(service.Name+" service", svc.PkgName, imports) def := &codegen.SectionTemplate{ Name: "service", @@ -208,39 +207,33 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa return append(files, InterceptorsFiles(genpkg, service, services)...) } -// normalizedMethodDefinitions returns the underlying object definitions -// emitted for semantic method wrappers in service.go. Their nested references -// contribute imports even though endpoint and client files stop at the wrapper -// declaration. -func normalizedMethodDefinitions(service *expr.ServiceExpr) []*expr.AttributeExpr { - var definitions []*expr.AttributeExpr - for _, method := range service.Methods { - definitions = append(definitions, normalizedMethodDefinitionsFor(method)...) - } - return definitions -} - -// normalizedMethodDefinitionsFor returns the raw object definitions emitted -// for one method so service.go imports their nested references. -func normalizedMethodDefinitionsFor(method *expr.MethodExpr) []*expr.AttributeExpr { +// emittedMethodDefinitions returns the underlying method type definitions +// written to service.go so their nested references contribute imports. +func emittedMethodDefinitions(service *expr.ServiceExpr, data *Data) []*expr.AttributeExpr { var definitions []*expr.AttributeExpr - definitions = appendNormalizedMethodDefinition(definitions, method, method.Payload, "Payload") - definitions = appendNormalizedMethodDefinition(definitions, method, method.StreamingPayload, "StreamingPayload") - definitions = appendNormalizedMethodDefinition(definitions, method, method.Result, "Result") - if method.HasMixedResults() { - definitions = appendNormalizedMethodDefinition(definitions, method, method.StreamingResult, "StreamingResult") + for index, method := range service.Methods { + methodData := data.Methods[index] + if methodData.PayloadLoc == nil && methodData.PayloadDef != "" { + definitions = appendUserTypeDefinition(definitions, method.Payload) + } + if method.StreamingPayload != nil && codegen.UserTypeLocation(method.StreamingPayload.Type) == nil && methodData.StreamingPayloadDef != "" { + definitions = appendUserTypeDefinition(definitions, method.StreamingPayload) + } + if methodData.ResultLoc == nil && methodData.ResultDef != "" { + definitions = appendUserTypeDefinition(definitions, method.Result) + } + if method.HasMixedResults() && codegen.UserTypeLocation(method.StreamingResult.Type) == nil && methodData.StreamingResultDef != "" { + definitions = appendUserTypeDefinition(definitions, method.StreamingResult) + } } return definitions } -// appendNormalizedMethodDefinition appends the underlying object only when -// attribute is the semantic wrapper created for the requested method role. -func appendNormalizedMethodDefinition(definitions []*expr.AttributeExpr, method *expr.MethodExpr, attribute *expr.AttributeExpr, suffix string) []*expr.AttributeExpr { - if attribute == nil { - return definitions - } +// appendUserTypeDefinition appends the definition rendered for a named method +// attribute. +func appendUserTypeDefinition(definitions []*expr.AttributeExpr, attribute *expr.AttributeExpr) []*expr.AttributeExpr { userType, ok := attribute.Type.(expr.UserType) - if !ok || userType.ID() != normalizedMethodTypeID(method.Service, method, suffix) { + if !ok { return definitions } return append(definitions, userType.Attribute()) @@ -278,15 +271,11 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F sort.Slice(generatedTypes, func(i, j int) bool { return generatedTypes[i].declaration.Name() < generatedTypes[j].declaration.Name() }) - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("fmt"), - codegen.GoaImport(""), - } collector := newImportCollector(aliases, genpkg, packagePath) for _, generatedType := range generatedTypes { collector.collect(generatedType.userType.Attribute()) } - imports = append(imports, collector.imports()...) + imports := collector.imports() sections := []*codegen.SectionTemplate{ codegen.Header("User types", generatedPackage.packageName, imports), } @@ -307,20 +296,17 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F sort.Slice(unions, func(i, j int) bool { return unions[i].Name < unions[j].Name }) - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - codegen.GoaImport(""), - } collector := newImportCollector(aliases, genpkg, packagePath) + for _, importPath := range []string{"bytes", "encoding/json", "fmt", codegen.GoaImport("").Path} { + collector.addPath(importPath) + } for _, union := range unions { for _, field := range union.Fields { collector.collect(field.reference) collector.collect(field.definition) } } - imports = append(imports, collector.imports()...) + imports := collector.imports() sections := []*codegen.SectionTemplate{ codegen.Header("Union types", generatedPackage.packageName, imports), } diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index cfdd15ae3a..5c4e1fa092 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -2029,18 +2029,13 @@ func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserT // pre-normalization shape by traversing those synthetic wrappers' attributes // directly instead of generating view-local types for the wrappers themselves. func projectedResultRoot(service *expr.ServiceExpr, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok && ut.ID() == normalizedMethodTypeID(service, m, "Result") { + identity := codegen.NewMethodResultIdentity(service.Name, m.Name) + if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok && identity.Matches(ut) { return expr.DupAtt(ut.Attribute()), ut.Attribute() } return expr.DupAtt(m.Result), m.Result } -// normalizedMethodTypeID returns the semantic identifier assigned when -// NormalizeRoot wraps a raw method object in a generated user type. -func normalizedMethodTypeID(service *expr.ServiceExpr, m *expr.MethodExpr, suffix string) string { - return service.Name + "#" + codegen.Goify(m.Name, true) + suffix -} - // hasResultType returns true if the given attribute has a result type recursively. func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { if _, ok := att.Type.(*expr.ResultTypeExpr); ok { diff --git a/codegen/service/templates/example_service_init.go.tpl b/codegen/service/templates/example_service_init.go.tpl index 0e9a9fca28..ac3488af59 100644 --- a/codegen/service/templates/example_service_init.go.tpl +++ b/codegen/service/templates/example_service_init.go.tpl @@ -1,4 +1,4 @@ {{ printf "New%s returns the %s service implementation." .StructName .Name | comment }} -func New{{ .StructName }}() {{ .PkgName }}.Service { +func New{{ .StructName }}() {{ .ServicePkg }}.Service { return &{{ .VarName }}srvc{} } diff --git a/codegen/service/templates/jsonrpc_handle_stream.go.tpl b/codegen/service/templates/jsonrpc_handle_stream.go.tpl index 310a979a79..e448f03d2d 100644 --- a/codegen/service/templates/jsonrpc_handle_stream.go.tpl +++ b/codegen/service/templates/jsonrpc_handle_stream.go.tpl @@ -2,7 +2,7 @@ // communication between the server and client. It receives requests from the // client, dispatches them to the appropriate service methods, and can send // server-initiated messages back to the client as needed. -func (s *{{ .VarName }}srvc) HandleStream(ctx context.Context, stream {{ .PkgName }}.Stream) error { +func (s *{{ .VarName }}srvc) HandleStream(ctx context.Context, stream {{ .ServicePkg }}.Stream) error { log.Printf(ctx, "{{ .VarName }}.HandleStream") // Example: In a real implementation you might read from an event source diff --git a/codegen/service/testdata/dedup_event_marker_dsls.go b/codegen/service/testdata/dedup_event_marker_dsls.go index 75f0625e3b..74a47efb07 100644 --- a/codegen/service/testdata/dedup_event_marker_dsls.go +++ b/codegen/service/testdata/dedup_event_marker_dsls.go @@ -1,26 +1,26 @@ package testdata import ( - . "goa.design/goa/v3/dsl" + . "goa.design/goa/v3/dsl" ) // StreamingDuplicateResultTypesDSL defines two streaming methods that share the same // result type to ensure event marker methods are not duplicated in generated service code. var StreamingDuplicateResultTypesDSL = func() { - API("dedup-streaming", func() { JSONRPC(func() {}) }) - var SharedEvent = Type("SharedEvent", func() { - Attribute("message", String) - Required("message") - }) - Service("DupStreamService", func() { - JSONRPC(func() { POST("/stream") }) - Method("A", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("B", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) + API("dedup-streaming", func() { JSONRPC(func() {}) }) + var SharedEvent = Type("SharedEvent", func() { + Attribute("message", String) + Required("message") + }) + Service("DupStreamService", func() { + JSONRPC(func() { POST("/stream") }) + Method("A", func() { + StreamingResult(SharedEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + Method("B", func() { + StreamingResult(SharedEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + }) } diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden index 09d7f741b7..d0b28f08e3 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceClientInterceptors struct { func NewAPIInterceptorServiceClientInterceptors() *APIInterceptorServiceClientInterceptors { return &APIInterceptorServiceClientInterceptors{} } -func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info *apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden index 0cf0d6ca49..218c82a154 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceServerInterceptors struct { func NewAPIInterceptorServiceServerInterceptors() *APIInterceptorServiceServerInterceptors { return &APIInterceptorServiceServerInterceptors{} } -func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info *apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden index b719d5252f..7715b020c9 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceClientInterceptors struct { func NewChainedInterceptorServiceClientInterceptors() *ChainedInterceptorServiceClientInterceptors { return &ChainedInterceptorServiceClientInterceptors{} } -func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info *chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info *chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info *chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden index e9a70a5564..6634a3ba3c 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceServerInterceptors struct { func NewChainedInterceptorServiceServerInterceptors() *ChainedInterceptorServiceServerInterceptors { return &ChainedInterceptorServiceServerInterceptors{} } -func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info *chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info *chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info *chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden index 72713cbf1b..4c196ac129 100644 --- a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - clientinterceptorservice "client_interceptor_service" "context" - "fmt" "goa.design/clue/log" + clientinterceptorservice "goa.design/goa/example/client_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ClientInterceptorServiceClientInterceptors struct { func NewClientInterceptorServiceClientInterceptors() *ClientInterceptorServiceClientInterceptors { return &ClientInterceptorServiceClientInterceptors{} } -func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info *clientinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden index 55126006cb..f6d3f0370a 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceClientInterceptors struct { func NewMultipleInterceptorsServiceClientInterceptors() *MultipleInterceptorsServiceClientInterceptors { return &MultipleInterceptorsServiceClientInterceptors{} } -func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *multipleinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *multipleinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden index 6ddbb67861..2f449e7b7a 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceServerInterceptors struct { func NewMultipleInterceptorsServiceServerInterceptors() *MultipleInterceptorsServiceServerInterceptors { return &MultipleInterceptorsServiceServerInterceptors{} } -func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *multipleinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *multipleinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden index be0c25041b..962a815f08 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ClientInterceptors struct { func NewMultipleServicesInterceptorsService2ClientInterceptors() *MultipleServicesInterceptorsService2ClientInterceptors { return &MultipleServicesInterceptorsService2ClientInterceptors{} } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info *multipleservicesinterceptorsservice2.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx conte log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info *multipleservicesinterceptorsservice2.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden index a36e8d46f5..11eb2c4b7a 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ServerInterceptors struct { func NewMultipleServicesInterceptorsService2ServerInterceptors() *MultipleServicesInterceptorsService2ServerInterceptors { return &MultipleServicesInterceptorsService2ServerInterceptors{} } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info *multipleservicesinterceptorsservice2.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx contex log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info *multipleservicesinterceptorsservice2.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden index c344ea098b..ce06395fa3 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceClientInterceptors struct { func NewMultipleServicesInterceptorsServiceClientInterceptors() *MultipleServicesInterceptorsServiceClientInterceptors { return &MultipleServicesInterceptorsServiceClientInterceptors{} } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *multipleservicesinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *multipleservicesinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden index aa349d4854..4126620994 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceServerInterceptors struct { func NewMultipleServicesInterceptorsServiceServerInterceptors() *MultipleServicesInterceptorsServiceServerInterceptors { return &MultipleServicesInterceptorsServiceServerInterceptors{} } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *multipleservicesinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *multipleservicesinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden index da77a3dedb..b973a20e52 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorbynameservice "goa.design/goa/example/server_interceptor_by_name_service" goa "goa.design/goa/v3/pkg" - serverinterceptorbynameservice "server_interceptor_by_name_service" ) // ServerInterceptorByNameServiceServerInterceptors implements the server interceptor for the ServerInterceptorByNameService service. @@ -22,7 +21,7 @@ type ServerInterceptorByNameServiceServerInterceptors struct { func NewServerInterceptorByNameServiceServerInterceptors() *ServerInterceptorByNameServiceServerInterceptors { return &ServerInterceptorByNameServiceServerInterceptors{} } -func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info *serverinterceptorbynameservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden index 7adff8b41a..93d2b2f494 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorservice "goa.design/goa/example/server_interceptor_service" goa "goa.design/goa/v3/pkg" - serverinterceptorservice "server_interceptor_service" ) // ServerInterceptorServiceServerInterceptors implements the server interceptor for the ServerInterceptorService service. @@ -22,7 +21,7 @@ type ServerInterceptorServiceServerInterceptors struct { func NewServerInterceptorServiceServerInterceptors() *ServerInterceptorServiceServerInterceptors { return &ServerInterceptorServiceServerInterceptors{} } -func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info *serverinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/views.go b/codegen/service/views.go index 401ab5a385..df2818ace0 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -57,16 +57,9 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go") outputPackage := genpkg + "/" + svc.PathName + "/views" - imports := []*codegen.ImportSpec{ - codegen.GoaImport(""), - {Path: "unicode/utf8"}, - } + importPaths := []string{codegen.GoaImport("").Path, "unicode/utf8"} if len(unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) + importPaths = append(importPaths, "bytes", "encoding/json", "fmt") } var attributes []*expr.AttributeExpr for _, viewed := range svc.viewedResultTypes { @@ -75,7 +68,7 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) for _, projected := range svc.projectedTypes { attributes = append(attributes, projected.Type.Attribute()) } - imports = append(imports, services.AttributeImports(outputPackage, attributes...)...) + imports := services.fileImports(outputPackage, importPaths, attributes...) header := codegen.Header(service.Name+" views", "views", imports) sections := []*codegen.SectionTemplate{header} diff --git a/codegen/validation_test.go b/codegen/validation_test.go index 9f4375c318..6e756c6e19 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -134,26 +134,47 @@ func TestRecursiveValidationWithCycleGuard(t *testing.T) { } } -// TestRecursiveValidationDistinguishesEqualUIDOrigins verifies that compiler -// copies share recursion state only through their exact declaration origin; -// unrelated user types with the same semantic UID receive distinct buffers. +// TestRecursiveValidationDistinguishesEqualUIDOrigins verifies that unrelated +// user types with the same semantic UID retain their distinct validation +// shapes when reached in one recursive validation pass. func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { + minLength := 3 + minimum := 5.0 first := &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, - TypeName: "First", - UID: "shared", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "code", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + TypeName: "First", + UID: "shared", } second := &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, - TypeName: "Second", - UID: "shared", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "count", + Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }, + }, + }}, + TypeName: "Second", + UID: "shared", } ctx := NewAttributeContext(false, false, false, "", NewNameScope()) seen := make(map[expr.UserType]*bytes.Buffer) + firstCode := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", "first", seen).String() + secondCode := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", "second", seen).String() - firstBuffer := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", "first", seen) - secondBuffer := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", "second", seen) - require.NotSame(t, firstBuffer, secondBuffer) + require.Contains(t, firstCode, "first.Code") + require.Contains(t, firstCode, "InvalidLengthError") + require.Contains(t, secondCode, "second.Count") + require.Contains(t, secondCode, "InvalidRangeError") require.Len(t, seen, 2) } diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index fcbfe8ee75..01faf06045 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -6,6 +6,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -28,7 +29,7 @@ func CreateGRPCServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", nil) + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 3ee78ab97f..3fc47ee076 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -3,6 +3,7 @@ package codegen import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -17,7 +18,7 @@ func CreateHTTPServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", nil) + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 790b24de70..234c518254 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -3,6 +3,7 @@ package codegen import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) @@ -19,7 +20,7 @@ func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", nil) + generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } From 1bf62f516d1f1977a966f8f7f586a7fa555cb5fe Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 03:30:53 -0700 Subject: [PATCH 19/43] codegen: prioritize required import aliases --- codegen/ARCHITECTURE.md | 13 ++ codegen/example/example_server_test.go | 2 + codegen/generated_types_test.go | 25 ++++ codegen/generation.go | 25 ++-- codegen/generator/example.go | 22 ++-- codegen/generator/generation_test.go | 4 +- codegen/generator/generators.go | 2 +- codegen/generator/openapi.go | 2 +- codegen/generator/service.go | 12 +- .../service_union_package_scope_test.go | 41 ++++++- codegen/generator/transport.go | 34 +++--- codegen/import_aliases.go | 111 ++++++++++++----- codegen/import_aliases_test.go | 114 ++++++++++++++++++ codegen/plugin_test.go | 4 +- codegen/service/convert.go | 12 +- codegen/service/declaration_resolver.go | 6 +- codegen/service/declaration_resolver_test.go | 12 +- codegen/service/example_interceptors_test.go | 4 +- codegen/service/example_svc_test.go | 4 +- codegen/service/generated_package.go | 18 +-- codegen/service/imports.go | 14 +-- codegen/service/imports_test.go | 73 +++++++++-- codegen/service/interceptors.go | 2 + codegen/service/service_data.go | 16 +-- .../service/service_data_union_order_test.go | 6 +- .../testdata/dedup_event_marker_dsls.go | 2 + grpc/codegen/testing.go | 2 + http/codegen/testing.go | 2 + jsonrpc/codegen/testing.go | 2 + 29 files changed, 454 insertions(+), 132 deletions(-) create mode 100644 codegen/import_aliases_test.go diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index e50a8a9219..77a7695816 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -53,6 +53,19 @@ path. Each catalog entry represents one Go package and owns: - the final names of each union type, discriminator type, constants, and constructors. +The generation copies its evaluated roots and generated module import path at +construction. Callers can read those values but cannot replace the registered +roots or redirect output packages after planning begins. Planning and rendering +therefore test membership against the same root snapshot. + +The generation also owns one import qualifier for each complete import path. +Imports referenced by static templates have required qualifiers, generated +service and views packages have preferred qualifiers, and design metadata has +lower-priority preferences. Required qualifiers are allocated first and +conflicting requirements are rejected; generated and metadata qualifiers may +receive deterministic suffixes. Each generated file still imports only the +paths used by the declarations and references it renders. + Planning a declaration returns its canonical record. Once every selected generator and plugin has planned its output, the context freezes the catalog. Rendering may only look up those records; a late attempt to add a declaration diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 90905d9ab2..5eb478f21b 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -1,3 +1,5 @@ +// This file verifies that generated example servers and command-line programs +// contain the service and transport wiring required by representative designs. package example import ( diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 15eafad495..7d4a7249f5 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -56,6 +56,31 @@ func TestGenerationOwnsPackageRecords(t *testing.T) { require.NotSame(t, first.Scope(), other.Scope()) } +// TestGenerationCopiesConstructionState verifies that callers cannot change +// root membership or the generated package path through constructor inputs or +// accessor results before or after freeze. +func TestGenerationCopiesConstructionState(t *testing.T) { + first := RunDSL(t, func() {}) + second := RunDSL(t, func() {}) + roots := []eval.Root{first} + generation := NewGeneration("generated.local/gen", roots) + + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) + + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) +} + // TestGeneratedPackageUserTypes verifies that a generated package records one // declaration per user type and that lookups do not reserve names. func TestGeneratedPackageUserTypes(t *testing.T) { diff --git a/codegen/generation.go b/codegen/generation.go index 1c3263bebe..c580754a69 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -13,11 +13,8 @@ type ( // Generation owns the evaluated design roots and generated-package naming // catalogs for one standalone code generation run. Generation struct { - // GenPkg is the import path of the generated module root. - GenPkg string - // Roots contains the evaluated DSL roots participating in the run. - Roots []eval.Root - + genpkg string + roots []eval.Root packages map[string]*GeneratedPackage importPlan *importAliasPlan imports map[string]importAliasBinding @@ -28,8 +25,8 @@ type ( // NewGeneration creates an independent generation catalog for roots. func NewGeneration(genpkg string, roots []eval.Root) *Generation { return &Generation{ - GenPkg: genpkg, - Roots: append([]eval.Root(nil), roots...), + genpkg: genpkg, + roots: append([]eval.Root(nil), roots...), packages: make(map[string]*GeneratedPackage), importPlan: &importAliasPlan{ candidates: make(map[string]*importAliasCandidate), @@ -37,6 +34,16 @@ func NewGeneration(genpkg string, roots []eval.Root) *Generation { } } +// GenPkg returns the import path of the generated module root. +func (g *Generation) GenPkg() string { + return g.genpkg +} + +// Roots returns a copy of the evaluated DSL roots participating in the run. +func (g *Generation) Roots() []eval.Root { + return append([]eval.Root(nil), g.roots...) +} + // GeneratedPackage returns the naming catalog for path, creating it before // the generation is frozen. It panics if path was not planned before freeze. func (g *Generation) GeneratedPackage(path string) *GeneratedPackage { @@ -58,10 +65,12 @@ func (g *Generation) Freeze() error { if g.frozen { return nil } + if err := g.freezeImports(); err != nil { + return err + } for _, generatedPackage := range g.packages { generatedPackage.freeze() } - g.freezeImports() g.frozen = true return nil } diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 657285e9e0..21560559b3 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -15,39 +15,39 @@ import ( // example service, server, and client. func Example(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - designRoots := serviceRoots(generation.Roots) + designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { services, err := service.NewServicesData(r, generation) if err != nil { return nil, err } // example service implementation - if fs := service.ExampleServiceFiles(generation.GenPkg, r, services); len(fs) != 0 { + if fs := service.ExampleServiceFiles(generation.GenPkg(), r, services); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(generation.GenPkg, r, services); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(generation.GenPkg(), r, services); len(fs) != 0 { files = append(files, fs...) } // server main - if fs := example.ServerFiles(generation.GenPkg, r, services); len(fs) != 0 { + if fs := example.ServerFiles(generation.GenPkg(), r, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(generation.GenPkg, r); len(fs) != 0 { + if fs := example.CLIFiles(generation.GenPkg(), r); len(fs) != 0 { files = append(files, fs...) } // HTTP if len(r.API.HTTP.Services) > 0 { httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(generation.GenPkg, httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleServerFiles(generation.GenPkg(), httpServices); len(fs) != 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg, httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg(), httpServices); len(fs) != 0 { files = append(files, fs...) } } @@ -55,10 +55,10 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { // JSON-RPC if len(r.API.JSONRPC.Services) > 0 { jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(generation.GenPkg, jsonrpcServices, files); len(fs) > 0 { + if fs := jsonrpccodegen.ExampleServerFiles(generation.GenPkg(), jsonrpcServices, files); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg, jsonrpcServices); len(fs) > 0 { + if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg(), jsonrpcServices); len(fs) > 0 { files = append(files, fs...) } } @@ -66,10 +66,10 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { // GRPC if len(r.API.GRPC.Services) > 0 { grpcServices := grpccodegen.NewServicesData(services) - if fs := grpccodegen.ExampleServerFiles(generation.GenPkg, grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleServerFiles(generation.GenPkg(), grpcServices); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(generation.GenPkg, grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleCLIFiles(generation.GenPkg(), grpcServices); len(fs) > 0 { files = append(files, fs...) } } diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index fab55dbd1c..df9044e7eb 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -34,7 +34,7 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { if planned != generation { return fmt.Errorf("generation changed between plan and render") } - if len(generation.Roots) != len(preparedRoots) { + if len(generation.Roots()) != len(preparedRoots) { return fmt.Errorf("generation roots changed after plugin preparation") } return nil @@ -45,7 +45,7 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { Plan: func(generation *codegen.Generation) error { events = append(events, "core-plan-first") planned = generation - typesPath = generation.GenPkg + "/types" + typesPath = generation.GenPkg() + "/types" _, err := generation.GeneratedPackage(typesPath).DeclareUnion(union) return err }, diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 0925d5e692..de2e7c3f82 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -47,7 +47,7 @@ func generators(cmd string) ([]Genfunc, error) { func renderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) Genfunc { return Genfunc{ Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { - return generate(generation.GenPkg, generation.Roots) + return generate(generation.GenPkg(), generation.Roots()) }, } } diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 2ed9b9df6a..18e5b88a17 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -12,7 +12,7 @@ import ( // the service OpenAPI spec. It produces OpenAPI specifications only if the // roots define a HTTP service. func OpenAPI(generation *codegen.Generation) ([]*codegen.File, error) { - designRoots := serviceRoots(generation.Roots) + designRoots := serviceRoots(generation.Roots()) for _, root := range designRoots { if _, err := service.NewServicesData(root, generation); err != nil { return nil, err diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 9ae6cbab73..97752dbd42 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -14,7 +14,7 @@ import ( // a goa design. func Service(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - designRoots := serviceRoots(generation.Roots) + designRoots := serviceRoots(generation.Roots()) analyses := make([]*service.ServicesData, len(designRoots)) for i, r := range designRoots { services, err := service.NewServicesData(r, generation) @@ -25,12 +25,12 @@ func Service(generation *codegen.Generation) ([]*codegen.File, error) { for _, s := range r.Services { endpointFiles := []*codegen.File{ - service.EndpointFile(generation.GenPkg, s, services), - service.ClientFile(generation.GenPkg, s, services), + service.EndpointFile(generation.GenPkg(), s, services), + service.ClientFile(generation.GenPkg(), s, services), } files = append(files, endpointFiles...) - if f := service.ViewsFile(generation.GenPkg, s, services); f != nil { + if f := service.ViewsFile(generation.GenPkg(), s, services); f != nil { files = append(files, f) } convFiles, err := service.ConvertFiles(r, s, services) @@ -40,14 +40,14 @@ func Service(generation *codegen.Generation) ([]*codegen.File, error) { files = append(files, convFiles...) } } - svcFiles := service.Files(generation.GenPkg, analyses) + svcFiles := service.Files(generation.GenPkg(), analyses) return append(svcFiles, files...), nil } // planServiceData declares service-owned generated package types for every Goa // design root in generation. func planServiceData(generation *codegen.Generation) error { - for _, root := range serviceRoots(generation.Roots) { + for _, root := range serviceRoots(generation.Roots()) { if err := service.Plan(root, generation); err != nil { return err } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index f0ca11db67..9e06770955 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -601,7 +601,7 @@ func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { dir := t.TempDir() files, err := Service(generation) require.NoError(t, err) - files = append(files, servicecodegen.ExampleServiceFiles(generation.GenPkg, root, services)...) + files = append(files, servicecodegen.ExampleServiceFiles(generation.GenPkg(), root, services)...) for _, file := range files { _, err := file.Render(dir) require.NoError(t, err) @@ -613,6 +613,45 @@ func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { runGeneratedTests(t, dir) } +// TestFixedRuntimeAliasesCompileWithGoaAndLogServices verifies that generated +// service imports are suffixed when static interceptor templates require the +// goa and log qualifiers for their runtime packages. +func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, servicecodegen.Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := servicecodegen.NewServicesData(root, generation) + require.NoError(t, err) + + dir := t.TempDir() + files, err := Service(generation) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleInterceptorsFiles(generation.GenPkg(), root, services)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + goaSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "goa_server.go")) + require.NoError(t, err) + require.Contains(t, string(goaSource), `goa "goa.design/goa/v3/pkg"`) + require.Contains(t, string(goaSource), `goa2 "generated.local/gen/goa"`) + logSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "log_server.go")) + require.NoError(t, err) + require.Contains(t, string(logSource), `"goa.design/clue/log"`) + require.Contains(t, string(logSource), `log2 "generated.local/gen/log"`) + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + // unusedRelocatedValueRoot declares a relocated type that no service reaches // and does not force generation. It must not reserve a generated package name. func unusedRelocatedValueRoot() func() { diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 429e8c1360..3767502a49 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -14,7 +14,7 @@ import ( // the transport code. func Transport(generation *codegen.Generation) ([]*codegen.File, error) { var files []*codegen.File - designRoots := serviceRoots(generation.Roots) + designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { services, err := service.NewServicesData(r, generation) if err != nil { @@ -22,30 +22,30 @@ func Transport(generation *codegen.Generation) ([]*codegen.File, error) { } // HTTP httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(generation.GenPkg, httpServices)...) - files = append(files, httpcodegen.ClientFiles(generation.GenPkg, httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg, httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg, httpServices)...) + files = append(files, httpcodegen.ServerFiles(generation.GenPkg(), httpServices)...) + files = append(files, httpcodegen.ClientFiles(generation.GenPkg(), httpServices)...) + files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg(), httpServices)...) + files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg(), httpServices)...) files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg, httpServices)...) + files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg(), httpServices)...) // GRPC grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(generation.GenPkg, grpcServices)...) - files = append(files, grpccodegen.ServerFiles(generation.GenPkg, grpcServices)...) - files = append(files, grpccodegen.ClientFiles(generation.GenPkg, grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(generation.GenPkg, grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(generation.GenPkg, grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(generation.GenPkg, grpcServices)...) + files = append(files, grpccodegen.ProtoFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ServerFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ClientFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ServerTypeFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ClientTypeFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ClientCLIFiles(generation.GenPkg(), grpcServices)...) // JSON-RPC jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(generation.GenPkg, jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(generation.GenPkg, jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg, jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg, jsonrpcServices)...) + files = append(files, jsonrpccodegen.ServerFiles(generation.GenPkg(), jsonrpcServices)...) + files = append(files, jsonrpccodegen.ClientFiles(generation.GenPkg(), jsonrpcServices)...) + files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg(), jsonrpcServices)...) + files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg(), jsonrpcServices)...) files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg, jsonrpcServices)...) + files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg(), jsonrpcServices)...) } return files, nil } diff --git a/codegen/import_aliases.go b/codegen/import_aliases.go index 209a7fabbb..c5aab8ae33 100644 --- a/codegen/import_aliases.go +++ b/codegen/import_aliases.go @@ -12,17 +12,20 @@ import ( ) type ( + // importPriority identifies the closed import ownership classes used during + // deterministic qualifier allocation. + importPriority uint8 + // importAliasPlan records every preferred spelling for a complete package // path before qualifiers are allocated. importAliasPlan struct { candidates map[string]*importAliasCandidate } - // importAliasCandidate retains generator-reserved and design-preferred names - // separately so generator template imports take priority deterministically. + // importAliasCandidate retains requested names by ownership class so the + // highest-priority request for one complete path wins. importAliasCandidate struct { - reserved map[string]bool - preferred map[string]bool + spellings [importPriorityCount]map[string]bool } // importAliasBinding records the qualifier and whether its import declaration @@ -33,16 +36,30 @@ type ( } ) -// ReserveImport declares a generator-owned import. Its spelling takes -// priority over design metadata that names the same path differently. -func (g *Generation) ReserveImport(spec *ImportSpec) error { - return g.declareImport(spec, true) +const ( + fixedImportPriority importPriority = iota + generatedImportPriority + metadataImportPriority + importPriorityCount +) + +// RequireImport declares an import whose qualifier is required by static +// generated code. Two different required qualifiers for one path are rejected. +func (g *Generation) RequireImport(spec *ImportSpec) error { + return g.declareImport(spec, fixedImportPriority) +} + +// ReserveGeneratedImport declares a preferred qualifier for a generated +// package. Required static imports take priority and may cause it to be +// suffixed. +func (g *Generation) ReserveGeneratedImport(spec *ImportSpec) error { + return g.declareImport(spec, generatedImportPriority) } // DeclareImport declares a design-owned import. Repeated declarations of one // complete path are merged before freeze. func (g *Generation) DeclareImport(spec *ImportSpec) error { - return g.declareImport(spec, false) + return g.declareImport(spec, metadataImportPriority) } // Import returns the frozen import declaration for importPath. It panics when @@ -61,7 +78,7 @@ func (g *Generation) ImportName(importPath string) string { // HasRoot reports whether root is one of the exact evaluated roots registered // when the generation was constructed. func (g *Generation) HasRoot(root eval.Root) bool { - for _, registered := range g.Roots { + for _, registered := range g.roots { if registered == root { return true } @@ -70,7 +87,7 @@ func (g *Generation) HasRoot(root eval.Root) bool { } // declareImport merges one path spelling into the generation plan. -func (g *Generation) declareImport(spec *ImportSpec, reserved bool) error { +func (g *Generation) declareImport(spec *ImportSpec, priority importPriority) error { if g.frozen { return fmt.Errorf("generation imports are frozen") } @@ -83,50 +100,76 @@ func (g *Generation) declareImport(spec *ImportSpec, reserved bool) error { } candidate, ok := g.importPlan.candidates[importPath] if !ok { - candidate = &importAliasCandidate{ - reserved: make(map[string]bool), - preferred: make(map[string]bool), - } + candidate = &importAliasCandidate{} g.importPlan.candidates[importPath] = candidate } - spellings := candidate.preferred - if reserved { - spellings = candidate.reserved + spellings := candidate.spellings[priority] + if spellings == nil { + spellings = make(map[string]bool) + candidate.spellings[priority] = spellings + } + if priority == fixedImportPriority && len(spellings) > 0 { + required, _ := firstImportSpelling(spellings) + if required != preferred { + return fmt.Errorf( + "fixed import path %q requires qualifier %q, not %q", + importPath, + required, + preferred, + ) + } } spellings[preferred] = spellings[preferred] || spec.Name != "" return nil } -// freezeImports allocates qualifiers in generator-priority, full-path order. -func (g *Generation) freezeImports() { +// freezeImports validates fixed requirements, then allocates qualifiers by +// ownership class and complete import path. +func (g *Generation) freezeImports() error { paths := make([]string, 0, len(g.importPlan.candidates)) for importPath := range g.importPlan.candidates { paths = append(paths, importPath) } sort.Slice(paths, func(i, j int) bool { - left := len(g.importPlan.candidates[paths[i]].reserved) > 0 - right := len(g.importPlan.candidates[paths[j]].reserved) > 0 + left := g.importPlan.candidates[paths[i]].priority() + right := g.importPlan.candidates[paths[j]].priority() if left != right { - return left + return left < right } return paths[i] < paths[j] }) - scope := NewNameScope() - g.imports = make(map[string]importAliasBinding, len(paths)) + fixedPaths := make(map[string]string) for _, importPath := range paths { candidate := g.importPlan.candidates[importPath] - spellings := candidate.preferred - if len(candidate.reserved) > 0 { - spellings = candidate.reserved + if candidate.priority() != fixedImportPriority { + continue + } + name, _ := firstImportSpelling(candidate.spellings[fixedImportPriority]) + if existingPath, ok := fixedPaths[name]; ok && existingPath != importPath { + return fmt.Errorf( + "fixed import qualifier %q is required by both %q and %q", + name, + existingPath, + importPath, + ) } + fixedPaths[name] = importPath + } + scope := NewNameScope() + bindings := make(map[string]importAliasBinding, len(paths)) + for _, importPath := range paths { + candidate := g.importPlan.candidates[importPath] + spellings := candidate.spellings[candidate.priority()] preferred, explicit := firstImportSpelling(spellings) name := scope.Unique(preferred) - g.imports[importPath] = importAliasBinding{ + bindings[importPath] = importAliasBinding{ name: name, explicit: explicit || name != path.Base(importPath), } } scope.Freeze() + g.imports = bindings + return nil } // importBinding returns one planned binding after generation freeze. @@ -153,6 +196,16 @@ func firstImportSpelling(spellings map[string]bool) (string, bool) { return name, spellings[name] } +// priority returns the strongest ownership class that requested this path. +func (c *importAliasCandidate) priority() importPriority { + for priority := fixedImportPriority; priority < importPriorityCount; priority++ { + if len(c.spellings[priority]) > 0 { + return priority + } + } + panic("import alias candidate has no spellings") +} + // explicitImportName omits a redundant alias unless planning or collision // resolution requires one. func explicitImportName(importPath string, binding importAliasBinding) string { diff --git a/codegen/import_aliases_test.go b/codegen/import_aliases_test.go new file mode 100644 index 0000000000..69eccc3aa9 --- /dev/null +++ b/codegen/import_aliases_test.go @@ -0,0 +1,114 @@ +// This file verifies that one generation assigns import qualifiers by explicit +// ownership priority rather than declaration order or import-path sorting. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestImportAliasPrioritiesIgnoreRegistrationOrder verifies that static +// template imports keep required qualifiers ahead of generated packages and +// design metadata regardless of planning order. +func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { + freeze := func(reverse bool) map[string]string { + generation := NewGeneration("generated.local/gen", nil) + declare := []func() error{ + func() error { + return generation.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) + }, + func() error { + return generation.ReserveGeneratedImport(NewImport("goa", "generated.local/gen/goa")) + }, + func() error { + return generation.DeclareImport(NewImport("goa", "example.com/custom/goa")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return map[string]string{ + "fixed": generation.ImportName("goa.design/goa/v3/pkg"), + "generated": generation.ImportName("generated.local/gen/goa"), + "metadata": generation.ImportName("example.com/custom/goa"), + } + } + + want := map[string]string{ + "fixed": "goa", + "generated": "goa2", + "metadata": "goa3", + } + require.Equal(t, want, freeze(false)) + require.Equal(t, want, freeze(true)) +} + +// TestImportAliasHighestPriorityWinsPerPath verifies that one complete import +// path has one identity and uses its highest-priority requested spelling. +func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { + freeze := func(reverse bool) string { + generation := NewGeneration("generated.local/gen", nil) + declare := []func() error{ + func() error { + return generation.RequireImport(NewImport("json", "encoding/json")) + }, + func() error { + return generation.ReserveGeneratedImport(NewImport("jason", "encoding/json")) + }, + func() error { + return generation.DeclareImport(NewImport("jsonp", "encoding/json")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return generation.ImportName("encoding/json") + } + + require.Equal(t, "json", freeze(false)) + require.Equal(t, "json", freeze(true)) +} + +// TestGeneratedImportPreferenceIsOrderIndependent verifies that repeated +// generated-package preferences for one path use deterministic spelling. +func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) + require.NoError(t, generation.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) + require.NoError(t, generation.Freeze()) + return generation.ImportName("generated.local/gen/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestImportAliasRejectsIncompatibleFixedRequirements verifies that static +// templates cannot request two different mandatory spellings for one path. +func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.RequireImport(NewImport("json", "encoding/json"))) + require.ErrorContains( + t, + generation.RequireImport(NewImport("jason", "encoding/json")), + "requires qualifier", + ) +} + +// TestImportAliasRejectsFixedQualifierCollision verifies that two static +// packages cannot both require the same qualifier. +func TestImportAliasRejectsFixedQualifierCollision(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/first"))) + require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/second"))) + require.ErrorContains(t, generation.Freeze(), "required by both") +} diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go index 0b0f15262c..0e09ab514c 100644 --- a/codegen/plugin_test.go +++ b/codegen/plugin_test.go @@ -1,3 +1,5 @@ +// This file verifies plugin registration order and the shared generation +// lifecycle used by plugin prepare, plan, and render callbacks. package codegen import ( @@ -148,7 +150,7 @@ func TestRegisterPluginLifecycleCallbacksUseGeneration(t *testing.T) { ) generation := NewGeneration("generated.local/gen", nil) - require.NoError(t, RunPluginsPrepare("test", generation.GenPkg, generation.Roots)) + require.NoError(t, RunPluginsPrepare("test", generation.GenPkg(), generation.Roots())) require.NoError(t, RunPluginsPlan("test", generation)) require.NoError(t, generation.Freeze()) _, err := RunPlugins("test", generation, nil) diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 8fe5742e6e..d0ab1225f0 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -172,10 +172,10 @@ func generateConvertFileForPath( paths = append(paths, importPath) } - outputPath := servicePackagePath(services.generation.GenPkg, service) + outputPath := servicePackagePath(services.generation.GenPkg(), service) first := append(append([]*expr.TypeMap(nil), conversions...), creations...)[0] if loc := codegen.UserTypeLocation(first.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) + outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) } sections := []*codegen.SectionTemplate{ codegen.Header( @@ -203,9 +203,9 @@ func generateConvertFileForPath( } tgtPkg := services.aliases.name(pkgImport) - outputPath := servicePackagePath(services.generation.GenPkg, service) + outputPath := servicePackagePath(services.generation.GenPkg(), service) if loc := codegen.UserTypeLocation(c.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) + outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) } srcAtt := &expr.AttributeExpr{Type: c.User} srcResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(srcAtt) @@ -258,9 +258,9 @@ func generateConvertFileForPath( srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) tgtAtt := &expr.AttributeExpr{Type: c.User} - outputPath := servicePackagePath(services.generation.GenPkg, service) + outputPath := servicePackagePath(services.generation.GenPkg(), service) if loc := codegen.UserTypeLocation(c.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg, service, loc) + outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) } tgtResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(tgtAtt) tgtCtx := &codegen.AttributeContext{ diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index 43d50deeff..a97ae24a36 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -61,7 +61,7 @@ func newServiceResolver(generation *codegen.Generation, aliases *importAliases, generation: generation, aliases: aliases, service: service, - currentPath: servicePackagePath(generation.GenPkg, service), + currentPath: servicePackagePath(generation.GenPkg(), service), outputPath: outputPath, } } @@ -70,7 +70,7 @@ func newServiceResolver(generation *codegen.Generation, aliases *importAliases, // derived binds rebuilt projected expression origins to their typed catalog // identities. func newViewResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { - viewsPath := servicePackagePath(generation.GenPkg, service) + "/views" + viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" return &declarationResolver{ generation: generation, aliases: aliases, @@ -269,7 +269,7 @@ func (r *declarationResolver) owner(att *expr.AttributeExpr) string { return r.currentPath } if location := codegen.UserTypeLocation(att.Type); location != nil { - return path.Join(r.generation.GenPkg, location.RelImportPath) + return path.Join(r.generation.GenPkg(), location.RelImportPath) } return r.currentPath } diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index 0efbda9bc0..d2acce304d 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -104,7 +104,7 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( container.Attribute().AddMeta("struct:pkg:path", "types") generation := codegen.NewGeneration("generated.local/gen", nil) - servicePackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + servicePackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) localDeclaration, err := servicePackage.DeclareUserType(local) require.NoError(t, err) errorsPackage := generation.GeneratedPackage("generated.local/gen/errors") @@ -119,12 +119,12 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( generation, aliasesForTest( t, - servicePackagePath(generation.GenPkg, service), + servicePackagePath(generation.GenPkg(), service), "generated.local/gen/errors", "generated.local/gen/types", ), service, - servicePackagePath(generation.GenPkg, service), + servicePackagePath(generation.GenPkg(), service), ) require.Equal(t, "Fault", localDeclaration.Name()) require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) @@ -151,13 +151,13 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { service := &expr.ServiceExpr{Name: "Missing"} generation := codegen.NewGeneration("generated.local/gen", nil) - generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) require.NoError(t, generation.Freeze()) resolver := newServiceResolver( generation, - aliasesForTest(t, servicePackagePath(generation.GenPkg, service)), + aliasesForTest(t, servicePackagePath(generation.GenPkg(), service)), service, - servicePackagePath(generation.GenPkg, service), + servicePackagePath(generation.GenPkg(), service), ) missing := resolverUserType("Missing", expr.String) require.PanicsWithValue( diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index 02909938d0..5cf1252233 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter server and client interceptor files generated +// from API, service, and method interceptor declarations. package service import ( @@ -88,7 +90,7 @@ func TestExampleInterceptorsFiles(t *testing.T) { require.NotNil(t, root) // Generate files - fs := ExampleInterceptorsFiles(services.generation.GenPkg, root, services) + fs := ExampleInterceptorsFiles(services.generation.GenPkg(), root, services) require.Len(t, fs, len(c.ExpectedFiles)) // Verify file paths diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 4834cd6431..9a332e9c19 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter service implementations generated from +// normalized service methods and their frozen package references. package service import ( @@ -34,7 +36,7 @@ func TestExampleServiceFiles(t *testing.T) { root := codegen.RunDSL(t, c.DSL) services := mustServicesData(t, root) require.Len(t, root.Services, 3) - fs := ExampleServiceFiles(services.generation.GenPkg, root, services) + fs := ExampleServiceFiles(services.generation.GenPkg(), root, services) require.Len(t, fs, 3) for _, f := range fs { require.Greater(t, len(f.SectionTemplates), 0) diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 8e1d9c128a..052795359a 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -90,7 +90,7 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { for _, service := range root.Services { // The service package record makes NewServicesData a render-only contract: // its scope is unavailable until the generation freezes. - generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) } seenTypes := make(map[plannedUserType]struct{}) @@ -121,7 +121,7 @@ func rootMembershipError(root *expr.RootExpr) error { func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { planned := make(map[expr.UserType]codegen.DerivedTypeID) for _, service := range root.Services { - generatedPackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + generatedPackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) for _, method := range service.Methods { attributes := []methodTypeCandidate{ {method.Payload, codegen.NewMethodPayloadIdentity(service.Name, method.Name)}, @@ -206,7 +206,7 @@ func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, loc } key := plannedUserType{ userType: declaredType, - packagePath: generatedPackagePath(generation.GenPkg, service, typeLocation), + packagePath: generatedPackagePath(generation.GenPkg(), service, typeLocation), } if _, ok := seen[key]; ok { return nil @@ -263,7 +263,7 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati } key := plannedUserType{ userType: declaredType, - packagePath: generatedPackagePath(generation.GenPkg, service, typeLocation), + packagePath: generatedPackagePath(generation.GenPkg(), service, typeLocation), } if _, ok := seen[key]; ok { return nil @@ -284,7 +284,7 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati } return recurse(actual.ElemType, location) case *expr.Union: - packagePath := generatedPackagePath(generation.GenPkg, service, location) + packagePath := generatedPackagePath(generation.GenPkg(), service, location) generatedPackage := generation.GeneratedPackage(packagePath) if _, err := generatedPackage.DeclareUnion(actual); err != nil { return err @@ -312,7 +312,7 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati // families after the derived type names have been recorded. func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *rootTypeSet) error { for _, service := range root.Services { - viewsPath := servicePackagePath(generation.GenPkg, service) + "/views" + viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" views := generation.GeneratedPackage(viewsPath) seenProjected := make(map[expr.UserType]expr.UserType) derived := make(map[expr.UserType]codegen.DerivedTypeID) @@ -334,7 +334,7 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r projectedRoots = append(projectedRoots, projected) if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { - serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)) + serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) resultDeclaration, err := serviceTypes.Type(rootTypes.canonical(resultType)) if err != nil { return err @@ -471,7 +471,7 @@ func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { // generatedPackage returns the root-owned render data for the package selected // by location, creating that owner on first use. func (d *ServicesData) generatedPackage(service *expr.ServiceExpr, location *codegen.Location) *generatedPackageData { - importPath := generatedPackagePath(d.generation.GenPkg, service, location) + importPath := generatedPackagePath(d.generation.GenPkg(), service, location) if generatedPackage, ok := d.packages[importPath]; ok { return generatedPackage } @@ -573,7 +573,7 @@ func (d *ServicesData) registerMethodType(service *expr.ServiceExpr, attribute * } userType := attribute.Type.(expr.UserType) declaration, err := d.generation.GeneratedPackage( - generatedPackagePath(d.generation.GenPkg, service, location), + generatedPackagePath(d.generation.GenPkg(), service, location), ).UserType(d.rootTypes.canonical(userType)) if err != nil { return err diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 626358f621..38892ad290 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -46,7 +46,7 @@ func AttributeImports(genpkg, outputPackage string, attributes ...*expr.Attribut // referenced by attributes using the frozen aliases shared with service type // references. func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { - collector := newImportCollector(d.aliases, d.generation.GenPkg, outputPackage) + collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) for _, attribute := range attributes { collector.collect(attribute) } @@ -57,7 +57,7 @@ func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*exp // generated file. Explicit paths and attribute-derived paths are deduplicated // before their frozen aliases are materialized. func (d *ServicesData) fileImports(outputPackage string, paths []string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { - collector := newImportCollector(d.aliases, d.generation.GenPkg, outputPackage) + collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) for _, importPath := range paths { collector.addPath(importPath) } @@ -111,17 +111,17 @@ func planImports(root *expr.RootExpr, generation *codegen.Generation) error { codegen.GoaImport("security"), } for _, spec := range fixed { - if err := generation.ReserveImport(spec); err != nil { + if err := generation.RequireImport(spec); err != nil { return err } } for _, service := range root.Services { - servicePath := servicePackagePath(generation.GenPkg, service) + servicePath := servicePackagePath(generation.GenPkg(), service) serviceName := strings.ToLower(codegen.Goify(service.Name, false)) - if err := generation.ReserveImport(codegen.NewImport(serviceName, servicePath)); err != nil { + if err := generation.ReserveGeneratedImport(codegen.NewImport(serviceName, servicePath)); err != nil { return err } - if err := generation.ReserveImport(codegen.NewImport(serviceName+"views", servicePath+"/views")); err != nil { + if err := generation.ReserveGeneratedImport(codegen.NewImport(serviceName+"views", servicePath+"/views")); err != nil { return err } } @@ -171,7 +171,7 @@ func planAttributeImports(attribute *expr.AttributeExpr, generation *codegen.Gen if location := codegen.UserTypeLocation(actual); location != nil { if err := generation.DeclareImport(codegen.NewImport( location.PackageName(), - path.Join(generation.GenPkg, location.RelImportPath), + path.Join(generation.GenPkg(), location.RelImportPath), )); err != nil { return err } diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index 87feba4c64..e4e8bc1fc1 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -38,12 +38,44 @@ func TestPlanRejectsUnregisteredRoot(t *testing.T) { require.ErrorContains(t, err, "does not belong") } +// TestPlanUsesCopiedGenerationRoots verifies that mutating root slices outside +// the generation cannot change which service designs planning and rendering +// accept. +func TestPlanUsesCopiedGenerationRoots(t *testing.T) { + first := codegen.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() {}) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() {}) + }) + }) + roots := []eval.Root{first} + generation := codegen.NewGeneration("generated.local/gen", roots) + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + + require.NoError(t, Plan(first, generation)) + require.ErrorContains(t, Plan(second, generation), "does not belong") + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + _, err := NewServicesData(first, generation) + require.NoError(t, err) + _, err = NewServicesData(second, generation) + require.ErrorContains(t, err, "does not belong") +} + // TestImportAliasesUsePathAsIdentity verifies that generator-owned imports // retain their canonical qualifier when metadata prefers another spelling for // the same complete package path. func TestImportAliasesUsePathAsIdentity(t *testing.T) { generation := codegen.NewGeneration("generated.local/gen", nil) - require.NoError(t, generation.ReserveImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("jason", "encoding/json"))) require.NoError(t, generation.Freeze()) @@ -97,7 +129,7 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { require.Equal(t, "alpha", first.aliases.name("example.com/shared/value")) require.Equal(t, first.aliases.name("example.com/shared/value"), second.aliases.name("example.com/shared/value")) - files := Files(generation.GenPkg, []*ServicesData{first, second}) + files := Files(generation.GenPkg(), []*ServicesData{first, second}) for _, name := range []string{"first_payload.go", "second_payload.go"} { file := findFile(files, path.Join("gen", "types", name)) require.NotNil(t, file) @@ -132,6 +164,25 @@ func TestImportAliasesReserveFixedJSON(t *testing.T) { require.Equal(t, "json2", aliases.name("example.com/custom/json")) } +// TestFixedTemplateAliasesBeatGeneratedPackages verifies that generated +// service paths cannot take qualifiers required by static Goa and log calls. +func TestFixedTemplateAliasesBeatGeneratedPackages(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + services := mustServicesData(t, root) + require.Equal(t, "goa", services.aliases.name(codegen.GoaImport("").Path)) + require.Equal(t, "goa2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) + require.Equal(t, "log", services.aliases.name("goa.design/clue/log")) + require.Equal(t, "log2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) +} + // TestDocumentedJSONMetadataUsesCanonicalAlias verifies that an alternate // metadata spelling for encoding/json produces one import and one canonical // qualifier in the generated service definition. @@ -149,7 +200,7 @@ func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { }) }) services := mustServicesData(t, root) - file := findFile(Files(services.generation.GenPkg, []*ServicesData{services}), path.Join("gen", "values", "service.go")) + file := findFile(Files(services.generation.GenPkg(), []*ServicesData{services}), path.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) require.Contains(t, code, "json.RawMessage") @@ -171,11 +222,11 @@ func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { }) }) services := mustServicesData(t, root) - servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Values")) + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) require.Equal(t, "values", services.aliases.name(servicePath)) require.Equal(t, "values2", services.aliases.name("example.com/custom/values")) - files := ExampleServiceFiles(services.generation.GenPkg, root, services) + files := ExampleServiceFiles(services.generation.GenPkg(), root, services) require.Len(t, files, 1) code := renderSections(t, files[0].SectionTemplates) _, err := format.Source([]byte(code)) @@ -198,12 +249,12 @@ func TestExampleServiceReservesFixedQualifiers(t *testing.T) { }) }) services := mustServicesData(t, root) - servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Fmt")) + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Fmt")) servicePkg := services.aliases.name(servicePath) require.NotEqual(t, "fmt", servicePkg) require.Equal(t, "strings2", services.aliases.name("example.com/custom/strings")) - files := ExampleServiceFiles(services.generation.GenPkg, root, services) + files := ExampleServiceFiles(services.generation.GenPkg(), root, services) require.Len(t, files, 1) code := renderSections(t, files[0].SectionTemplates) _, err := format.Source([]byte(code)) @@ -232,12 +283,12 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { }) }) services := mustServicesData(t, root) - servicePath := servicePackagePath(services.generation.GenPkg, root.Service("Values")) + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) viewsPath := servicePath + "/views" require.Equal(t, "valuesviews", services.aliases.name(viewsPath)) require.Equal(t, "valuesviews2", services.aliases.name("example.com/custom/views")) - file := findFile(Files(services.generation.GenPkg, []*ServicesData{services}), path.Join("gen", "values", "service.go")) + file := findFile(Files(services.generation.GenPkg(), []*ServicesData{services}), path.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) _, err := format.Source([]byte(code)) @@ -251,7 +302,7 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { // binding when encoding/json already owns the preferred json name. func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { generation := codegen.NewGeneration("generated.local/gen", nil) - require.NoError(t, generation.ReserveImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) service := &expr.ServiceExpr{Name: "Values"} @@ -285,7 +336,7 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { require.NoError(t, err) require.Equal(t, "json2.Value", data.Fields[0].FieldType) - collector := newImportCollector(aliases, generation.GenPkg, "generated.local/gen/values") + collector := newImportCollector(aliases, generation.GenPkg(), "generated.local/gen/values") collector.collect(branch) header := codegen.Header( "Union types", diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index aff9049455..855c9c4fa6 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -1,3 +1,5 @@ +// This file renders service interceptor interfaces, information records, and +// endpoint wrappers from the interceptor data collected during service analysis. package service import ( diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index 5c4e1fa092..1d4be20f2b 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -708,7 +708,7 @@ func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*Serv rootTypes: newRootTypeSet(root), } for _, service := range root.Services { - generation.GeneratedPackage(servicePackagePath(generation.GenPkg, service)).Scope() + generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)).Scope() analyzed, err := data.analyze(service) if err != nil { return nil, err @@ -811,12 +811,12 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projTypes []*ProjectedTypeData viewedRTs []*ViewedResultTypeData ) - servicePackage := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg, service)) + servicePackage := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg(), service)) scope := servicePackage.Scope().Fork() scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket viewScope := d.generation.GeneratedPackage( - servicePackagePath(d.generation.GenPkg, service) + "/views", + servicePackagePath(d.generation.GenPkg(), service) + "/views", ).Scope().Fork() pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") viewspkg := pkgName + "views" @@ -830,7 +830,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { d.generation, d.aliases, service, - servicePackagePath(d.generation.GenPkg, service), + servicePackagePath(d.generation.GenPkg(), service), ) // A function to collect user types from an error expression @@ -896,7 +896,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projected, result := projectedResultRoot(service, m) pairs := projectTypePairs(projected, result, seenProjected) removeMeta(projected) - views := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg, service) + "/views") + views := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg(), service) + "/views") for _, pair := range pairs { identity := codegen.NewProjectedTypeID(pair.source) viewDerived[pair.projected.Origin()] = identity @@ -1014,7 +1014,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projected := seenProj[rt.Origin()] projAtt := &expr.AttributeExpr{Type: projected.Type} viewedDeclaration, err := d.generation.GeneratedPackage( - servicePackagePath(d.generation.GenPkg, service) + "/views", + servicePackagePath(d.generation.GenPkg(), service) + "/views", ).DerivedType(codegen.NewViewedResultTypeID(rt)) if err != nil { return nil, err @@ -1305,11 +1305,11 @@ func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr. } return recurse(dt.ElemType, loc) case *expr.Union: - packagePath := servicePackagePath(d.generation.GenPkg, service) + packagePath := servicePackagePath(d.generation.GenPkg(), service) if view { packagePath += "/views" } else if loc != nil { - packagePath = generatedPackagePath(d.generation.GenPkg, service, loc) + packagePath = generatedPackagePath(d.generation.GenPkg(), service, loc) } key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(dt)} if _, ok := unions[key]; !ok { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index c9e901871d..bda4e60176 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -69,7 +69,7 @@ func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *co service := &expr.ServiceExpr{Name: "test"} generation := codegen.NewGeneration("generated.local/gen", nil) generatedPackage := generation.GeneratedPackage( - generatedPackagePath(generation.GenPkg, service, loc), + generatedPackagePath(generation.GenPkg(), service, loc), ) object := att.Type.(*expr.Object) for _, named := range *object { @@ -83,7 +83,7 @@ func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *co } services := &ServicesData{ generation: generation, - aliases: aliasesForTest(t, generatedPackagePath(generation.GenPkg, service, loc)), + aliases: aliasesForTest(t, generatedPackagePath(generation.GenPkg(), service, loc)), packages: make(map[string]*generatedPackageData), } seen := make(map[expr.UserType]struct{}) @@ -92,7 +92,7 @@ func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *co generation, services.aliases, service, - generatedPackagePath(generation.GenPkg, service, loc), + generatedPackagePath(generation.GenPkg(), service, loc), ) if err := services.collectUnionTypes(att, service, resolver, loc, unionByHash, seen, false); err != nil { panic(err) diff --git a/codegen/service/testdata/dedup_event_marker_dsls.go b/codegen/service/testdata/dedup_event_marker_dsls.go index 74a47efb07..7df9ac05dc 100644 --- a/codegen/service/testdata/dedup_event_marker_dsls.go +++ b/codegen/service/testdata/dedup_event_marker_dsls.go @@ -1,3 +1,5 @@ +// This file defines streaming service designs used to verify that shared result +// types emit one event marker method in generated service code. package testdata import ( diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index 01faf06045..c7f688fcd9 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -1,3 +1,5 @@ +// This file builds gRPC code-generation analysis in tests using the same +// normalize, plan, freeze, and render lifecycle as production generation. package codegen import ( diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 3fc47ee076..3bb6ccd15f 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -1,3 +1,5 @@ +// This file builds HTTP code-generation analysis in tests using the same +// normalize, plan, freeze, and render lifecycle as production generation. package codegen import ( diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 234c518254..b603ab9435 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -1,3 +1,5 @@ +// This file builds JSON-RPC code-generation analysis in tests using the same +// normalize, plan, freeze, and render lifecycle as production generation. package codegen import ( From 839791a69e19153992b0af945dbd0045468cb71f Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 03:34:46 -0700 Subject: [PATCH 20/43] docs(codegen): record package-owned service completion --- .../plans/2026-08-20-generated-package-ownership.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 1f4a317958..9e1c8863cf 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -227,14 +227,14 @@ Expected: PASS. - Consumes: frozen or planning `*codegen.Generation`, `*codegen.TypeDeclaration` - Produces: `NewServicesData(*expr.RootExpr, *codegen.Generation) (*ServicesData, error)` and root-level package-owned service files -- [ ] **Step 1: Add package analysis and emission tests** +- [x] **Step 1: Add package analysis and emission tests** Test that all services in one root bind to the same declaration records, identical unions emit once, different same-base unions receive distinct frozen names, relocated user types emit once at their metadata paths, and each owning package emits one `unions.go`. -- [ ] **Step 2: Replace local package priming with frozen declarations** +- [x] **Step 2: Replace local package priming with frozen declarations** Delete `NewServicesDataForRoots`, `packageScopes`, `serviceNameScopes`, `unionCompanionKey`, and every decorated union key. During planning, @@ -243,7 +243,7 @@ rendering, it looks up the same records. `UserTypeData` and `UnionTypeData` retain their `*codegen.TypeDeclaration`; `buildUnionTypeData` allocates the kind name once from the owning package scope and stores it in the union render data. -- [ ] **Step 3: Make the package owner render all service types** +- [x] **Step 3: Make the package owner render all service types** Change the public renderer to: @@ -256,13 +256,13 @@ file, and one sorted `unions.go` per package. Remove `userTypePkgs`, `~union:`, and `unionRegistryKey`. `ConvertFiles` uses the owning package scope rather than a fresh one. -- [ ] **Step 4: Migrate core service, example, and OpenAPI generators** +- [x] **Step 4: Migrate core service, example, and OpenAPI generators** Their plan callback analyzes each design root with the active generation. Their render callback repeats analysis against frozen records and propagates errors. The Service renderer calls the root-level `service.Files` once. -- [ ] **Step 5: Run service and generator tests** +- [x] **Step 5: Run service and generator tests** Run: From 595f50aded9b704f9bdb271f3ba3b5810718151a Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 05:08:11 -0700 Subject: [PATCH 21/43] codegen: freeze service names across transports --- codegen/ARCHITECTURE.md | 34 +- codegen/generator/example.go | 12 +- ...erate_http_union_shape_integration_test.go | 2 +- .../generate_union_merge_integration_test.go | 2 +- codegen/generator/generators.go | 4 +- .../service_union_package_scope_test.go | 491 +++++++++++++++++- codegen/generator/transport.go | 60 ++- codegen/go_transform.go | 18 +- codegen/go_transform_test.go | 135 +++++ codegen/service/declaration_resolver.go | 91 +--- codegen/service/declaration_resolver_test.go | 40 ++ codegen/service/imports.go | 66 ++- codegen/service/imports_test.go | 2 +- codegen/service/service.go | 6 +- codegen/service/service_data.go | 58 +++ codegen/union.go | 8 - .../2026-08-20-generated-package-ownership.md | 10 +- dsl/error.go | 9 + expr/error_contract.go | 176 +++++++ expr/error_contract_test.go | 43 ++ expr/grpc_endpoint.go | 27 + expr/grpc_error.go | 23 +- expr/http_endpoint.go | 27 + expr/http_error.go | 27 +- expr/transport_error_contract_test.go | 189 +++++++ grpc/codegen/client.go | 22 +- grpc/codegen/client_cli.go | 24 +- grpc/codegen/client_cli_test.go | 2 +- grpc/codegen/client_test.go | 6 +- grpc/codegen/client_types_test.go | 2 +- grpc/codegen/example_cli.go | 7 +- grpc/codegen/example_cli_test.go | 5 +- grpc/codegen/example_server.go | 7 +- grpc/codegen/example_server_test.go | 3 +- grpc/codegen/idempotency_test.go | 4 +- .../oneof_anonymous_user_union_test.go | 4 +- grpc/codegen/parse_endpoint_test.go | 4 +- grpc/codegen/plan.go | 61 +++ grpc/codegen/proto.go | 7 +- grpc/codegen/proto_test.go | 4 +- grpc/codegen/protobuf_transform_test.go | 4 +- grpc/codegen/server.go | 24 +- grpc/codegen/server_test.go | 8 +- grpc/codegen/server_types_test.go | 2 +- grpc/codegen/service_data.go | 139 ++--- grpc/codegen/service_imports.go | 12 +- .../service_metadata_reference_test.go | 37 ++ grpc/codegen/streaming_errors_test.go | 6 +- grpc/codegen/streaming_test.go | 16 +- grpc/codegen/templates/type_init.go.tpl | 2 +- ...endpoint-endpoint-with-interceptors.golden | 2 +- ...ent_cli_payload-with-validations.go.golden | 2 +- grpc/codegen/testing.go | 11 +- grpc/codegen/types.go | 28 +- http/codegen/client.go | 51 +- http/codegen/client_body_types_test.go | 8 +- http/codegen/client_cli.go | 20 +- http/codegen/client_cli_test.go | 2 +- http/codegen/client_decode_test.go | 2 +- http/codegen/client_encode_test.go | 4 +- http/codegen/client_init_test.go | 2 +- http/codegen/cookie_security_test.go | 6 +- http/codegen/example_cli.go | 7 +- http/codegen/example_cli_test.go | 3 +- http/codegen/example_server.go | 15 +- http/codegen/example_server_test.go | 5 +- http/codegen/handler_test.go | 2 +- http/codegen/idempotency_test.go | 10 +- http/codegen/multipart_test.go | 8 +- http/codegen/oneof_http_codegen_test.go | 6 +- http/codegen/plan.go | 46 ++ http/codegen/plan_test.go | 38 ++ http/codegen/server.go | 30 +- http/codegen/server_decode_test.go | 2 +- http/codegen/server_encode_test.go | 4 +- http/codegen/server_error_encoder_test.go | 2 +- http/codegen/server_handler_test.go | 2 +- http/codegen/server_init_test.go | 2 +- http/codegen/server_mount_test.go | 2 +- http/codegen/server_payload_types_test.go | 2 +- http/codegen/server_types_test.go | 2 +- http/codegen/service_data.go | 281 +++++----- http/codegen/service_data_union_order_test.go | 80 ++- http/codegen/service_imports.go | 26 +- http/codegen/sse.go | 20 +- http/codegen/sse_client.go | 6 +- http/codegen/sse_client_test.go | 2 +- http/codegen/sse_mixed_results_test.go | 4 +- http/codegen/sse_server_test.go | 4 +- http/codegen/streaming_test.go | 4 +- .../templates/build_stream_request.go.tpl | 4 +- .../templates/client_endpoint_init.go.tpl | 2 +- http/codegen/templates/request_encoder.go.tpl | 4 +- http/codegen/testdata/error_response_dsls.go | 6 +- http/codegen/testing.go | 5 +- http/codegen/transform_helper_test.go | 4 +- http/codegen/types.go | 14 +- http/codegen/websocket.go | 22 +- http/codegen/websocket_golden_test.go | 8 +- jsonrpc/codegen/client.go | 22 +- jsonrpc/codegen/example_server.go | 9 +- jsonrpc/codegen/idempotency_test.go | 2 +- jsonrpc/codegen/plan.go | 43 ++ jsonrpc/codegen/plan_test.go | 33 ++ jsonrpc/codegen/server.go | 22 +- jsonrpc/codegen/service_imports.go | 25 +- jsonrpc/codegen/sse.go | 8 +- jsonrpc/codegen/sse_dedup_test.go | 2 +- jsonrpc/codegen/sse_integration_test.go | 4 +- jsonrpc/codegen/sse_test.go | 2 +- jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go | 4 +- .../testdata/jsonrpc_sse_duplicate_dsls.go | 35 +- jsonrpc/codegen/testing.go | 5 +- jsonrpc/codegen/websocket_client.go | 4 +- jsonrpc/codegen/websocket_server.go | 4 +- 115 files changed, 2227 insertions(+), 791 deletions(-) create mode 100644 expr/error_contract.go create mode 100644 expr/error_contract_test.go create mode 100644 expr/transport_error_contract_test.go create mode 100644 grpc/codegen/plan.go create mode 100644 grpc/codegen/service_metadata_reference_test.go create mode 100644 http/codegen/plan.go create mode 100644 http/codegen/plan_test.go create mode 100644 jsonrpc/codegen/plan.go create mode 100644 jsonrpc/codegen/plan_test.go diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 77a7695816..2877470143 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -33,6 +33,8 @@ explicitly given the active generation context. | Design identity and structural equality | `expr` | validation and code generation | | Go identifiers in a generated package | the generated-package record for its output path | service and transport rendering | | Relocated user-type and union declarations | the same generated-package record | file rendering | +| Service error values | the endpoint method's effective error declaration | service and transport rendering | +| HTTP and gRPC error response policy | the transport mapping selected by error name | transport validation and wire rendering | | HTTP, gRPC, and JSON-RPC wire types | each transport generator | transport templates | | Output-path merging | the generator | all file-producing plugins | @@ -66,6 +68,13 @@ conflicting requirements are rejected; generated and metadata qualifiers may receive deterministic suffixes. Each generated file still imports only the paths used by the declarations and references it renders. +Each transport plans the literal imports used by its own templates before the +catalog freezes. JSON-RPC planning includes HTTP planning because it reuses the +HTTP type, codec, and command-line renderers. The service planner does not know +about transport packages. Render functions derive their output import paths +from the same generation-backed service analysis; they do not accept another +generated module path that could redirect files away from their imports. + Planning a declaration returns its canonical record. Once every selected generator and plugin has planned its output, the context freezes the catalog. Rendering may only look up those records; a late attempt to add a declaration @@ -104,6 +113,25 @@ This is the only supported route for resolving generated service types inside HTTP, gRPC, JSON-RPC, conversion, and validation helpers. Transport-specific scopes still own transport-only wire declarations. +Each side of a conversion enters its own attribute independently. A copied HTTP +body remains owned by the HTTP package even if the source service declaration +was relocated, while the service-side value follows the relocated declaration's +package record. The generated file's actual import path determines whether a +reference is local or qualified, and the qualifier comes from the same frozen +full-path import binding used by that file's imports. + +Reusable API- or service-level HTTP and gRPC error mappings are response policy, +not replacement service types. When an endpoint inherits a mapping by error +name, the mapping's error attribute must equal the method's effective error +attribute, including its named type shape, validations, defaults, and struct +metadata. Validation rejects incompatible shadowing before code generation. +Finalization then binds the mapping to the method error declaration, so service +constructors, transport encoders and decoders, and generated references all use +one concrete error value. For example, an API mapping for a string +`bad_request` may be reused by a method that independently declares the same +string error, but not by a method that declares `bad_request` as an integer or +as the built-in service error object. + ## Plugin and file assembly contracts A plugin that can emit generated service types plans them before the catalog is @@ -140,5 +168,7 @@ files, or file merging, trace one declaration through all of these stages: 8. final files after output-path merging. A service-only render test is insufficient. The regression must compile a real -generated module with both HTTP and gRPC enabled whenever those transports can -refer to the declaration. +generated module with HTTP, gRPC, and JSON-RPC enabled whenever those transports +can refer to the declaration. Streaming coverage must exercise WebSocket and +SSE files when streaming payloads, results, or selected SSE data fields contain +relocated declarations. diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 21560559b3..b5348aa9ab 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -44,10 +44,10 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { // HTTP if len(r.API.HTTP.Services) > 0 { httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(generation.GenPkg(), httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleServerFiles(httpServices); len(fs) != 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg(), httpServices); len(fs) != 0 { + if fs := httpcodegen.ExampleCLIFiles(httpServices); len(fs) != 0 { files = append(files, fs...) } } @@ -55,10 +55,10 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { // JSON-RPC if len(r.API.JSONRPC.Services) > 0 { jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(generation.GenPkg(), jsonrpcServices, files); len(fs) > 0 { + if fs := jsonrpccodegen.ExampleServerFiles(jsonrpcServices, files); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(generation.GenPkg(), jsonrpcServices); len(fs) > 0 { + if fs := httpcodegen.ExampleCLIFiles(jsonrpcServices); len(fs) > 0 { files = append(files, fs...) } } @@ -66,10 +66,10 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { // GRPC if len(r.API.GRPC.Services) > 0 { grpcServices := grpccodegen.NewServicesData(services) - if fs := grpccodegen.ExampleServerFiles(generation.GenPkg(), grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleServerFiles(grpcServices); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(generation.GenPkg(), grpcServices); len(fs) > 0 { + if fs := grpccodegen.ExampleCLIFiles(grpcServices); len(fs) > 0 { files = append(files, fs...) } } diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index ac006bf16b..47bfa03a02 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -20,7 +20,7 @@ func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, }, nil } diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index cee67652ad..f5cdb87f2e 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -20,7 +20,7 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, {Plan: planServiceData, Generate: OpenAPI}, }, nil } diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index de2e7c3f82..222db2d065 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -32,11 +32,11 @@ func generators(cmd string) ([]Genfunc, error) { case "gen": return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, {Plan: planServiceData, Generate: OpenAPI}, }, nil case "example": - return []Genfunc{{Plan: planServiceData, Generate: Example}}, nil + return []Genfunc{{Plan: planTransportData, Generate: Example}}, nil default: return nil, fmt.Errorf("unknown command %q", cmd) } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index 9e06770955..daf4ce0e66 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -24,7 +24,7 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { Generators = func(_ string) ([]Genfunc, error) { return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, }, nil } @@ -34,14 +34,24 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { firstInput := dsl.Type("FirstInput", func() { dsl.Meta("struct:pkg:path", "types") dsl.OneOf("Value", func() { - dsl.Field(1, "text", dsl.String) + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.Field(1, "text", dsl.String) + }) + dsl.Required("Nested") + }) }) dsl.Required("Value") }) secondInput := dsl.Type("SecondInput", func() { dsl.Meta("struct:pkg:path", "types") dsl.OneOf("Value", func() { - dsl.Field(1, "number", dsl.Int) + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.Field(1, "text", dsl.Int) + }) + dsl.Required("Nested") + }) }) dsl.Required("Value") }) @@ -55,6 +65,11 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { }) dsl.GRPC(func() {}) }) + dsl.Method("ReadJSON", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) }) dsl.Service("Second", func() { dsl.Method("Read", func() { @@ -66,6 +81,11 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { }) dsl.GRPC(func() {}) }) + dsl.Method("ReadJSON", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) }) } codegen.RunDSL(t, root) @@ -82,12 +102,180 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { filepath.Join("http", "second", "server", "server.go"), filepath.Join("grpc", "first", "server", "server.go"), filepath.Join("grpc", "second", "server", "server.go"), + filepath.Join("jsonrpc", "first", "server", "server.go"), + filepath.Join("jsonrpc", "second", "server", "server.go"), } { require.FileExists(t, filepath.Join(genDir, path)) } runGeneratedTests(t, genDir) } +// TestInheritedTransportErrorMappingsCompileWithMethodErrors verifies reusable +// HTTP and gRPC response policy binds to the equivalent error value declared by +// the endpoint method instead of retaining the API declaration object. +func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + dsl.API("error policy", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "bad_request") }) + dsl.GRPC(func() { dsl.Response("bad_request", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestNestedTransportMetadataOwnsRecursiveImports verifies conversion helpers +// import a custom field type nested inside a relocated service declaration. +func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "domain/outer") + dsl.Field(1, "value", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "gen/custom/value", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("HTTP", func() { + dsl.Payload(outer) + dsl.HTTP(func() { dsl.POST("/values") }) + }) + dsl.Method("GRPC", func() { + dsl.Payload(outer) + dsl.GRPC(func() {}) + }) + dsl.Method("JSONRPC", func() { + dsl.Payload(outer) + dsl.JSONRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "value"), "custom") + runGeneratedTests(t, genDir) +} + +// TestTransportServiceImportsUseFrozenAliases verifies a service package whose +// natural name collides with a fixed runtime import is declared and referenced +// with the same generation-owned qualifier in every transport. +func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + dsl.Service("Goa", func() { + for _, transport := range []string{"HTTP", "GRPC", "JSONRPC"} { + dsl.Method(transport, func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + switch transport { + case "HTTP": + dsl.HTTP(func() { dsl.POST("/values") }) + case "GRPC": + dsl.GRPC(func() {}) + case "JSONRPC": + dsl.JSONRPC(func() {}) + } + }) + } + }) + dsl.Service("Goahttp", func() { + dsl.Method("HTTP", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.HTTP(func() { dsl.POST("/http-values") }) + }) + }) + dsl.Service("Goapb", func() { + dsl.Method("GRPC", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestInheritedTransportErrorsOwnImports verifies API-level response policy +// imports the relocated effective error referenced by generated HTTP and gRPC +// encoders even though the method does not redeclare it. +func TestInheritedTransportErrorsOwnImports(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + fault := dsl.Type("Fault", func() { + dsl.Meta("struct:pkg:path", "domain/errors") + dsl.Attribute("message", dsl.String) + }) + dsl.API("error imports", func() { + dsl.Error("fault", fault) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "fault") }) + dsl.GRPC(func() { dsl.Response("fault", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + // TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch // aliases with one natural name but different primitive shapes remain distinct. func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { @@ -169,7 +357,7 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { Generators = func(_ string) ([]Genfunc, error) { return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, }, nil } @@ -213,6 +401,69 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { runGeneratedTests(t, genDir) } +// TestRawBodyStructsRemainInEndpointsPackage verifies relocated payload and +// result declarations never relocate the request/response wrappers consumed by +// the raw HTTP body path. +func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + upload := dsl.Type("Upload", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + download := dsl.Type("Download", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + dsl.Service("RawBodies", func() { + dsl.Method("Upload", func() { + dsl.Payload(upload) + dsl.HTTP(func() { + dsl.POST("/upload") + dsl.Header("length:Content-Length") + dsl.SkipRequestBodyEncodeDecode() + dsl.Response(dsl.StatusNoContent) + }) + }) + dsl.Method("Download", func() { + dsl.Result(download) + dsl.HTTP(func() { + dsl.GET("/download") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + + clientSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "client.go")) + require.NoError(t, err) + require.Contains(t, string(clientSource), "&rawbodies.DownloadResponseData") + require.NotContains(t, string(clientSource), "types.DownloadResponseData") + codecSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "encode_decode.go")) + require.NoError(t, err) + require.Contains(t, string(codecSource), "*rawbodies.UploadRequestData") + require.NotContains(t, string(codecSource), "types.UploadRequestData") + runGeneratedTests(t, genDir) +} + // TestServiceReferencesUseImportPathAliases verifies that one service can // reference generated packages with the same Go package name without emitting // duplicate import aliases or ambiguous qualified references. @@ -257,6 +508,107 @@ func TestServiceReferencesUseImportPathAliases(t *testing.T) { runGeneratedTests(t, genDir) } +// TestTransportReferencesUseImportPathAliases verifies HTTP, gRPC, and +// JSON-RPC files qualify two same-basename service packages with the aliases +// frozen by the shared generation. +func TestTransportReferencesUseImportPathAliases(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + path string + payload expr.UserType + }{ + {"HTTPFirst", "/http/first", first}, + {"HTTPSecond", "/http/second", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.HTTP(func() { dsl.POST(method.path) }) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"GRPCFirst", first}, + {"GRPCSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.GRPC(func() {}) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"JSONFirst", first}, + {"JSONSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + for _, transport := range []string{"http", "grpc", "jsonrpc"} { + source := generatedTreeSource(t, filepath.Join(genDir, transport, "values")) + require.Contains(t, source, `shared "gen/first/shared"`) + require.Contains(t, source, `shared2 "gen/second/shared"`) + require.Contains(t, source, "shared.First") + require.Contains(t, source, "shared2.Second") + } + runGeneratedTests(t, genDir) +} + +// generatedTreeSource returns the concatenated Go source below root in path +// order so tests can assert file-owned imports without depending on which +// transport file contains a conversion helper. +func generatedTreeSource(t *testing.T, root string) string { + t.Helper() + var source strings.Builder + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + source.Write(data) + return nil + }) + require.NoError(t, err) + return source.String() +} + // TestNamedUnionBranchImportsReferenceOnly verifies that unions.go does not // expand a named branch definition and import packages used only where that // named type itself is declared. @@ -352,7 +704,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { Generators = func(_ string) ([]Genfunc, error) { return []Genfunc{ {Plan: planServiceData, Generate: Service}, - {Plan: planServiceData, Generate: Transport}, + {Plan: planTransportData, Generate: Transport}, }, nil } codegen.RunDSL(t, func() { @@ -471,7 +823,7 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { }) }) generation := codegen.NewGeneration("gen", []eval.Root{root}) - require.NoError(t, planServiceData(generation)) + require.NoError(t, planTransportData(generation)) require.NoError(t, generation.Freeze()) files, err := Transport(generation) require.NoError(t, err) @@ -489,6 +841,85 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { require.NotContains(t, header.String(), `"gen/request/shared"`) } +// TestRelocatedStreamingUnionReferencesCompile verifies WebSocket and SSE +// files resolve relocated streaming declarations through the frozen service +// packages while their event and frame bodies remain transport-owned. +func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + streamInput := relocatedStreamingType("StreamInput", "InputChoice", dsl.String) + streamOutput := relocatedStreamingType("StreamOutput", "OutputChoice", dsl.Int) + sseEvent := dsl.Type("SSEEvent", func() { + dsl.Attribute("data", streamOutput) + dsl.Attribute("id", dsl.String) + dsl.Required("data", "id") + }) + dsl.Service("HTTPStreams", func() { + dsl.Method("Socket", func() { + dsl.StreamingPayload(streamInput) + dsl.StreamingResult(streamOutput) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.HTTP(func() { + dsl.GET("/events") + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + dsl.Service("JSONSockets", func() { + dsl.Method("Socket", func() { + dsl.StreamingPayload(streamInput) + dsl.StreamingResult(streamOutput) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("JSONEvents", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join("http", "http_streams", "server", "websocket.go"), + filepath.Join("http", "http_streams", "server", "sse.go"), + filepath.Join("jsonrpc", "json_sockets", "server", "websocket.go"), + filepath.Join("jsonrpc", "json_events", "server", "sse.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + runGeneratedTests(t, genDir) +} + +// relocatedStreamingType builds an object with a nested union that is emitted +// in the shared streaming package used by the integration test. +func relocatedStreamingType(name, unionName string, value expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "stream/types") + dsl.OneOf(unionName, func() { + dsl.Attribute("value", value) + }) + dsl.Required(unionName) + }) +} + // writeStubPackage creates the external package referenced by struct:field:type // metadata inside the generated module used by the integration test. func writeStubPackage(t *testing.T, dir, packageName string) { @@ -652,6 +1083,54 @@ func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { runGeneratedTests(t, dir) } +// TestTransportStaticAliasesCompileWithHttpAndPathServices verifies transport +// imports retain their literal qualifiers beside conflicting service names. +func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(_ string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + codegen.RunDSL(t, func() { + for _, name := range []string{"Http", "Path"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + httpServers, err := filepath.Glob(filepath.Join(genDir, "http", "*", "server", "server.go")) + require.NoError(t, err) + require.Len(t, httpServers, 2) + var httpSource strings.Builder + for _, server := range httpServers { + source, err := os.ReadFile(server) + require.NoError(t, err) + httpSource.Write(source) + } + require.Contains(t, httpSource.String(), `http_ "gen/http_"`) + require.Contains(t, httpSource.String(), `path2 "gen/path"`) + runGeneratedTests(t, genDir) +} + // unusedRelocatedValueRoot declares a relocated type that no service reaches // and does not force generation. It must not reserve a generated package name. func unusedRelocatedValueRoot() func() { diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 3767502a49..a028ca183b 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -22,30 +22,58 @@ func Transport(generation *codegen.Generation) ([]*codegen.File, error) { } // HTTP httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(generation.GenPkg(), httpServices)...) - files = append(files, httpcodegen.ClientFiles(generation.GenPkg(), httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg(), httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg(), httpServices)...) + files = append(files, httpcodegen.ServerFiles(httpServices)...) + files = append(files, httpcodegen.ClientFiles(httpServices)...) + files = append(files, httpcodegen.ServerTypeFiles(httpServices)...) + files = append(files, httpcodegen.ClientTypeFiles(httpServices)...) files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg(), httpServices)...) + files = append(files, httpcodegen.ClientCLIFiles(httpServices)...) // GRPC grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(generation.GenPkg(), grpcServices)...) - files = append(files, grpccodegen.ServerFiles(generation.GenPkg(), grpcServices)...) - files = append(files, grpccodegen.ClientFiles(generation.GenPkg(), grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(generation.GenPkg(), grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(generation.GenPkg(), grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(generation.GenPkg(), grpcServices)...) + files = append(files, grpccodegen.ProtoFiles(grpcServices)...) + files = append(files, grpccodegen.ServerFiles(grpcServices)...) + files = append(files, grpccodegen.ClientFiles(grpcServices)...) + files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) // JSON-RPC jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(generation.GenPkg(), jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(generation.GenPkg(), jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(generation.GenPkg(), jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(generation.GenPkg(), jsonrpcServices)...) + files = append(files, jsonrpccodegen.ServerFiles(jsonrpcServices)...) + files = append(files, jsonrpccodegen.ClientFiles(jsonrpcServices)...) + files = append(files, httpcodegen.ServerTypeFiles(jsonrpcServices)...) + files = append(files, httpcodegen.ClientTypeFiles(jsonrpcServices)...) files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(generation.GenPkg(), jsonrpcServices)...) + files = append(files, httpcodegen.ClientCLIFiles(jsonrpcServices)...) } return files, nil } + +// planTransportData declares service packages and the fixed import qualifiers +// required by each transport before the shared generation catalog freezes. +func planTransportData(generation *codegen.Generation) error { + if err := planServiceData(generation); err != nil { + return err + } + var hasHTTP, hasGRPC, hasJSONRPC bool + for _, root := range serviceRoots(generation.Roots()) { + hasHTTP = hasHTTP || len(root.API.HTTP.Services) > 0 + hasGRPC = hasGRPC || len(root.API.GRPC.Services) > 0 + hasJSONRPC = hasJSONRPC || len(root.API.JSONRPC.Services) > 0 + } + if hasHTTP { + if err := httpcodegen.Plan(generation); err != nil { + return err + } + } + if hasGRPC { + if err := grpccodegen.Plan(generation); err != nil { + return err + } + } + if hasJSONRPC { + return jsonrpccodegen.Plan(generation) + } + return nil +} diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 96e43b38ba..956b384f1c 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -93,6 +93,7 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar source, target, dir = h.UnwrapPair(source, target) prelude = dir.apply(&sourceVar, &targetVar, &newVar) } + ta = enterTransformAttrs(source, target, ta) if err := IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -144,6 +145,7 @@ func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) if h := ta.Hooks; h != nil && h.HelperNameAttrs != nil { source, target = h.HelperNameAttrs(source, target) } + ta = enterTransformAttrs(source, target, ta) sname = Goify(ta.SourceCtx.Scope.Name(source, ta.SourceCtx.Pkg(source), ta.SourceCtx.Pointer, ta.SourceCtx.UseDefault), true) tname = Goify(ta.TargetCtx.Scope.Name(target, ta.TargetCtx.Pkg(target), ta.TargetCtx.Pointer, ta.TargetCtx.UseDefault), true) prefix = ta.Prefix @@ -622,6 +624,7 @@ func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta * if h := ta.Hooks; h != nil && h.UnwrapPair != nil { source, target, _ = h.UnwrapPair(source, target) } + ta = enterTransformAttrs(source, target, ta) if topLevel { req = true } else { @@ -680,16 +683,19 @@ func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta * return helpers, err } +// enterTransformAttrs returns transform attributes whose source and target +// resolvers independently own the attributes being transformed. +func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *TransformAttrs) *TransformAttrs { + entered := *attributes + entered.SourceCtx = attributes.SourceCtx.Enter(source) + entered.TargetCtx = attributes.TargetCtx.Enter(target) + return &entered +} + // generateHelper generates the code that transforms instances of source into // target. Both source and target must be user types. The caller // (collectHelpers) guarantees no helper was generated yet for the pair. func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - ta = &TransformAttrs{ - SourceCtx: ta.SourceCtx.Enter(source), - TargetCtx: ta.TargetCtx.Enter(target), - Prefix: ta.Prefix, - Hooks: ta.Hooks, - } name := TransformHelperName(source, target, ta) code, err := TransformAttribute(source, target, "v", "res", true, ta) diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 515c34cbbc..1df0782d23 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -1,3 +1,5 @@ +// This file verifies Go transformations across primitive, composite, named, +// union, service, and transport-owned attribute contexts. package codegen import ( @@ -10,6 +12,15 @@ import ( "goa.design/goa/v3/expr" ) +type ( + transformOwnerAttributor struct { + prefix string + owner string + scope *NameScope + entered *[]string + } +) + func TestGoTransform(t *testing.T) { root := RunDSL(t, testdata.TestTypesDSL) var ( @@ -255,3 +266,127 @@ func TestGoTransformUnionAcrossTransportBoundary(t *testing.T) { require.NotContains(t, transportToService, "scopeValue") require.NotContains(t, transportToService, "target.Scope = &") } + +func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { + source := transformOwnerTestType("Envelope", "Choice") + target := transformOwnerTestType("Envelope", "Choice") + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + _, helpers, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, helpers) + require.Contains(t, helpers[0].ParamTypeRef, "sourceChoiceContainer.ChoiceContainer") + require.Contains(t, helpers[0].ResultTypeRef, "targetChoiceContainer.ChoiceContainer") + require.Contains(t, *sourceOwner.entered, "sourceEnvelope") + require.Contains(t, *sourceOwner.entered, "sourceChoiceContainer") + require.Contains(t, *sourceOwner.entered, "sourceChoice") + require.Contains(t, *targetOwner.entered, "targetEnvelope") + require.Contains(t, *targetOwner.entered, "targetChoiceContainer") + require.Contains(t, *targetOwner.entered, "targetChoice") + + reverseSource := newTransformOwnerAttributor("source") + reverseTarget := newTransformOwnerAttributor("target") + _, reverseHelpers, err := GoTransform( + &expr.AttributeExpr{Type: target}, + &expr.AttributeExpr{Type: source}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: reverseTarget}, + &AttributeContext{UseDefault: true, Scope: reverseSource}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, reverseHelpers) + require.Contains(t, reverseHelpers[0].ParamTypeRef, "targetChoiceContainer.ChoiceContainer") + require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceChoiceContainer.ChoiceContainer") +} + +func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { + entered := make([]string, 0) + return &transformOwnerAttributor{ + prefix: prefix, + scope: NewNameScope(), + entered: &entered, + } +} + +func (a *transformOwnerAttributor) Name(att *expr.AttributeExpr, _ string, _, _ bool) string { + return a.owner + "." + codegenTypeName(att) +} + +func (a *transformOwnerAttributor) Ref(att *expr.AttributeExpr, pkg string) string { + name := a.Name(att, pkg, false, false) + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +func (*transformOwnerAttributor) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (a *transformOwnerAttributor) Package(_ *expr.AttributeExpr) string { + return a.owner +} + +func (a *transformOwnerAttributor) Enter(att *expr.AttributeExpr) Attributor { + entered := *a + entered.owner = a.prefix + codegenTypeName(att) + *a.entered = append(*a.entered, entered.owner) + return &entered +} + +func (*transformOwnerAttributor) IsSumType() bool { + return true +} + +func (a *transformOwnerAttributor) Scope() *NameScope { + return a.scope +} + +func transformOwnerTestType(name, unionName string) expr.UserType { + union := &expr.Union{ + TypeName: unionName, + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "number", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + container := &expr.UserTypeExpr{ + TypeName: unionName + "Container", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "inner", Attribute: &expr.AttributeExpr{Type: container}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } +} + +func codegenTypeName(att *expr.AttributeExpr) string { + if att.Type.Name() == "object" { + return "Object" + } + return Goify(att.Type.Name(), true) +} diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index a97ae24a36..5319d53a93 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -25,35 +25,8 @@ type ( derived map[expr.UserType]codegen.DerivedTypeID view bool } - - // methodDeclarationAttributor binds one normalized method wrapper to its - // frozen declaration while leaving all other transport naming unchanged. - methodDeclarationAttributor struct { - origin expr.UserType - declaration *codegen.TypeDeclaration - delegate codegen.Attributor - } ) -// NewMethodTypeContext returns the service-side transport context for a named -// method type. The exact wrapper uses its frozen declaration; nested and wire -// attributes retain the transport's existing naming scope. -func NewMethodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - userType, ok := attribute.Type.(expr.UserType) - if !ok || declaration == nil { - panic("method type context requires a named generated declaration") - } - delegate := codegen.NewAttributeContext(false, false, true, pkg, scope).Scope - return &codegen.AttributeContext{ - UseDefault: true, - Scope: &methodDeclarationAttributor{ - origin: userType.Origin(), - declaration: declaration, - delegate: delegate, - }, - } -} - // newServiceResolver resolves declarations starting in service's generated // package and qualifies names relative to outputPath. func newServiceResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, outputPath string) *declarationResolver { @@ -105,6 +78,9 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe case *expr.Object: return r.Def(att, ptr, useDefault) case expr.UserType: + if actual == expr.Empty { + return "struct {}" + } if actual == expr.ErrorResult { return "goa.ServiceError" } @@ -316,67 +292,6 @@ func (r *declarationResolver) declarationName(attribute *expr.AttributeExpr) str return entered.userType(entered.currentPath, attribute.Type.(expr.UserType)).Name() } -// Name returns the frozen wrapper name for the bound method type and delegates -// every other attribute to the transport's existing scope. -func (a *methodDeclarationAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { - if a.matches(attribute) { - if pkg == "" { - return a.declaration.Name() - } - return pkg + "." + a.declaration.Name() - } - return a.delegate.Name(attribute, pkg, pointer, useDefault) -} - -// Ref returns the frozen wrapper reference for the bound method type and -// delegates every other attribute to the transport's existing scope. -func (a *methodDeclarationAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { - if !a.matches(attribute) { - return a.delegate.Ref(attribute, pkg) - } - name := a.Name(attribute, pkg, false, false) - if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { - return "*" + name - } - return name -} - -// Field delegates service field naming to the transport's existing scope. -func (a *methodDeclarationAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { - return a.delegate.Field(attribute, name, firstUpper) -} - -// Package delegates package qualification to the transport's existing scope. -func (a *methodDeclarationAttributor) Package(attribute *expr.AttributeExpr) string { - return a.delegate.Package(attribute) -} - -// Enter keeps the frozen binding for the exact wrapper and delegates nested -// attributes to the transport's existing package rules. -func (a *methodDeclarationAttributor) Enter(attribute *expr.AttributeExpr) codegen.Attributor { - if a.matches(attribute) { - return a - } - return a.delegate.Enter(attribute) -} - -// IsSumType preserves the transport scope's union representation. -func (a *methodDeclarationAttributor) IsSumType() bool { - return a.delegate.IsSumType() -} - -// Scope returns the transport naming scope used for all unbound attributes. -func (a *methodDeclarationAttributor) Scope() *codegen.NameScope { - return a.delegate.Scope() -} - -// matches reports whether attribute is the exact normalized wrapper bound to -// this rendering context. -func (a *methodDeclarationAttributor) matches(attribute *expr.AttributeExpr) bool { - userType, ok := attribute.Type.(expr.UserType) - return ok && userType.Origin() == a.origin -} - // serviceFieldIsPointer matches Goa service struct pointer semantics for one // field definition. func serviceFieldIsPointer(parent *expr.AttributeExpr, name string, pointer, useDefault bool) bool { diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index d2acce304d..75798155a6 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -169,6 +170,45 @@ func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { ) } +// TestServicesDataServiceAttributorUsesFrozenPackageDeclarations verifies +// transport generators can consume the same local, relocated, and nested +// declaration records used by service rendering without accessing resolver +// state. +func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T) { + var record expr.UserType + root := codegen.RunDSL(t, func() { + record = dsl.Type("Record", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "example.com/custom", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(record) + }) + }) + }) + services := mustServicesData(t, root) + external := services.ServiceAttributor("Values", "example.com/consumer") + recordAttribute := &expr.AttributeExpr{Type: record} + recordResolver := external.Enter(recordAttribute) + value := expr.AsObject(record.Attribute().Type).Attribute("Value") + externalValue := expr.AsObject(record.Attribute().Type).Attribute("external") + + require.Equal(t, "*types.Record", external.Ref(recordAttribute, "")) + require.Equal(t, "*types.Value", recordResolver.Ref(value, "")) + require.Equal(t, "custom.Value", recordResolver.Ref(externalValue, "")) + + typesPackage := "goa.design/goa/example/types" + local := services.ServiceAttributor("Values", typesPackage).Enter(recordAttribute) + require.Equal(t, "*Record", local.Ref(recordAttribute, "")) + require.Equal(t, "*Value", local.Ref(value, "")) +} + // aliasesForTest builds the same frozen full-path qualifier table used by // service analysis for the package paths exercised by a focused resolver test. func aliasesForTest(t *testing.T, paths ...string) *importAliases { diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 38892ad290..2cabb2431d 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -26,29 +26,17 @@ type ( genpkg string outputPackage string paths map[string]struct{} - legacy map[string]*codegen.ImportSpec } ) -// AttributeImports returns the generated-type and struct:field:type imports -// referenced by attributes using their preferred, unshared aliases. Transport -// generators retain this contract until their service-side references move to -// the declaration resolver in Task 5. -func AttributeImports(genpkg, outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { - collector := newImportCollector(nil, genpkg, outputPackage) - for _, attribute := range attributes { - collector.collect(attribute) - } - return collector.imports() -} - // AttributeImports returns the exact generated-type and metadata imports // referenced by attributes using the frozen aliases shared with service type // references. func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) + seen := make(map[expr.UserType]struct{}) for _, attribute := range attributes { - collector.collect(attribute) + collector.collectReferences(attribute, seen) } return collector.imports() } @@ -62,7 +50,7 @@ func (d *ServicesData) fileImports(outputPackage string, paths []string, attribu collector.addPath(importPath) } for _, attribute := range attributes { - collector.collect(attribute) + collector.collectDefinition(attribute) } return collector.imports() } @@ -224,7 +212,6 @@ func newImportCollector(aliases *importAliases, genpkg, outputPackage string) *i genpkg: genpkg, outputPackage: outputPackage, paths: make(map[string]struct{}), - legacy: make(map[string]*codegen.ImportSpec), } } @@ -236,9 +223,20 @@ func (c *importCollector) addPath(importPath string) { } } -// collect walks inline shapes but stops at named types because a reference to a -// named declaration does not render that declaration's fields in the file. -func (c *importCollector) collect(attribute *expr.AttributeExpr) { +// collectDefinition records imports used to render an attribute definition. +// Named references stop traversal because their fields are emitted elsewhere. +func (c *importCollector) collectDefinition(attribute *expr.AttributeExpr) { + c.collectAttribute(attribute, false, nil) +} + +// collectReferences records imports used by recursive conversion and +// validation code, including types and metadata nested in named declarations. +func (c *importCollector) collectReferences(attribute *expr.AttributeExpr, seen map[expr.UserType]struct{}) { + c.collectAttribute(attribute, true, seen) +} + +// collectAttribute implements definition and recursive-reference traversal. +func (c *importCollector) collectAttribute(attribute *expr.AttributeExpr, expandNamed bool, seen map[expr.UserType]struct{}) { if attribute == nil || attribute.Type == expr.Empty { return } @@ -246,18 +244,27 @@ func (c *importCollector) collect(attribute *expr.AttributeExpr) { switch actual := attribute.Type.(type) { case expr.UserType: c.addLocation(codegen.UserTypeLocation(actual)) + if !expandNamed { + return + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + c.collectAttribute(actual.Attribute(), true, seen) case *expr.Object: for _, named := range *actual { - c.collect(named.Attribute) + c.collectAttribute(named.Attribute, expandNamed, seen) } case *expr.Array: - c.collect(actual.ElemType) + c.collectAttribute(actual.ElemType, expandNamed, seen) case *expr.Map: - c.collect(actual.KeyType) - c.collect(actual.ElemType) + c.collectAttribute(actual.KeyType, expandNamed, seen) + c.collectAttribute(actual.ElemType, expandNamed, seen) case *expr.Union: for _, named := range actual.Values { - c.collect(named.Attribute) + c.collectAttribute(named.Attribute, expandNamed, seen) } } } @@ -271,10 +278,6 @@ func (c *importCollector) addLocation(location *codegen.Location) { importPath := path.Join(c.genpkg, location.RelImportPath) if importPath != c.outputPackage { c.paths[importPath] = struct{}{} - c.legacy[importPath] = &codegen.ImportSpec{ - Name: location.PackageName(), - Path: importPath, - } } } @@ -284,7 +287,6 @@ func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { _, spec := codegen.GetMetaType(attribute) if spec != nil && spec.Path != c.outputPackage { c.paths[spec.Path] = struct{}{} - c.legacy[spec.Path] = spec } } @@ -298,11 +300,7 @@ func (c *importCollector) imports() []*codegen.ImportSpec { sort.Strings(paths) imports := make([]*codegen.ImportSpec, len(paths)) for i, importPath := range paths { - if c.aliases != nil { - imports[i] = c.aliases.spec(importPath) - continue - } - imports[i] = c.legacy[importPath] + imports[i] = c.aliases.spec(importPath) } return imports } diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index e4e8bc1fc1..5bce793d3a 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -337,7 +337,7 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { require.Equal(t, "json2.Value", data.Fields[0].FieldType) collector := newImportCollector(aliases, generation.GenPkg(), "generated.local/gen/values") - collector.collect(branch) + collector.collectDefinition(branch) header := codegen.Header( "Union types", "values", diff --git a/codegen/service/service.go b/codegen/service/service.go index 18fa1f2b97..6a80f08b34 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -273,7 +273,7 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F }) collector := newImportCollector(aliases, genpkg, packagePath) for _, generatedType := range generatedTypes { - collector.collect(generatedType.userType.Attribute()) + collector.collectDefinition(generatedType.userType.Attribute()) } imports := collector.imports() sections := []*codegen.SectionTemplate{ @@ -302,8 +302,8 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F } for _, union := range unions { for _, field := range union.Fields { - collector.collect(field.reference) - collector.collect(field.definition) + collector.collectDefinition(field.reference) + collector.collectDefinition(field.definition) } } imports := collector.imports() diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index 1d4be20f2b..e6e90c21a3 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -724,6 +724,64 @@ func (d *ServicesData) Get(name string) *Data { return d.Services[name] } +// GenPkg returns the generated module import path shared by every declaration, +// import alias, and transport built from this service analysis. +func (d *ServicesData) GenPkg() string { + return d.generation.GenPkg() +} + +// ServiceImport returns the frozen import alias for name's generated service +// package. The returned value is a copy that callers may add to one file. +func (d *ServicesData) ServiceImport(name string) *codegen.ImportSpec { + service := d.Root.Service(name) + if service == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + spec := d.aliases.spec(servicePackagePath(d.generation.GenPkg(), service)) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ViewImport returns the frozen import alias for name's generated views +// package. The returned value is a copy that callers may add to one file. +func (d *ServicesData) ViewImport(name string) *codegen.ImportSpec { + service := d.Root.Service(name) + if service == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + spec := d.aliases.spec(servicePackagePath(d.generation.GenPkg(), service) + "/views") + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// PackageImport returns the frozen import alias for importPath. The returned +// value is a copy that callers may add to one generated file. +func (d *ServicesData) PackageImport(importPath string) *codegen.ImportSpec { + spec := d.aliases.spec(importPath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ServiceAttributor returns the frozen service declaration resolver for name +// as referenced from outputPackage. The returned resolver follows explicit +// generated package locations and uses the same import aliases as service +// rendering. +func (d *ServicesData) ServiceAttributor(name, outputPackage string) codegen.Attributor { + service := d.Root.Service(name) + if service == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + return newServiceResolver(d.generation, d.aliases, service, outputPackage) +} + +// ViewAttributor returns the frozen projected and viewed result declaration +// resolver for name as referenced from outputPackage. +func (d *ServicesData) ViewAttributor(name, outputPackage string) codegen.Attributor { + service := d.Root.Service(name) + if service == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + data := d.Services[name] + return newViewResolver(d.generation, d.aliases, service, data.viewDerived).withOutputPackage(outputPackage) +} + // Method returns the service method data for the method with the given name, // nil if there isn't one. func (d *Data) Method(name string) *MethodData { diff --git a/codegen/union.go b/codegen/union.go index 2498fad5a6..e1a87d9eb8 100644 --- a/codegen/union.go +++ b/codegen/union.go @@ -31,14 +31,6 @@ func NewUnionTypeID(union *expr.Union) UnionTypeID { return UnionTypeID(key.String()) } -// UnionTypeHash returns the string form of a generated union identity. -// -// Deprecated: use NewUnionTypeID so generated-definition identity remains -// distinct from design expression hashes at naming and package ownership sites. -func UnionTypeHash(union *expr.Union) string { - return NewUnionTypeID(union).Hash() -} - // Hash returns the exact identity used by a generated package's name scope. func (id UnionTypeID) Hash() string { return string(id) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 9e1c8863cf..2c0d2a6194 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -291,27 +291,27 @@ Expected: PASS. - Consumes: service data bound to frozen `TypeDeclaration` records and package scopes - Produces: package-aware `Attributor` contexts for every recursive service-type transform -- [ ] **Step 1: Add focused transport reference assertions** +- [x] **Step 1: Add focused transport reference assertions** For the two-service nested-union design, assert that HTTP and gRPC conversion helpers refer to the exact union names declared in the relocated package. Keep transport wire-type scopes independent. -- [ ] **Step 2: Make attribute contexts carry package ownership** +- [x] **Step 2: Make attribute contexts carry package ownership** Add the generated package path or frozen declaration resolver required for an `Attributor` to select the enclosing service package while recursion enters a relocated user type. `AttributeContext.Dup` must preserve it, and helper generation must update it when `struct:pkg:path` changes the enclosing package. -- [ ] **Step 3: Replace direct service-scope recomputation** +- [x] **Step 3: Replace direct service-scope recomputation** HTTP, WebSocket, SSE, client/server callbacks, gRPC conversions, gRPC `fullTypeName`, and transport generator setup resolve service types through the frozen service attributor or existing canonical method declaration. `sd.Scope` continues to name only HTTP/protobuf wire declarations. -- [ ] **Step 4: Run the generated-module regression** +- [x] **Step 4: Run the generated-module regression** Run: @@ -321,7 +321,7 @@ go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 Expected: PASS with HTTP and gRPC enabled. -- [ ] **Step 5: Run all core codegen tests** +- [x] **Step 5: Run all core codegen tests** Run: diff --git a/dsl/error.go b/dsl/error.go index 35bad61525..8f0c172d07 100644 --- a/dsl/error.go +++ b/dsl/error.go @@ -1,3 +1,6 @@ +// This file defines service and method error DSL functions. Error declarations +// select service value contracts; transport mappings separately choose how +// those values are encoded by HTTP or gRPC. package dsl import ( @@ -62,6 +65,12 @@ const ( // the service methods) or Method expressions. Error may also appear under the API // expression to create reusable error definitions. // +// A reusable API or service transport response mapping is matched to a method +// error by name, but it does not replace the method's error type. If a method or +// service shadows the reusable error with the same name, both error attributes +// must define the same type, validations, defaults, and struct metadata. Goa +// rejects incompatible definitions during design validation. +// // See Attribute for details on the Error arguments. // // Example: diff --git a/expr/error_contract.go b/expr/error_contract.go new file mode 100644 index 0000000000..b7bc8964d7 --- /dev/null +++ b/expr/error_contract.go @@ -0,0 +1,176 @@ +// This file defines the transport-independent error contract used when an +// endpoint inherits HTTP or gRPC response policy from its service or API. +// Transport policy may select status codes and wire fields, but the method's +// effective error remains the concrete service value encoded on every path. +package expr + +import ( + "reflect" + "slices" +) + +type ( + // attributePair identifies two nodes already compared while traversing + // recursive error types. + attributePair struct { + first *AttributeExpr + second *AttributeExpr + } +) + +// equivalentErrorAttributes reports whether two error attributes generate the +// same service value contract. Descriptions and examples are documentation; +// types, validations, defaults, and metadata affect generated code or runtime +// behavior and must match. +func equivalentErrorAttributes(first, second *AttributeExpr) bool { + if first == second { + return true + } + if first == nil || second == nil { + return false + } + return equivalentErrorAttributeNodes(first, second, make(map[attributePair]struct{})) +} + +// equivalentErrorAttributeNodes compares every contract-bearing node while +// stopping when recursive user types revisit the same declaration pair. +func equivalentErrorAttributeNodes(first, second *AttributeExpr, seen map[attributePair]struct{}) bool { + if first == second { + return true + } + pair := attributePair{first: first, second: second} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !equivalentErrorValidation(first.Validation, second.Validation) || + !reflect.DeepEqual(first.DefaultValue, second.DefaultValue) || + !equivalentErrorMetadata(first.Meta, second.Meta) { + return false + } + + switch firstType := first.Type.(type) { + case Primitive: + secondType, ok := second.Type.(Primitive) + return ok && firstType == secondType + case UserType: + secondType, ok := second.Type.(UserType) + return ok && + firstType.Name() == secondType.Name() && + equivalentErrorAttributeNodes(firstType.Attribute(), secondType.Attribute(), seen) + case *Object: + secondType, ok := second.Type.(*Object) + if !ok || len(*firstType) != len(*secondType) { + return false + } + for _, field := range *firstType { + other := secondType.Attribute(field.Name) + if other == nil || !equivalentErrorAttributeNodes(field.Attribute, other, seen) { + return false + } + } + case *Array: + secondType, ok := second.Type.(*Array) + return ok && + firstType.NonNullableElems == secondType.NonNullableElems && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Map: + secondType, ok := second.Type.(*Map) + return ok && + equivalentErrorAttributeNodes(firstType.KeyType, secondType.KeyType, seen) && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Union: + secondType, ok := second.Type.(*Union) + if !ok || + firstType.TypeName != secondType.TypeName || + firstType.GetTypeKey() != secondType.GetTypeKey() || + firstType.GetValueKey() != secondType.GetValueKey() || + len(firstType.Values) != len(secondType.Values) { + return false + } + for _, branch := range firstType.Values { + var other *AttributeExpr + for _, candidate := range secondType.Values { + if candidate.Name == branch.Name { + other = candidate.Attribute + break + } + } + if other == nil || !equivalentErrorAttributeNodes(branch.Attribute, other, seen) { + return false + } + } + default: + return false + } + return true +} + +// equivalentErrorValidation compares validation behavior independently of the +// authored order of required fields and enum values. +func equivalentErrorValidation(first, second *ValidationExpr) bool { + if first == nil { + first = new(ValidationExpr) + } + if second == nil { + second = new(ValidationExpr) + } + firstScalars, secondScalars := *first, *second + firstScalars.Required, secondScalars.Required = nil, nil + firstScalars.Values, secondScalars.Values = nil, nil + return reflect.DeepEqual(firstScalars, secondScalars) && + equivalentStringSet(first.Required, second.Required) && + equivalentValueSet(first.Values, second.Values) +} + +// equivalentStringSet reports whether both slices contain the same distinct +// strings; validation order does not affect runtime behavior. +func equivalentStringSet(first, second []string) bool { + if len(first) != len(second) { + return false + } + for _, value := range first { + if !slices.Contains(second, value) { + return false + } + } + return true +} + +// equivalentValueSet reports whether both enum lists contain the same values +// regardless of declaration order. +func equivalentValueSet(first, second []any) bool { + if len(first) != len(second) { + return false + } + matched := make([]bool, len(second)) + for _, value := range first { + found := false + for index, candidate := range second { + if !matched[index] && reflect.DeepEqual(value, candidate) { + matched[index] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// equivalentErrorMetadata compares metadata keys and ordered values while +// treating nil and empty maps or value slices as the same absent content. +func equivalentErrorMetadata(first, second MetaExpr) bool { + if len(first) != len(second) { + return false + } + for key, values := range first { + other, ok := second[key] + if !ok || !slices.Equal(values, other) { + return false + } + } + return true +} diff --git a/expr/error_contract_test.go b/expr/error_contract_test.go new file mode 100644 index 0000000000..74a1ae7323 --- /dev/null +++ b/expr/error_contract_test.go @@ -0,0 +1,43 @@ +// This file verifies canonical comparison of reusable transport error value +// contracts independently of authored ordering and explicit default storage. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEquivalentErrorAttributesUseEffectiveUnionKeys(t *testing.T) { + branches := []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + } + implicit := &AttributeExpr{Type: &Union{TypeName: "Value", Values: branches}} + explicit := &AttributeExpr{Type: &Union{ + TypeName: "Value", + TypeKey: "type", + ValueKey: "value", + Values: branches, + }} + + require.True(t, equivalentErrorAttributes(implicit, explicit)) +} + +func TestEquivalentErrorAttributesIgnoreRequiredOrder(t *testing.T) { + first := requiredObject("first", "second") + second := requiredObject("second", "first") + + require.True(t, equivalentErrorAttributes(first, second)) +} + +// requiredObject returns the same two-field error object with the requested +// validation order so the test distinguishes authored order from semantics. +func requiredObject(required ...string) *AttributeExpr { + return &AttributeExpr{ + Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: String}}, + {Name: "second", Attribute: &AttributeExpr{Type: String}}, + }, + Validation: &ValidationExpr{Required: required}, + } +} diff --git a/expr/grpc_endpoint.go b/expr/grpc_endpoint.go index 11a0f76d45..2e1d723279 100644 --- a/expr/grpc_endpoint.go +++ b/expr/grpc_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the gRPC transport contract for +// one service method. package expr import ( @@ -251,6 +253,31 @@ func (e *GRPCEndpointExpr) Validate() error { verr.Merge(validateRPCTags(AsObject(ee.Type), e)) } } + verr.Merge(e.validateErrorMappings()) + return verr +} + +// validateErrorMappings ensures inherited gRPC response policy describes the +// same concrete error value returned by the endpoint method. +func (e *GRPCEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.GRPCErrors { + mapped, owner := mapping.mappedError() + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `gRPC error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute (type, validations, defaults, and struct metadata)`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + ) + } return verr } diff --git a/expr/grpc_error.go b/expr/grpc_error.go index b387254e91..4a2989f3f3 100644 --- a/expr/grpc_error.go +++ b/expr/grpc_error.go @@ -1,3 +1,5 @@ +// This file binds reusable gRPC error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -45,17 +47,22 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { // Finalize looks up the corresponding method error expression. func (e *GRPCErrorExpr) Finalize(a *GRPCEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { + e.ErrorExpr = a.MethodExpr.Error(e.Name) + e.Response.Finalize(a, e.AttributeExpr) +} + +// mappedError returns the error declaration that owns this reusable gRPC +// response policy before the policy is applied to an endpoint method. +func (e *GRPCErrorExpr) mappedError() (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { case *GRPCEndpointExpr: - ee = p.MethodExpr.Error(e.Name) + return parent.MethodExpr.Error(e.Name), "method" case *GRPCServiceExpr: - ee = p.Error(e.Name) - case *GRPCExpr: - ee = Root.Error(e.Name) + return parent.Error(e.Name), "service" + case *GRPCExpr, *RootExpr: + return Root.Error(e.Name), "API" } - e.ErrorExpr = ee - e.Response.Finalize(a, e.AttributeExpr) + return nil, "" } // Dup creates a copy of the error expression. diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index 565d63bb8c..11f4dad3b6 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the HTTP transport contract for +// one service method. package expr import ( @@ -604,6 +606,7 @@ func (e *HTTPEndpointExpr) Validate() error { for _, er := range e.HTTPErrors { verr.Merge(er.Validate()) } + verr.Merge(e.validateErrorMappings()) // Validate definitions of params, headers and bodies against definition of payload var ( @@ -750,6 +753,30 @@ func (e *HTTPEndpointExpr) Validate() error { return verr } +// validateErrorMappings ensures inherited HTTP response policy describes the +// same concrete error value returned by the endpoint method. +func (e *HTTPEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.HTTPErrors { + mapped, owner := mapping.mappedError() + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `HTTP error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute (type, validations, defaults, and struct metadata)`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + ) + } + return verr +} + // Finalize is run post DSL execution. It merges response definitions, creates // implicit endpoint parameters and initializes querystring parameters. It also // flattens the error responses and makes sure the error types are all user diff --git a/expr/http_error.go b/expr/http_error.go index 9f2ef4d1be..d7e6ed4121 100644 --- a/expr/http_error.go +++ b/expr/http_error.go @@ -1,3 +1,5 @@ +// This file binds reusable HTTP error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -87,16 +89,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { // Finalize looks up the corresponding method error expression. func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { - case *HTTPEndpointExpr: - ee = p.MethodExpr.Error(e.Name) - case *HTTPServiceExpr: - ee = p.Error(e.Name) - case *RootExpr: - ee = Root.Error(e.Name) - } - e.ErrorExpr = ee + e.ErrorExpr = a.MethodExpr.Error(e.Name) e.Response.Finalize(a, e.AttributeExpr) if e.Response.Body == nil { e.Response.Body = httpErrorResponseBody(a, e) @@ -119,6 +112,20 @@ func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { e.Response.ContentType = mt.Identifier } +// mappedError returns the error declaration that owns this reusable HTTP +// response policy before the policy is applied to an endpoint method. +func (e *HTTPErrorExpr) mappedError() (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { + case *HTTPEndpointExpr: + return parent.MethodExpr.Error(e.Name), "method" + case *HTTPServiceExpr: + return parent.Error(e.Name), "service" + case *RootExpr: + return Root.Error(e.Name), "API" + } + return nil, "" +} + // Dup creates a copy of the error expression. func (e *HTTPErrorExpr) Dup() *HTTPErrorExpr { return &HTTPErrorExpr{ diff --git a/expr/transport_error_contract_test.go b/expr/transport_error_contract_test.go new file mode 100644 index 0000000000..9f40cf0dc1 --- /dev/null +++ b/expr/transport_error_contract_test.go @@ -0,0 +1,189 @@ +// This file verifies that reusable transport error mappings never replace the +// service error contract selected by an endpoint method. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestHTTPInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"different type", incompatibleHTTPErrorMappingDSL}, + {"different validation", incompatibleHTTPErrorValidationDSL}, + {"different named type", incompatibleHTTPNamedErrorMappingDSL}, + {"different object", incompatibleHTTPObjectErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `HTTP error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + +func TestGRPCInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestServiceErrorMappingsUseMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentServiceErrorMappingDSL) + httpEndpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + grpcEndpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + methodError := httpEndpoint.MethodExpr.Error("bad_request") + + require.Same(t, methodError, httpEndpoint.HTTPErrors[0].ErrorExpr) + require.Same(t, methodError, grpcEndpoint.GRPCErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingAcceptsEquivalentValidationOrder(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorValidationOrderDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestGRPCInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + err := expr.RunInvalidDSL(t, incompatibleGRPCErrorMappingDSL) + require.ErrorContains(t, err, `gRPC error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") +} + +var equivalentHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorValidationDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { MinLength(2) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { MinLength(3) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentHTTPErrorValidationOrderDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { Enum("first", "second") }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { Enum("second", "first") }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPNamedErrorMappingDSL = func() { + firstError := Type("FirstError", func() { Attribute("message", String) }) + secondError := Type("SecondError", func() { Attribute("message", String) }) + API("errors", func() { + Error("bad_request", firstError) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", secondError) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPObjectErrorMappingDSL = func() { + stringError := Type("StringError", func() { Attribute("header") }) + API("errors", func() { + Error("bad_request", stringError) + HTTP(func() { + Response("bad_request", StatusBadRequest, func() { Header("header") }) + }) + }) + Service("Errors", func() { + Error("bad_request") + Method("Show", func() { HTTP(func() { GET("/") }) }) + }) +} + +var equivalentGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + GRPC(func() {}) + }) + }) +} + +var incompatibleGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + GRPC(func() {}) + }) + }) +} + +var equivalentServiceErrorMappingDSL = func() { + Service("Errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index da491560d6..f8f5fb6408 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -13,20 +13,20 @@ import ( // ClientFiles returns the client files that contain client methods to call the // corresponding service methods along with the encoding and decoding logic. -func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { +func ClientFiles(services *ServicesData) []*codegen.File { svcLen := len(services.Root.API.GRPC.Services) fw := make([]*codegen.File, 2*svcLen) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(clientFile(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) + fw[i] = addEndpointImports(clientFile(svc, services), services, svc.GRPCEndpoints...) } for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = addEndpointImports(clientEncodeDecode(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) + fw[i+svcLen] = addEndpointImports(clientEncodeDecode(svc, services), services, svc.GRPCEndpoints...) } return fw } // clientFile returns the file implementing the gRPC client. -func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -42,9 +42,9 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), codegen.GoaNamedImport("grpc/pb", "goapb"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC client", "client", imports), @@ -113,7 +113,7 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // clientEncodeDecode returns the file containing the gRPC client encoding and // decoding logic. -func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -132,9 +132,9 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} fm := transTmplFuncs(svc, services) diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index f0cf29ed72..ddab8dd44d 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -13,7 +13,7 @@ import ( // ClientCLIFiles returns the CLI files to generate a command-line client that // makes gRPC requests. -func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +func ClientCLIFiles(services *ServicesData) []*codegen.File { if len(services.Root.API.GRPC.Services) == 0 { return nil } @@ -38,17 +38,18 @@ func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { } files := make([]*codegen.File, 0, len(services.Root.API.Servers)+len(svcs)) for _, svr := range services.Root.API.Servers { - files = append(files, endpointParser(genpkg, services, svr, data)) + files = append(files, endpointParser(services, svr, data)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, data[i], services)) + files = append(files, payloadBuilders(svc, data[i], services)) } return files } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, data []*cli.CommandData) *codegen.File { +func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.CommandData) *codegen.File { + genpkg := services.GenPkg() pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) fpath := filepath.Join(codegen.Gendir, "grpc", "cli", pkg, "cli.go") title := svr.Name + " gRPC client CLI support package" @@ -84,13 +85,10 @@ func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, svcName := sd.Service.PathName specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, "client"), Name: sd.Service.PkgName + "c"}, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: svcName + pbPkgName}) + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName))) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + specs = append(specs, services.ServiceImport(svc.Name())) } } @@ -110,7 +108,7 @@ func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(svc *expr.GRPCServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { sd := services.Get(svc.Name()) svcName := sd.Service.PathName fpath := filepath.Join(codegen.Gendir, "grpc", svcName, "client", "cli.go") @@ -121,8 +119,8 @@ func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.Command {Path: "strconv"}, {Path: "unicode/utf8"}, codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, + services.ServiceImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } // Add structpb import if Any type is used if usesAnyType(svc.GRPCEndpoints, false) { @@ -130,7 +128,7 @@ func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.Command &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), genpkg, svc.GRPCEndpoints...) + return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), services, svc.GRPCEndpoints...) } func buildFlags(e *EndpointData) ([]*cli.FlagData, *cli.BuildFunctionData) { diff --git a/grpc/codegen/client_cli_test.go b/grpc/codegen/client_cli_test.go index c2b906c865..416f0f5340 100644 --- a/grpc/codegen/client_cli_test.go +++ b/grpc/codegen/client_cli_test.go @@ -23,7 +23,7 @@ func TestClientCLIFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + fs := ClientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[1].SectionTemplates) var buf bytes.Buffer diff --git a/grpc/codegen/client_test.go b/grpc/codegen/client_test.go index 464056963f..7bb591001b 100644 --- a/grpc/codegen/client_test.go +++ b/grpc/codegen/client_test.go @@ -32,7 +32,7 @@ func TestClientEndpointInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("client-endpoint-init") if len(sections) == 0 { @@ -64,7 +64,7 @@ func TestRequestEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-encoder") require.NotEmpty(t, sections) @@ -95,7 +95,7 @@ func TestResponseDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-decoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/client_types_test.go b/grpc/codegen/client_types_test.go index 4a65fb5039..083097add3 100644 --- a/grpc/codegen/client_types_test.go +++ b/grpc/codegen/client_types_test.go @@ -31,7 +31,7 @@ func TestClientTypeFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientTypeFiles("", services) + fs := ClientTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index c06c228701..663807752e 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -11,10 +11,10 @@ import ( ) // ExampleCLIFiles returns an example gRPC client tool implementation. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +func ExampleCLIFiles(services *ServicesData) []*codegen.File { var files []*codegen.File for _, svr := range services.Root.API.Servers { - if f := exampleCLI(genpkg, services, svr); f != nil { + if f := exampleCLI(services, svr); f != nil { files = append(files, f) } } @@ -23,7 +23,8 @@ func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { // exampleCLI returns an example client tool HTTP implementation for the given // server expression. -func exampleCLI(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { +func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { + genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, services.Root) mainPath := filepath.Join("cmd", svrdata.Dir+"-cli", "grpc.go") if _, err := os.Stat(mainPath); !os.IsNotExist(err) { diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index 24908f2830..a673195f28 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -1,3 +1,4 @@ +// This file verifies generated gRPC command-line client examples. package codegen import ( @@ -32,8 +33,8 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(createServiceServices(root)) - fs := ExampleCLIFiles(c.PkgPath, services) + services := NewServicesData(createServiceServicesForPackage(root, c.PkgPath)) + fs := ExampleCLIFiles(services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index c2d5bcc417..ed7a7abdbd 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -11,10 +11,10 @@ import ( ) // ExampleServerFiles returns an example gRPC server implementation. -func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { +func ExampleServerFiles(services *ServicesData) []*codegen.File { var fw []*codegen.File for _, svr := range services.Root.API.Servers { - if m := exampleServer(genpkg, services, svr); m != nil { + if m := exampleServer(services, svr); m != nil { fw = append(fw, m) } } @@ -22,9 +22,10 @@ func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { } // exampleServer returns an example gRPC server implementation. -func exampleServer(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { +func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { var ( mainPath string + genpkg = services.GenPkg() svrdata = example.Servers.Get(svr, services.Root) ) diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index fcc5940e38..bdbb3e71ef 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -1,3 +1,4 @@ +// This file verifies generated gRPC server examples. package codegen import ( @@ -27,7 +28,7 @@ func TestExampleServerFiles(t *testing.T) { example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) services := NewServicesData(createServiceServices(root)) - fs := ExampleServerFiles("", services) + fs := ExampleServerFiles(services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/grpc/codegen/idempotency_test.go b/grpc/codegen/idempotency_test.go index 437775b746..dbe96a3bac 100644 --- a/grpc/codegen/idempotency_test.go +++ b/grpc/codegen/idempotency_test.go @@ -15,14 +15,14 @@ func TestIdempotentRPCCodegen(t *testing.T) { root := RunGRPCDSL(t, testdata.IdempotentRPCsDSL) services := CreateGRPCServices(root) - protoFiles := ProtoFiles("", services) + protoFiles := ProtoFiles(services) require.Len(t, protoFiles, 1) protoCode := sectionCode(t, protoFiles[0].SectionTemplates[1:]...) assert.Equal(t, 2, strings.Count(protoCode, "option idempotency_level = IDEMPOTENT;")) protoPath := codegen.CreateTempFile(t, protoCode) assert.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) - clientFiles := ClientFiles("", services) + clientFiles := ClientFiles(services) require.Len(t, clientFiles, 2) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) assert.Contains(t, clientCode, `goa.RetryEndpoint(endpoint, "busy")`) diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 04b7e7bbb2..48c90daac6 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -1,3 +1,5 @@ +// This file verifies protobuf generation for unions containing anonymous user +// type branches. package codegen import ( @@ -44,7 +46,7 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { }) sd := &ServiceData{Name: "Svc", Scope: codegen.NewNameScope()} - svcCtx := serviceTypeContext("proto", sd.Scope) + svcCtx := codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) pbCtx := protoBufTypeContext("proto", sd.Scope, true) // Transform protobuf -> Go for Container diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index ca592dcaa9..caa0079f18 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -25,8 +25,8 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + services := NewServicesData(createServiceServicesForPackage(root, "")) + fs := ClientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) var buf bytes.Buffer diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go new file mode 100644 index 0000000000..683f5c983f --- /dev/null +++ b/grpc/codegen/plan.go @@ -0,0 +1,61 @@ +// This file declares gRPC runtime imports and generated protobuf package +// aliases before the shared generation catalog is frozen. +package codegen + +import ( + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// Plan reserves every literal gRPC import qualifier and each generated +// protobuf package alias used by gRPC render templates. +func Plan(generation *codegen.Generation) error { + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("net"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("os"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("sync"), + codegen.SimpleImport("time"), + codegen.SimpleImport("unicode/utf8"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.GoaNamedImport("grpc/pb", "goapb"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/codes"), + codegen.SimpleImport("google.golang.org/grpc/credentials/insecure"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + codegen.SimpleImport("google.golang.org/grpc/reflection"), + codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb"), + } + for _, spec := range imports { + if err := generation.RequireImport(spec); err != nil { + return err + } + } + for _, root := range generation.Roots() { + design, ok := root.(*expr.RootExpr) + if !ok { + continue + } + for _, service := range design.API.GRPC.Services { + protobufName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) + protobufPath := path.Join(generation.GenPkg(), "grpc", protobufName, pbPkgName) + if err := generation.ReserveGeneratedImport(codegen.NewImport(protobufName+"pb", protobufPath)); err != nil { + return err + } + } + } + return nil +} diff --git a/grpc/codegen/proto.go b/grpc/codegen/proto.go index 88e1db6f3a..4251cb4c82 100644 --- a/grpc/codegen/proto.go +++ b/grpc/codegen/proto.go @@ -20,16 +20,17 @@ const ( ) // ProtoFiles returns the protobuf file for every gRPC service. -func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { +func ProtoFiles(services *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = protoFile(genpkg, svc, services) + fw[i] = protoFile(svc, services) } return fw } // protoFile returns the protobuf file defining the specified service. -func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() data := services.Get(svc.Name()) svcName := data.Service.PathName parts := strings.Split(genpkg, "/") diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go index 61cd9b9ca9..691bde84b2 100644 --- a/grpc/codegen/proto_test.go +++ b/grpc/codegen/proto_test.go @@ -40,7 +40,7 @@ func TestProtoFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := ProtoFiles(services) if len(fs) != 1 { t.Fatalf("got %d files, expected one", len(fs)) } @@ -75,7 +75,7 @@ func TestMessageDefSection(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := ProtoFiles(services) require.Len(t, fs, 1) sections := fs[0].SectionTemplates require.GreaterOrEqual(t, len(sections), 3) diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index 413aab48a4..fce44b6061 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -1,3 +1,5 @@ +// This file verifies generated transformations between service values and +// protobuf messages. package codegen import ( @@ -52,7 +54,7 @@ func TestProtoBufTransform(t *testing.T) { pkgOverride = root.UserType("CompositePkgOverride") // attribute contexts used in test cases - svcCtx = serviceTypeContext("proto", sd.Scope) + svcCtx = codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) ptrCtx = pointerContext("proto", sd.Scope) pbCtx = protoBufTypeContext("proto", sd.Scope, true) ) diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index a91c775adf..c68240aa0f 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -15,20 +15,20 @@ import ( // contain the server which implements the generated gRPC server interface and // encoders and decoders to transform protocol buffer types and gRPC metadata // into goa types and vice versa. -func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { +func ServerFiles(services *ServicesData) []*codegen.File { svcLen := len(services.Root.API.GRPC.Services) fw := make([]*codegen.File, 2*svcLen) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(serverFile(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) + fw[i] = addEndpointImports(serverFile(svc, services), services, svc.GRPCEndpoints...) } for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = addEndpointImports(serverEncodeDecode(genpkg, svc, services), genpkg, svc.GRPCEndpoints...) + fw[i+svcLen] = addEndpointImports(serverEncodeDecode(svc, services), services, svc.GRPCEndpoints...) } return fw } // serverFile returns the files defining the gRPC server. -func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -44,9 +44,9 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), {Path: "google.golang.org/grpc/codes"}, - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } for _, e := range data.Endpoints { if e.Request.StreamEnvelope != nil { @@ -125,7 +125,7 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // serverEncodeDecode returns the file defining the gRPC server encoding and // decoding logic. -func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -145,9 +145,9 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } sections = []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} @@ -181,7 +181,7 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv func transTmplFuncs(s *expr.GRPCServiceExpr, services *ServicesData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return services.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, } } diff --git a/grpc/codegen/server_test.go b/grpc/codegen/server_test.go index bd8f52c834..fb48b17478 100644 --- a/grpc/codegen/server_test.go +++ b/grpc/codegen/server_test.go @@ -33,7 +33,7 @@ func TestServerGRPCInterface(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("server-grpc-interface") require.NotEmpty(t, sections) @@ -61,7 +61,7 @@ func TestServerHandlerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("grpc-handler-init") require.NotEmpty(t, sections) @@ -93,7 +93,7 @@ func TestRequestDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-decoder") require.NotEmpty(t, sections) @@ -121,7 +121,7 @@ func TestResponseEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-encoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/server_types_test.go b/grpc/codegen/server_types_test.go index 5aea024276..dd1e908509 100644 --- a/grpc/codegen/server_types_test.go +++ b/grpc/codegen/server_types_test.go @@ -33,7 +33,7 @@ func TestServerTypeFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerTypeFiles("", services) + fs := ServerTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index b2fd99c0c8..c66d2b11ff 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -4,6 +4,7 @@ package codegen import ( "fmt" + "path" "strings" "goa.design/goa/v3/codegen" @@ -132,6 +133,9 @@ type ( FieldName string // FieldType is the type of the struct field. FieldType expr.DataType + // FieldTypeRef is the frozen service reference used to cast an aliased + // metadata value before assigning it to the service field. + FieldTypeRef string // VarName is the name of the Go variable used to read or // convert the metadata value. VarName string @@ -345,6 +349,9 @@ type ( // FieldType is the type of the data structure field that should be // initialized with the argument if any. FieldType expr.DataType + // FieldTypeRef is the frozen service reference used to cast an aliased + // argument before assigning it to the service field. + FieldTypeRef string // TypeName is the argument type name. TypeName string // TypeRef is the argument type reference. @@ -493,8 +500,13 @@ func (sd *ServiceData) HasStreamingEndpoint() bool { // analyze creates the data necessary to render the code of the given service. func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { svc := d.ServicesData.Get(gs.Name()) + transportService := *svc + transportService.PkgName = d.ServiceImport(svc.Name).Name + transportService.ViewsPkg = d.ViewImport(svc.Name).Name + svc = &transportService scope := codegen.NewNameScope() - pkg := codegen.SnakeCase(codegen.Goify(svc.Name, false)) + pbPkgName + protobufPath := path.Join(d.GenPkg(), "grpc", svc.PathName, pbPkgName) + pkg := d.PackageImport(protobufPath).Name svcVarN := scope.HashedUnique(gs.ServiceExpr, codegen.Goify(svc.Name, true)) sd := &ServiceData{ Service: svc, @@ -585,12 +597,12 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { ) md := svc.Method(e.Name()) if e.MethodExpr.Payload.Type != expr.Empty { - pkg := md.PayloadLoc.PackageNameOrDefault(svc.PkgName) - payloadRef = methodTypeRef(e.MethodExpr.Payload, md.PayloadDeclaration, pkg, svc.Scope) + svcctx := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Payload) + payloadRef = svcctx.Scope.Ref(e.MethodExpr.Payload, svcctx.Pkg(e.MethodExpr.Payload)) } if e.MethodExpr.Result.Type != expr.Empty { - pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) - resultRef = methodTypeRef(e.MethodExpr.Result, md.ResultDeclaration, pkg, svc.Scope) + svcctx := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Result) + resultRef = svcctx.Scope.Ref(e.MethodExpr.Result, svcctx.Pkg(e.MethodExpr.Result)) } if md.ViewedResult != nil { viewedResultRef = md.ViewedResult.FullRef @@ -604,7 +616,7 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } // build request data - reqMD := extractMetadata(e.Metadata, e.MethodExpr.Payload, svc.Scope, *d) + reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server") request := &RequestData{ Description: requestMessage.Description, Metadata: reqMD, @@ -640,16 +652,17 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } // build response data - result, svcCtx := resultContext(e, sd) - hdrs := extractMetadata(e.Response.Headers, result, svc.Scope, *d) - trlrs := extractMetadata(e.Response.Trailers, result, svc.Scope, *d) + serverResult, serverCtx := d.resultContext(e, sd, "server") + clientResult, clientCtx := d.resultContext(e, sd, "client") + hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client") + trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client") response := &ResponseData{ StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), Description: e.Response.Description, Headers: hdrs, Trailers: trlrs, - ServerConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, true), - ClientConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, false), + ServerConvert: d.buildResponseConvertData(responseMessage, serverResult, serverCtx, hdrs, trlrs, e, sd, true), + ClientConvert: d.buildResponseConvertData(responseMessage, clientResult, clientCtx, hdrs, trlrs, e, sd, false), } // If the endpoint is a streaming endpoint, no message is returned // by gRPC. Hence, no need to set response message. @@ -961,9 +974,11 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE } svc := sd.Service - method := svc.Method(e.MethodExpr.Name) - pkg := method.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := methodTypeContext(payload, method.PayloadDeclaration, pkg, svc.Scope) + side := "client" + if svr { + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side data := d.buildInitData(request, payload, "message", "v", svcCtx, false, false, sd) @@ -1017,15 +1032,13 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi mdObj.Set("goa_payload", expr.DupAtt(payload)) legacyMD.Validation.AddRequired("goa_payload") } - md := extractMetadata(legacyMD, payload, svc.Scope, *d) + md := d.extractMetadata(legacyMD, payload, sd, "server") data := &LegacyDecodeData{ FuncName: fmt.Sprintf("decode%sLegacyRequest", codegen.Goify(e.Name(), true)), Metadata: md, } if expr.IsObject(payload.Type) { - method := svc.Method(e.MethodExpr.Name) - pkg := method.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := methodTypeContext(payload, method.PayloadDeclaration, pkg, svc.Scope) + svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, false, sd) init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) @@ -1145,7 +1158,6 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV // response message derived by analyze; errors without a custom object type // have no entry. func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages map[string]*expr.AttributeExpr, sd *ServiceData) []*ErrorData { - svc := sd.Service errors := make([]*ErrorData, 0, len(e.GRPCErrors)) for _, v := range e.GRPCErrors { responseData := &ResponseData{ @@ -1154,10 +1166,10 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m ServerConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, true), ClientConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, false), } - errorLoc := svc.Method(e.MethodExpr.Name).ErrorLocs[v.Name] + svcctx := d.serviceTypeContext(sd, "server").Enter(v.AttributeExpr) errors = append(errors, &ErrorData{ Name: v.Name, - Ref: svc.Scope.GoFullTypeRef(v.AttributeExpr, errorLoc.PackageNameOrDefault(svc.PkgName)), + Ref: svcctx.Scope.Ref(v.AttributeExpr, svcctx.Pkg(v.AttributeExpr)), Response: responseData, }) } @@ -1174,7 +1186,11 @@ func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRP return nil } svc := sd.Service - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) + side := "client" + if svr { + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(ge.AttributeExpr) if svr { // server side data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, false, sd) @@ -1232,8 +1248,12 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques svc := sd.Service ed := sd.Endpoint(e.Name()) md := ed.Method - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) - result, resCtx := resultContext(e, sd) + side := "client" + if svr { + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(e.MethodExpr.StreamingPayload) + result, resCtx := d.resultContext(e, sd, side) resVar := "result" if md.ViewedResult != nil { resVar = "vresult" @@ -1333,13 +1353,15 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques // extractMetadata collects the request/response metadata from the given // metadata attribute and service type (payload/result). -func extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope, services ServicesData) []*MetadataData { +func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side string) []*MetadataData { var metadata []*MetadataData - ctx := serviceTypeContext("", scope) + scope := sd.Service.Scope + ctx := d.serviceTypeContext(sd, side).Enter(service) codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck arr := expr.AsArray(c.Type) mp := expr.AsMap(c.Type) typeRef := scope.GoTypeRef(unalias(c)) + serviceField := service ft := service.Type varn := scope.Name(codegen.Goify(name, false)) fieldName := codegen.Goify(name, true) @@ -1348,17 +1370,20 @@ func extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, s fieldName = "" } else { pointer = service.IsPrimitivePointer(name, true) - ft = service.Find(name).Type + serviceField = service.Find(name) + ft = serviceField.Type } if pointer { typeRef = "*" + typeRef } + fieldContext := ctx.Enter(serviceField) metadata = append(metadata, &MetadataData{ Name: elem, AttributeName: name, Description: c.Description, FieldName: fieldName, FieldType: ft, + FieldTypeRef: fieldContext.Scope.Ref(serviceField, fieldContext.Pkg(serviceField)), VarName: varn, Required: required, Type: c.Type, @@ -1374,7 +1399,7 @@ func extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, s expr.AsArray(mp.ElemType.Type).ElemType.Type.Kind() == expr.StringKind, Validate: codegen.AttributeValidationCode(c, nil, ctx, required, false, varn, name), DefaultValue: c.DefaultValue, - Example: c.Example(services.Root.API.ExampleGenerator.Field(service, name)), + Example: c.Example(d.Root.API.ExampleGenerator.Field(service, name)), }) return nil }) @@ -1391,6 +1416,7 @@ func initArgsFromMetadata(md []*MetadataData) []*InitArgData { Ref: m.VarName, FieldName: m.FieldName, FieldType: m.FieldType, + FieldTypeRef: m.FieldTypeRef, TypeName: m.TypeName, TypeRef: m.TypeRef, Type: m.Type, @@ -1473,50 +1499,31 @@ func unalias(att *expr.AttributeExpr) *expr.AttributeExpr { return att } -// serviceTypeContext returns a contextual attribute for service types. Service -// types are Go types and uses non-pointers to hold attributes having default -// values. -func serviceTypeContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) -} - -// methodTypeContext binds a named method wrapper to its frozen declaration and -// preserves the existing service scope for primitive method types. -func methodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - if declaration == nil { - return serviceTypeContext(pkg, scope) +// serviceTypeContext returns a context that resolves service declarations from +// the generated gRPC package for side. +func (d *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: d.ServiceAttributor(sd.Service.Name, outputPackage), } - return service.NewMethodTypeContext(attribute, declaration, pkg, scope) } -// methodTypeRef returns the frozen reference for a named method type and -// preserves the existing spelling for primitive method types. -func methodTypeRef(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { - if declaration == nil { - return scope.GoFullTypeRef(attribute, pkg) - } - name := declaration.Name() - if pkg != "" { - name = pkg + "." + name - } - if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { - return "*" + name - } - return name -} - -// resultContext returns the method result attribute and the result context for the given -// endpoint. -func resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData) (*expr.AttributeExpr, *codegen.AttributeContext) { - svc := sd.Service - md := svc.Method(e.Name()) +// resultContext returns the method result and its frozen service or view +// declaration context for side. +func (d *ServicesData) resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData, side string) (*expr.AttributeExpr, *codegen.AttributeContext) { + md := sd.Service.Method(e.Name()) if md.ViewedResult != nil { vresAtt := expr.AsObject(md.ViewedResult.Type).Attribute("projected") - // return projected type context - return vresAtt, codegen.NewAttributeContext(true, false, true, svc.ViewsPkg, svc.ViewScope) + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return vresAtt, &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: d.ViewAttributor(sd.Service.Name, outputPackage), + } } - pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) - return e.MethodExpr.Result, methodTypeContext(e.MethodExpr.Result, md.ResultDeclaration, pkg, svc.Scope) + result := e.MethodExpr.Result + return result, d.serviceTypeContext(sd, side).Enter(result) } // getPrimitive returns the primitive expression if the given expression is an alias to one diff --git a/grpc/codegen/service_imports.go b/grpc/codegen/service_imports.go index ba5851ce8e..b849f96373 100644 --- a/grpc/codegen/service_imports.go +++ b/grpc/codegen/service_imports.go @@ -7,7 +7,6 @@ import ( "strings" "goa.design/goa/v3/codegen" - servicecodegen "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -15,10 +14,10 @@ import ( // to file's header. The output package is computed from the generated path. // Current gRPC server, client, codec, type, and CLI files each render every // endpoint; callers pass that complete endpoint list explicitly. -func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.GRPCEndpointExpr) *codegen.File { +func addEndpointImports(file *codegen.File, services *ServicesData, endpoints ...*expr.GRPCEndpointExpr) *codegen.File { outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") - outputPackage := path.Join(genpkg, path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, grpcEndpointAttributes(endpoints...)...)...) + outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, grpcEndpointAttributes(endpoints...)...)...) return file } @@ -28,10 +27,7 @@ func grpcEndpointAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.Attribu var attributes []*expr.AttributeExpr for _, endpoint := range endpoints { method := endpoint.MethodExpr - attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) - if method.HasMixedResults() { - attributes = append(attributes, method.StreamingResult) - } + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) for _, methodError := range method.Errors { attributes = append(attributes, methodError.AttributeExpr) } diff --git a/grpc/codegen/service_metadata_reference_test.go b/grpc/codegen/service_metadata_reference_test.go new file mode 100644 index 0000000000..9111dd5d32 --- /dev/null +++ b/grpc/codegen/service_metadata_reference_test.go @@ -0,0 +1,37 @@ +// This file verifies that gRPC metadata casts use frozen service declaration +// references instead of rebuilding type names from DSL locations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestMetadataFieldTypeRefUsesFrozenServiceDeclaration(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.String, func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "value", value) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("value") }) + }) + }) + }) + }) + + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + require.Equal(t, "shared.Value", metadata[0].FieldTypeRef) + require.Equal(t, metadata[0].FieldTypeRef, initArgsFromMetadata(metadata)[0].FieldTypeRef) +} diff --git a/grpc/codegen/streaming_errors_test.go b/grpc/codegen/streaming_errors_test.go index c1f938e7c1..6f4189ac06 100644 --- a/grpc/codegen/streaming_errors_test.go +++ b/grpc/codegen/streaming_errors_test.go @@ -60,7 +60,7 @@ func TestStreamingWithErrors(t *testing.T) { t.Run(c.name, func(t *testing.T) { root := RunGRPCDSL(t, c.dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) require.Greater(t, len(clientfs), 0) // Get recv method implementations @@ -94,7 +94,7 @@ func TestStreamingErrorsWithValidation(t *testing.T) { require.Greater(t, len(method.Errors), 0, "method should have errors defined") // Generate client code - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) require.Greater(t, len(clientfs), 0) // Check recv implementations @@ -148,7 +148,7 @@ func TestStreamingErrorComparison(t *testing.T) { root := RunGRPCDSL(t, dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) require.Greater(t, len(clientfs), 0, "should have client files") // Find unary and streaming code in different sections diff --git a/grpc/codegen/streaming_test.go b/grpc/codegen/streaming_test.go index 8a3c3b871b..32cb22741f 100644 --- a/grpc/codegen/streaming_test.go +++ b/grpc/codegen/streaming_test.go @@ -107,11 +107,11 @@ func TestStreaming(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := ServerFiles(services) if len(serverfs) < 2 { t.Fatalf("got %d server files, expected 2", len(serverfs)) } - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) if len(clientfs) < 2 { t.Fatalf("got %d client files, expected 2", len(clientfs)) } @@ -154,11 +154,11 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { root := RunGRPCDSL(t, testdata.ClientStreamingRPCWithUnionPayloadDSL) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) require.Len(t, clientfs, 2) - serverfs := ServerFiles("", services) + serverfs := ServerFiles(services) require.Len(t, serverfs, 2) - protofs := ProtoFiles("", services) + protofs := ProtoFiles(services) require.Len(t, protofs, 1) requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) @@ -192,9 +192,9 @@ func TestStreamingPayloadLegacyCompat(t *testing.T) { root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := ServerFiles(services) require.Len(t, serverfs, 2) - clientfs := ClientFiles("", services) + clientfs := ClientFiles(services) require.Len(t, clientfs, 2) // The server stream tracks the protocol spoken by the client. @@ -223,7 +223,7 @@ func TestStreamingPayloadLegacyCompat(t *testing.T) { assert.Contains(t, requestEncoder, "goagrpc.StreamProtocolMetadataKey") // The wire contract for envelope clients is unchanged. - protofs := ProtoFiles("", services) + protofs := ProtoFiles(services) require.Len(t, protofs, 1) proto := sectionCode(t, protofs[0].SectionTemplates[1:]...) assert.Contains(t, proto, "oneof body") diff --git a/grpc/codegen/templates/type_init.go.tpl b/grpc/codegen/templates/type_init.go.tpl index befe07f307..7d8ecc6d30 100644 --- a/grpc/codegen/templates/type_init.go.tpl +++ b/grpc/codegen/templates/type_init.go.tpl @@ -4,7 +4,7 @@ func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .Ret {{- if .ReturnIsStruct }} {{- range .Args }} {{- if .FieldName }} - {{ $.ReturnVarName }}.{{ .FieldName }} = {{ if isAlias .FieldType }}{{ fullName .FieldType }}({{ end }}{{ .Name }}{{ if isAlias .FieldType }}){{ end }} + {{ $.ReturnVarName }}.{{ .FieldName }} = {{ if isAlias .FieldType }}{{ .FieldTypeRef }}({{ end }}{{ .Name }}{{ if isAlias .FieldType }}){{ end }} {{- end }} {{- end }} {{- end }} diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 43aecefa91..b667bdc512 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -6,11 +6,11 @@ package cli import ( - servicewithinterceptors "/service_with_interceptors" "flag" "fmt" servicewithinterceptorsc "grpc/service_with_interceptors/client" "os" + servicewithinterceptors "service_with_interceptors" goa "goa.design/goa/v3/pkg" grpc "google.golang.org/grpc" diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden index 8f436a645a..eb509cc136 100644 --- a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden @@ -6,8 +6,8 @@ package client import ( + payloadwithvalidation "/payload_with_validation" "fmt" - payloadwithvalidation "payload_with_validation" "strconv" "unicode/utf8" diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index c7f688fcd9..a64e6aed01 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -31,10 +31,19 @@ func CreateGRPCServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + return createServiceServicesForPackage(root, "/") +} + +// createServiceServicesForPackage builds test service analysis for the exact +// generated module path whose imports the test renders. +func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *service.ServicesData { + generation := codegen.NewGeneration(genpkg, []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } + if err := Plan(generation); err != nil { + panic(err) + } if err := generation.Freeze(); err != nil { panic(err) } diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index 8086ca0da0..14b7863d4e 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -12,20 +12,20 @@ import ( // ServerTypeFiles returns the server types files containing all the server // interfaces and types needed to implement gRPC server. -func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { +func ServerTypeFiles(services *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(typesFile(genpkg, svc, services, true), genpkg, svc.GRPCEndpoints...) + fw[i] = addEndpointImports(typesFile(svc, services, true), services, svc.GRPCEndpoints...) } return fw } // ClientTypeFiles returns the client types files containing all the client // interfaces and types needed to implement gRPC client. -func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { +func ClientTypeFiles(services *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(typesFile(genpkg, svc, services, false), genpkg, svc.GRPCEndpoints...) + fw[i] = addEndpointImports(typesFile(svc, services, false), services, svc.GRPCEndpoints...) } return fw } @@ -33,7 +33,7 @@ func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { // typesFile returns the file defining the gRPC types for the given service. // svr indicates whether the file is generated for the server (true) or the // client (false) package. -func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *codegen.File { +func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *codegen.File { var ( initData []*InitData @@ -97,9 +97,9 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, imports := []*codegen.ImportSpec{ {Path: "unicode/utf8"}, codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: sd.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), + services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } // Add imports if Any type is used if usesAnyType(svc.GRPCEndpoints, true) { @@ -114,8 +114,7 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, Source: grpcTemplates.Read(grpcTypeInitT), Data: init, FuncMap: map[string]any{ - "isAlias": expr.IsAlias, - "fullName": fullTypeName, + "isAlias": expr.IsAlias, }, }) } @@ -139,12 +138,3 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, } return &codegen.File{Path: fpath, SectionTemplates: sections} } - -// fullTypeName returns the name of the given type qualified with the name of -// its package when the type is defined in an explicit user type location. -func fullTypeName(dt expr.DataType) string { - if loc := codegen.UserTypeLocation(dt); loc != nil { - return loc.PackageName() + "." + dt.Name() - } - return dt.Name() -} diff --git a/http/codegen/client.go b/http/codegen/client.go index d8d4b08e6c..6e899efcf3 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -12,20 +12,20 @@ import ( ) // ClientFiles returns the generated HTTP client files. -func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { +func ClientFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { - files = append(files, addEndpointImports(clientFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) - if f := WebsocketClientFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, httpWebSocketEndpoints(svc)...)) + files = append(files, addEndpointImports(clientFile(svc, data), data, svc.HTTPEndpoints...)) + if f := WebsocketClientFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, httpWebSocketEndpoints(svc)...)) } - if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, httpSSEEndpoints(svc)...)) + if f := sseClientFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, httpSSEEndpoints(svc)...)) } } for _, svc := range data.Expressions.Services { - if f := ClientEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) + if f := ClientEncodeDecodeFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } } return files @@ -33,7 +33,7 @@ func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { // ClientEncodeDecodeFile returns the file containing the HTTP client encoding // and decoding logic. -func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func ClientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "client", "encode_decode.go") @@ -53,8 +53,8 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), } for _, e := range data.Endpoints { if e.IsJSONRPC { @@ -82,7 +82,7 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * "typeConversionData": typeConversionData, "mapConversionData": mapConversionData, "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isBearer": isBearer, "aliasedType": fieldType, @@ -96,7 +96,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * } return dt }, - "requestStructPkg": requestStructPkg, }, Data: e, }) @@ -114,7 +113,7 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Data: e, FuncMap: map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "buildResponseData": buildResponseData, }, @@ -124,9 +123,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Name: "build-stream-request", Source: httpTemplates.Read(buildStreamRequestT), Data: e, - FuncMap: map[string]any{ - "requestStructPkg": requestStructPkg, - }, }) } } @@ -142,7 +138,7 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * } // clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "http", svcName, "client", "client.go") @@ -160,8 +156,8 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), }), } sections = append(sections, &codegen.SectionTemplate{ @@ -213,7 +209,6 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData FuncMap: map[string]any{ "isWebSocketEndpoint": IsWebSocketEndpoint, "isSSEEndpoint": IsSSEEndpoint, - "responseStructPkg": responseStructPkg, }, }) } @@ -284,17 +279,3 @@ func isBearer(schemes []*service.SchemeData) bool { } return false } - -func requestStructPkg(m *service.MethodData, def string) string { - if m.PayloadLoc != nil { - return m.PayloadLoc.PackageName() - } - return def -} - -func responseStructPkg(m *service.MethodData, def string) string { - if m.ResultLoc != nil { - return m.ResultLoc.PackageName() - } - return def -} diff --git a/http/codegen/client_body_types_test.go b/http/codegen/client_body_types_test.go index 5dc8ec2660..6e986108f9 100644 --- a/http/codegen/client_body_types_test.go +++ b/http/codegen/client_body_types_test.go @@ -26,7 +26,7 @@ func TestBodyTypeDecl(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + fs := typesFile(root.API.HTTP.Services[0], false, services) section := fs.SectionTemplates[1] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_decl_"+c.Name+".go.golden", code) @@ -57,7 +57,7 @@ func TestBodyTypeInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + fs := typesFile(root.API.HTTP.Services[0], false, services) section := fs.SectionTemplates[c.SectionIndex] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_init_"+c.Name+".go.golden", code) @@ -91,7 +91,7 @@ func TestClientTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + fs := typesFile(root.API.HTTP.Services[0], false, services) var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) @@ -114,7 +114,7 @@ func TestClientTypeFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fw := ClientTypeFiles(genpkg, services) + fw := ClientTypeFiles(services) for i, fs := range fw { var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index e58b5aa01d..b85b71a8d2 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -43,7 +43,7 @@ type subcommandData struct { } // ClientCLIFiles returns the client HTTP CLI support file. -func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { +func ClientCLIFiles(data *ServicesData) []*codegen.File { if len(data.Expressions.Services) == 0 { return nil } @@ -82,10 +82,10 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { } } } - files = append(files, endpointParser(genpkg, data.Root, svr, svrData, data)) + files = append(files, endpointParser(data.Root, svr, svrData, data)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, cmds[i].CommandData, data)) + files = append(files, payloadBuilders(svc, cmds[i].CommandData, data)) } return files } @@ -109,7 +109,8 @@ func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { +func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) path := filepath.Join(codegen.Gendir, services.dir(), "cli", pkg, "cli.go") title := fmt.Sprintf("%s %s client CLI support package", svr.Name, services.label()) @@ -136,10 +137,7 @@ func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, da }) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + specs = append(specs, services.ServiceImport(svc.Name)) } } @@ -165,7 +163,7 @@ func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, da // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { sd := services.Get(svc.Name()) path := filepath.Join(codegen.Gendir, services.dir(), sd.Service.PathName, "client", "cli.go") title := fmt.Sprintf("%s %s client CLI support package", svc.Name(), services.label()) @@ -178,9 +176,9 @@ func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.Command {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + sd.Service.PathName, Name: sd.Service.PkgName}, + services.ServiceImport(svc.Name()), } - return addEndpointImports(cli.PayloadBuildersFile(path, title, specs, data), genpkg, svc.HTTPEndpoints...) + return addEndpointImports(cli.PayloadBuildersFile(path, title, specs, data), services, svc.HTTPEndpoints...) } // buildFlags builds the flag data and build function for an endpoint. diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index a9aebff5f0..6f8355e4f7 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -54,7 +54,7 @@ func TestClientCLIFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + fs := ClientCLIFiles(services) sections := fs[c.FileIndex].SectionTemplates code := codegen.SectionCode(t, sections[c.SectionIndex]) testutil.AssertGo(t, "testdata/golden/client_cli_"+c.Name+".go.golden", code) diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index b66a8b7d9a..be3e928557 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -39,7 +39,7 @@ func TestClientDecode(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_encode_test.go b/http/codegen/client_encode_test.go index 3a2c43a908..5c56d1aa2e 100644 --- a/http/codegen/client_encode_test.go +++ b/http/codegen/client_encode_test.go @@ -182,7 +182,7 @@ func TestClientEncode(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) @@ -206,7 +206,7 @@ func TestClientBuildRequest(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_init_test.go b/http/codegen/client_init_test.go index d19ce7e272..c72dfb9813 100644 --- a/http/codegen/client_init_test.go +++ b/http/codegen/client_init_test.go @@ -26,7 +26,7 @@ func TestClientInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 91f75fd194..4fb1f2d827 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -78,7 +78,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) services := CreateHTTPServices(root) - serverTypes := typesFile("gen", root.API.HTTP.Services[0], true, services) + serverTypes := typesFile(root.API.HTTP.Services[0], true, services) var serverTypesBuf bytes.Buffer for _, section := range serverTypes.SectionTemplates[1:] { require.NoError(t, section.Write(&serverTypesBuf)) @@ -88,7 +88,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverTypesCode, "browserSession *string, browserSession *string") require.NotContains(t, serverTypesCode, "browserSession string, browserSession string") - serverFiles := ServerFiles("", services) + serverFiles := ServerFiles(services) require.Len(t, serverFiles, 2) serverDecode := codegen.SectionCode(t, serverFiles[1].SectionTemplates[2]) require.Contains(t, serverDecode, `r.Cookie("__Host-ak_session")`) @@ -96,7 +96,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverDecode, "browserSession *string, browserSession *string") require.NotContains(t, serverDecode, "browserSession string, browserSession string") - clientFiles := ClientFiles("", services) + clientFiles := ClientFiles(services) require.Len(t, clientFiles, 2) clientEncode := codegen.SectionCode(t, clientFiles[1].SectionTemplates[2]) require.Contains(t, clientEncode, `req.AddCookie(&http.Cookie{`) diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index 3d0ba64ec2..8821c59a92 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -11,10 +11,10 @@ import ( // ExampleCLIFiles returns an example client tool implementation for the // transport described by services for each server expression. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +func ExampleCLIFiles(services *ServicesData) []*codegen.File { var files []*codegen.File for _, svr := range services.Root.API.Servers { - if f := ExampleCLI(genpkg, svr, services); f != nil { + if f := ExampleCLI(svr, services); f != nil { files = append(files, f) } } @@ -23,7 +23,8 @@ func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { // ExampleCLI returns an example client tool implementation for the transport // described by services and the given server expression. -func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *codegen.File { +func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, services.Root) path := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") if _, err := os.Stat(path); !os.IsNotExist(err) { diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 45f9120c78..89fb32933d 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -1,3 +1,4 @@ +// This file verifies generated HTTP command-line client examples. package codegen import ( @@ -31,7 +32,7 @@ func TestExampleCLIFiles(t *testing.T) { example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleCLIFiles("", httpServices) + fs := ExampleCLIFiles(httpServices) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 970c1e2d3b..a490585061 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -10,20 +10,19 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - servicecodegen "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) // ExampleServerFiles returns an example http service implementation. -func ExampleServerFiles(genpkg string, data *ServicesData) []*codegen.File { +func ExampleServerFiles(data *ServicesData) []*codegen.File { var fw []*codegen.File for _, svr := range data.Root.API.Servers { - if m := ExampleServer(genpkg, data.Root, svr, data); m != nil { + if m := ExampleServer(data.Root, svr, data); m != nil { fw = append(fw, m) } } for _, svc := range data.Expressions.Services { - if f := dummyMultipartFile(genpkg, data.Root, svc, data); f != nil { + if f := dummyMultipartFile(data.Root, svc, data); f != nil { fw = append(fw, f) } } @@ -31,7 +30,8 @@ func ExampleServerFiles(genpkg string, data *ServicesData) []*codegen.File { } // ExampleServer returns an example HTTP server implementation. -func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { +func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, root) fpath := filepath.Join("cmd", svrdata.Dir, "http.go") specs := make([]*codegen.ImportSpec, 0, 12+2*len(root.API.HTTP.Services)) @@ -131,7 +131,8 @@ func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, ser // dummyMultipartFile returns a dummy implementation of the multipart decoders // and encoders. -func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func dummyMultipartFile(root *expr.RootExpr, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() mpath := "multipart.go" if _, err := os.Stat(mpath); !os.IsNotExist(err) { return nil // file already exists, skip it. @@ -167,7 +168,7 @@ func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServic Path: path.Join(genpkg, data.Service.PathName), Name: scope.Unique(data.Service.PkgName, "svc"), }) - specs = append(specs, servicecodegen.AttributeImports(genpkg, example.RootPath(genpkg), httpEndpointAttributes(multipartEndpoints...)...)...) + specs = append(specs, services.AttributeImports(example.RootPath(genpkg), ServiceReferenceAttributes(multipartEndpoints...)...)...) apiPkg := example.APIPkg(root, scope) sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index babe1d64dd..207c15daca 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -1,3 +1,4 @@ +// This file verifies generated HTTP server examples. package codegen import ( @@ -35,7 +36,7 @@ func TestExampleServerFiles(t *testing.T) { root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + fs := ExampleServerFiles(httpServices) require.Len(t, fs, 2) for i, f := range fs { if i < len(fs)-1 { @@ -71,7 +72,7 @@ func TestExampleServerFiles(t *testing.T) { example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + fs := ExampleServerFiles(httpServices) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/http/codegen/handler_test.go b/http/codegen/handler_test.go index d61cc85664..54c16203c7 100644 --- a/http/codegen/handler_test.go +++ b/http/codegen/handler_test.go @@ -32,7 +32,7 @@ func TestHandlerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) sections := codegentest.Sections(fs, "server.go", "server-handler-init") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/idempotency_test.go b/http/codegen/idempotency_test.go index e859cb2e86..dfb2a0fcfd 100644 --- a/http/codegen/idempotency_test.go +++ b/http/codegen/idempotency_test.go @@ -38,7 +38,7 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { }) }) services := CreateHTTPServices(root) - clientFiles := ClientFiles("", services) + clientFiles := ClientFiles(services) require.NotEmpty(t, clientFiles) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) @@ -68,10 +68,10 @@ func TestFileGenerationIdempotent(t *testing.T) { render := func(dir string) { files := PathFiles(services) - files = append(files, ServerFiles("gen", services)...) - files = append(files, ClientFiles("gen", services)...) - files = append(files, ServerTypeFiles("gen", services)...) - files = append(files, ClientTypeFiles("gen", services)...) + files = append(files, ServerFiles(services)...) + files = append(files, ClientFiles(services)...) + files = append(files, ServerTypeFiles(services)...) + files = append(files, ClientTypeFiles(services)...) require.NotEmpty(t, files) for _, f := range files { _, err := f.Render(dir) diff --git a/http/codegen/multipart_test.go b/http/codegen/multipart_test.go index 518e54fd2c..c3715247f4 100644 --- a/http/codegen/multipart_test.go +++ b/http/codegen/multipart_test.go @@ -27,7 +27,7 @@ func TestServerMultipartFuncType(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 5) @@ -52,7 +52,7 @@ func TestClientMultipartFuncType(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 4) @@ -79,7 +79,7 @@ func TestServerMultipartNewFunc(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) @@ -106,7 +106,7 @@ func TestClientMultipartNewFunc(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 365b45ca6e..6a68d06ab0 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -67,7 +67,7 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde root := expr.RunDSL(t, dsl) services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + fs := ClientCLIFiles(services) return codegen.SectionCode(t, fs[fileIndex].SectionTemplates[sectionIndex]) } @@ -80,7 +80,7 @@ func renderClientTypesCode(t *testing.T, dsl func()) string { root := expr.RunDSL(t, dsl) services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + fs := typesFile(root.API.HTTP.Services[0], false, services) var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { @@ -97,7 +97,7 @@ func renderClientDecodeCode(t *testing.T, dsl func()) string { root := expr.RunDSL(t, dsl) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates diff --git a/http/codegen/plan.go b/http/codegen/plan.go new file mode 100644 index 0000000000..310d5d409c --- /dev/null +++ b/http/codegen/plan.go @@ -0,0 +1,46 @@ +// This file declares the fixed import qualifiers used by HTTP-generated files +// before service package aliases are frozen for the generation. +package codegen + +import ( + "goa.design/goa/v3/codegen" +) + +// Plan reserves every literal import qualifier used by HTTP render templates. +// Generated service packages are planned separately and receive a suffix when +// their preferred qualifier conflicts with one of these required names. +func Plan(generation *codegen.Generation) error { + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("bufio"), + codegen.SimpleImport("bytes"), + codegen.SimpleImport("context"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("mime/multipart"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("os"), + codegen.SimpleImport("path"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("sync"), + codegen.SimpleImport("time"), + codegen.SimpleImport("unicode/utf8"), + codegen.SimpleImport("github.com/google/uuid"), + codegen.SimpleImport("github.com/gorilla/websocket"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + codegen.GoaImport("middleware"), + } + for _, spec := range imports { + if err := generation.RequireImport(spec); err != nil { + return err + } + } + return nil +} diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go new file mode 100644 index 0000000000..19ea177c49 --- /dev/null +++ b/http/codegen/plan_test.go @@ -0,0 +1,38 @@ +// This file verifies HTTP import planning participates in the shared +// generation lifecycle before service aliases are frozen. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Path", func() { + dsl.Method("Read", func() {}) + }) + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) + + require.Equal(t, "path2", services.ServiceImport("Path").Name) +} + +func TestPlanRejectsFrozenGeneration(t *testing.T) { + generation := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, generation.Freeze()) + + require.Error(t, Plan(generation)) +} diff --git a/http/codegen/server.go b/http/codegen/server.go index 1a55e9f5f0..2af67da1a2 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -14,27 +14,27 @@ import ( ) // ServerFiles returns the generated HTTP server files. -func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { +func ServerFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { - files = append(files, addEndpointImports(serverFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) - if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, httpWebSocketEndpoints(svc)...)) + files = append(files, addEndpointImports(serverFile(svc, data), data, svc.HTTPEndpoints...)) + if f := websocketServerFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, httpWebSocketEndpoints(svc)...)) } - if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, httpSSEEndpoints(svc)...)) + if f := sseServerFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, httpSSEEndpoints(svc)...)) } } for _, svc := range data.Expressions.Services { - if f := ServerEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) + if f := ServerEncodeDecodeFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } } return files } // serverFile returns the file implementing the HTTP server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "http", svcName, "server", "server.go") @@ -62,8 +62,8 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -120,7 +120,7 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData // ServerEncodeDecodeFile returns the file defining the HTTP server encoding and // decoding logic. -func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func ServerEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "server", "encode_decode.go") @@ -138,8 +138,8 @@ func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), } sections := []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} @@ -201,7 +201,7 @@ func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * func transTmplFuncs(s *expr.HTTPServiceExpr, services *ServicesData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return services.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isAliased": func(dt expr.DataType) bool { _, ok := dt.(expr.UserType) diff --git a/http/codegen/server_decode_test.go b/http/codegen/server_decode_test.go index b1ac5dd444..9b18ae7171 100644 --- a/http/codegen/server_decode_test.go +++ b/http/codegen/server_decode_test.go @@ -227,7 +227,7 @@ func TestDecode(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 898ef907f1..b595a7b0c2 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -93,7 +93,7 @@ func TestEncode(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -118,7 +118,7 @@ func TestEncodeMarshallingAndUnmarshalling(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates totalSectionsExpected := c.SectionsOffset + c.SectionCount diff --git a/http/codegen/server_error_encoder_test.go b/http/codegen/server_error_encoder_test.go index ccd7a216e6..9bc7dd262f 100644 --- a/http/codegen/server_error_encoder_test.go +++ b/http/codegen/server_error_encoder_test.go @@ -36,7 +36,7 @@ func TestEncodeError(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/server_handler_test.go b/http/codegen/server_handler_test.go index 824d81c80d..797a17b494 100644 --- a/http/codegen/server_handler_test.go +++ b/http/codegen/server_handler_test.go @@ -27,7 +27,7 @@ func TestServerHandler(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) sections := codegentest.Sections(fs, "server.go", "server-handler") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/server_init_test.go b/http/codegen/server_init_test.go index f9bedc77a6..787925c483 100644 --- a/http/codegen/server_init_test.go +++ b/http/codegen/server_init_test.go @@ -33,7 +33,7 @@ func TestServerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/server_mount_test.go b/http/codegen/server_mount_test.go index 5875b6ef18..1e29dbf9a1 100644 --- a/http/codegen/server_mount_test.go +++ b/http/codegen/server_mount_test.go @@ -35,7 +35,7 @@ func TestServerMount(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + fs := ServerFiles(services) sections := codegentest.Sections(fs, "server.go", c.SectionName) require.Greater(t, len(sections), c.SectionNum) code := codegen.SectionCode(t, sections[c.SectionNum]) diff --git a/http/codegen/server_payload_types_test.go b/http/codegen/server_payload_types_test.go index 0fd2ace2b3..86b7d8f980 100644 --- a/http/codegen/server_payload_types_test.go +++ b/http/codegen/server_payload_types_test.go @@ -124,7 +124,7 @@ func TestPayloadConstructor(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) services := CreateHTTPServices(root) - fs := typesFile("", root.API.HTTP.Services[0], true, services) + fs := typesFile(root.API.HTTP.Services[0], true, services) sections := fs.SectionTemplates var section *codegen.SectionTemplate for _, s := range sections { diff --git a/http/codegen/server_types_test.go b/http/codegen/server_types_test.go index e572e03288..ffa1c607ee 100644 --- a/http/codegen/server_types_test.go +++ b/http/codegen/server_types_test.go @@ -42,7 +42,7 @@ func TestServerTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], true, services) + fs := typesFile(root.API.HTTP.Services[0], true, services) var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index fdfb52a63c..26759b00b9 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "net/http" + "path" "slices" "sort" "strconv" @@ -692,12 +693,16 @@ func (sds *ServicesData) label() string { // It records the user types needed by the service definition in userTypes. func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { svc := sds.ServicesData.Get(httpSvc.ServiceExpr.Name) + transportService := *svc + transportService.PkgName = sds.ServiceImport(svc.Name).Name + transportService.ViewsPkg = sds.ViewImport(svc.Name).Name + svc = &transportService scope := codegen.NewNameScope() scope.Unique("c") // 'c' is reserved as the client's receiver name. scope.Unique("v") // 'v' is reserved as the request builder payload argument name. // Reserve 'websocket' to avoid collision with gorilla/websocket scope.Unique("websocket") - // Reserve the service package name to avoid collision with parameter names in generated code + // Reserve the service package alias to avoid collision with parameter names in generated code. scope.Unique(svc.PkgName) sd := &ServiceData{ Service: svc, @@ -895,6 +900,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { ) { name = fmt.Sprintf("Build%sRequest", method.VarName) + svcctx := sds.serviceTypeContext(sd, "client").Enter(httpEndpoint.MethodExpr.Payload) s := codegen.NewNameScope() s.Unique("c") // 'c' is reserved as the client's receiver name. for _, ca := range routes[0].PathInit.ClientArgs { @@ -904,14 +910,15 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { // Populate service-aware type resolution fields _, ca.IsAliased = ca.FieldType.(expr.UserType) if ca.IsAliased { - ca.ServiceTypeRef = sds.ServicesData.Get(svc.Name).Scope.GoTypeRef(&expr.AttributeExpr{Type: ca.Type}) + attribute := &expr.AttributeExpr{Type: ca.Type} + ca.ServiceTypeRef = svcctx.Scope.Ref(attribute, svcctx.Pkg(attribute)) } args = append(args, ca) } } - pkg = method.PayloadLoc.PackageNameOrDefault(svc.PkgName) + pkg = svc.PkgName if len(routes[0].PathInit.ClientArgs) > 0 && httpEndpoint.MethodExpr.Payload.Type != expr.Empty { - payloadRef = methodTypeRef(httpEndpoint.MethodExpr.Payload, method.PayloadDeclaration, pkg, svc.Scope) + payloadRef = svcctx.Scope.Ref(httpEndpoint.MethodExpr.Payload, svcctx.Pkg(httpEndpoint.MethodExpr.Payload)) } } data := map[string]any{ @@ -968,7 +975,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } if httpEndpoint.MethodExpr.IsStreaming() { sds.initWebSocketData(ed, httpEndpoint, sd) - initSSEData(ed, httpEndpoint, sd) + sds.initSSEData(ed, httpEndpoint, sd) } if httpEndpoint.MultipartRequest { @@ -1050,27 +1057,27 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } - unionByHash := make(map[string]*service.UnionTypeData) - seenUnionTypes := make(map[string]struct{}) + unionTypes := make(map[codegen.UnionTypeID]*service.UnionTypeData) + seenUnionTypes := make(map[expr.UserType]struct{}) for _, a := range httpSvc.HTTPEndpoints { - collectHTTPUnionTypes(sd.bodies.request(a), sd.Scope, unionByHash, seenUnionTypes) + collectHTTPUnionTypes(sd.bodies.request(a), sd.Scope, unionTypes, seenUnionTypes) if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectHTTPUnionTypes(sd.bodies.streaming(a), sd.Scope, unionByHash, seenUnionTypes) + collectHTTPUnionTypes(sd.bodies.streaming(a), sd.Scope, unionTypes, seenUnionTypes) } md := sd.Service.Method(a.Name()) for _, v := range a.Responses { - collectHTTPUnionTypes(effectiveClientResponseBody(sd.bodies.response(v), a, md), sd.Scope, unionByHash, seenUnionTypes) + collectHTTPUnionTypes(effectiveClientResponseBody(sd.bodies.response(v), a, md), sd.Scope, unionTypes, seenUnionTypes) } for _, v := range a.HTTPErrors { - collectHTTPUnionTypes(sd.bodies.errorResponse(v), sd.Scope, unionByHash, seenUnionTypes) + collectHTTPUnionTypes(sd.bodies.errorResponse(v), sd.Scope, unionTypes, seenUnionTypes) } } - unions := make([]*service.UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { + unions := make([]*service.UnionTypeData, 0, len(unionTypes)) + for _, u := range unionTypes { unions = append(unions, u) } sort.Slice(unions, func(i, j int) bool { @@ -1091,6 +1098,7 @@ func makeHTTPType(att *expr.AttributeExpr) *expr.AttributeExpr { } func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *expr.AttributeExpr { + delete(att.Meta, "struct:pkg:path") switch dt := att.Type.(type) { case expr.UserType: if dt == expr.Empty { @@ -1165,6 +1173,7 @@ func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { b.streams = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) } att := expr.DupAtt(e.StreamingBody) + expr.RemovePkgPath(att) b.streams[e] = att return att } @@ -1209,8 +1218,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ep = svc.Method(e.MethodExpr.Name) httpsvrctx = httpContext(sd.Scope, true, true) httpclictx = httpContext(sd.Scope, true, false) - pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcctx = methodTypeContext(payload, ep.PayloadDeclaration, pkg, svc.Scope) + svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) + svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) request *RequestData mapQueryParam *ParamData @@ -1219,10 +1228,10 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD var ( serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd) clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd) - paramsData = sds.extractPathParams(e.PathParams(), payload, sd.Scope) - queryData = sds.extractQueryParams(e.QueryParams(), payload, sd.Scope) - headersData = sds.extractHeaders(e.Headers, payload, svcctx, sd.Scope) - cookiesData = sds.extractCookies(e.Cookies, payload, svcctx, sd.Scope) + paramsData = sds.extractPathParams(e.PathParams(), payload, sd) + queryData = sds.extractQueryParams(e.QueryParams(), payload, sd) + headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope) + cookiesData = sds.extractCookies(e.Cookies, payload, svcsvrctx, sd.Scope) origin string mustValidate bool @@ -1429,7 +1438,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD for _, sc := range r.Schemes { if sc.Type == "Basic" { uatt := e.MethodExpr.Payload.Find(sc.UsernameAttr) - uref := svc.Scope.GoTypeRef(uatt) + uctx := svcclictx.Enter(uatt) + uref := uctx.Scope.Ref(uatt, uctx.Pkg(uatt)) if sc.UsernamePointer { uref = "*" + uref } @@ -1443,7 +1453,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: uatt.Type, Description: uatt.Description, Required: sc.UsernameRequired, - TypeName: svc.Scope.GoTypeName(uatt), + TypeName: uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), TypeRef: uref, Type: uatt.Type, Pointer: sc.UsernamePointer, @@ -1452,7 +1462,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD }, } patt := e.MethodExpr.Payload.Find(sc.PasswordAttr) - pref := svc.Scope.GoTypeRef(patt) + pctx := svcclictx.Enter(patt) + pref := pctx.Scope.Ref(patt, pctx.Pkg(patt)) if sc.PasswordPointer { pref = "*" + pref } @@ -1466,7 +1477,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: patt.Type, Description: patt.Description, Required: sc.PasswordRequired, - TypeName: svc.Scope.GoTypeName(patt), + TypeName: pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), TypeRef: pref, Type: patt.Type, Pointer: sc.PasswordPointer, @@ -1505,7 +1516,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD var ( helpers []*codegen.TransformFunctionData ) - serverCode, helpers, err = unmarshal(httpBody, pAtt, "body", httpsvrctx, svcctx) + serverCode, helpers, err = unmarshal(httpBody, pAtt, "body", httpsvrctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -1513,18 +1524,18 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // body is used by the CLI tool to build the payload given to the // client endpoint. It differs because the body type there does not // use pointers for all fields (no need to validate). - clientCode, helpers, err = marshal(httpBody, pAtt, "body", "v", httpclictx, svcctx) + clientCode, helpers, err = marshal(httpBody, pAtt, "body", "v", httpclictx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), httpsvrctx, svcctx) + serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), httpsvrctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } - clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", httpclictx, svcctx) + clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", httpclictx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -1539,11 +1550,11 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ServerArgs: serverArgs, ClientArgs: clientArgs, CLIArgs: cliArgs, - ReturnTypeName: methodTypeName(payload, ep.PayloadDeclaration, pkg, svc.Scope), - ReturnTypeRef: methodTypeRef(payload, ep.PayloadDeclaration, pkg, svc.Scope), + ReturnTypeName: svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true), + ReturnTypeRef: svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)), ReturnIsStruct: isObject, ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: svcsvrctx.Pkg(payload), ServerCode: serverCode, ClientCode: clientCode, ReturnIsPrimitivePointer: pointer, @@ -1557,8 +1568,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ref string ) if payload.Type != expr.Empty { - name = methodTypeName(payload, ep.PayloadDeclaration, pkg, svc.Scope) - ref = methodTypeRef(payload, ep.PayloadDeclaration, pkg, svc.Scope) + name = svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true) + ref = svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)) } if init == nil { if o := expr.AsObject(e.Params.Type); o != nil && len(*o) > 0 { @@ -1595,10 +1606,9 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // buildResultData builds the result data for the given service endpoint. func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceData) *ResultData { var ( - svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - pkg = ep.ResultLoc.PackageNameOrDefault(svc.PkgName) result = e.MethodExpr.Result + method = sd.Service.Method(e.MethodExpr.Name) + svcctx = sds.serviceTypeContext(sd, "server").Enter(result) name string ref string @@ -1610,8 +1620,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa view = v } if result.Type != expr.Empty { - name = methodTypeName(result, ep.ResultDeclaration, pkg, svc.Scope) - ref = methodTypeRef(result, ep.ResultDeclaration, pkg, svc.Scope) + name = svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + ref = svcctx.Scope.Ref(result, svcctx.Pkg(result)) } var ( @@ -1620,8 +1630,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa ) { viewed := false - if ep.ViewedResult != nil { - result = expr.AsObject(ep.ViewedResult.Type).Attribute("projected") + if method.ViewedResult != nil { + result = expr.AsObject(method.ViewedResult.Type).Attribute("projected") viewed = true } responses = sds.buildResponses(e, result, viewed, sd) @@ -1669,15 +1679,14 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A svc = sd.Service md = svc.Method(e.Name()) - pkg = md.ResultLoc.PackageNameOrDefault(svc.PkgName) httpclictx = httpContext(sd.Scope, false, false) scope = svc.Scope - svcctx = methodTypeContext(result, md.ResultDeclaration, pkg, svc.Scope) + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) { if viewed { scope = svc.ViewScope - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "client").Enter(result) } notag := -1 for i, resp := range e.Responses { @@ -1718,14 +1727,14 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A if origin != "" { // Response body is explicitly set to an attribute in the method // result type. No need to do any view-based projections server side. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &vname, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else if v, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { // Design explicitly sets the view to render the result. // We generate only one server body type which will be rendered // using the specified view. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &v, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else { @@ -1738,22 +1747,22 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // attributes defined in the view in the response (NOTE: a required // attribute in the result type may not be present in all its views) for _, view := range md.ViewedResult.Views { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &view.Name, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } } if clientView != "" { clientRespBody = effectiveClientResponseBody(respBody, e, md) - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &clientView, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd) } else { - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &vname, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd) } } else { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, nil, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd) } if clientBodyData != nil && clientBodyData.Def != "" { sd.ClientTypeNames[clientBodyData.Name] = struct{}{} @@ -1785,12 +1794,8 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A helpers []*codegen.TransformFunctionData ) { - tname = methodTypeName(result, md.ResultDeclaration, pkg, svc.Scope) - tref = methodTypeRef(result, md.ResultDeclaration, pkg, svc.Scope) - if viewed { - tname = svc.ViewScope.GoFullTypeName(result, svc.ViewsPkg) - tref = svc.ViewScope.GoFullTypeRef(result, svc.ViewsPkg) - } + tname = svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + tref = svcctx.Scope.Ref(result, svcctx.Pkg(result)) status := codegen.Goify(http.StatusText(resp.StatusCode), true) n := codegen.Goify(md.Name, true) r := codegen.Goify(md.Result, true) @@ -1871,7 +1876,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A ReturnTypeRef: tref, ReturnIsStruct: expr.IsObject(result.Type), ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: svcctx.Pkg(result), ReturnIsPrimitivePointer: pointer, ClientCode: code, } @@ -1927,13 +1932,13 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa data := make(map[string][]*ErrorData) for _, v := range e.HTTPErrors { respBody := sd.bodies.errorResponse(v) + errorAttribute := e.MethodExpr.Error(v.Name).AttributeExpr var ( init *InitData body = respBody.Type ) - pkg := ep.ErrorLocs[v.Name].PackageNameOrDefault(svc.PkgName) - errctx := serviceContext(pkg, sd.Service.Scope) + errctx := sds.serviceTypeContext(sd, "client").Enter(errorAttribute) if needInit(v.Type) { var ( @@ -1945,8 +1950,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa name = fmt.Sprintf("New%s%s", codegen.Goify(ep.Name, true), codegen.Goify(v.ErrorExpr.Name, true)) desc = fmt.Sprintf("%s builds a %s service %s endpoint %s error.", name, svc.Name, e.Name(), v.ErrorExpr.Name) - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope) argsCap := len(headers) + len(cookies) if body != expr.Empty { argsCap++ @@ -1976,7 +1981,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa err error ) if body != expr.Empty { - eAtt := v.AttributeExpr + eAtt := errorAttribute // If design uses Body("name") syntax then need to use payload // attribute to transform. if o, ok := respBody.Meta["origin:attribute"]; ok { @@ -1992,7 +1997,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } else if expr.IsArray(v.Type) || expr.IsMap(v.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal((*params)[0].Attribute, v.AttributeExpr, codegen.Goify((*params)[0].Name, false), httpclictx, errctx) + code, helpers, err = unmarshal((*params)[0].Attribute, errorAttribute, codegen.Goify((*params)[0].Name, false), httpclictx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2006,11 +2011,11 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa Name: name, Description: desc, ClientArgs: args, - ReturnTypeName: svc.Scope.GoFullTypeName(v.AttributeExpr, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg), + ReturnTypeName: errctx.Scope.Name(errorAttribute, errctx.Pkg(errorAttribute), false, true), + ReturnTypeRef: errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)), ReturnIsStruct: expr.IsObject(v.Type), ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: errctx.Pkg(errorAttribute), ClientCode: code, } } @@ -2024,11 +2029,10 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa clientBodyData *TypeData ) { - errorLoc := ep.ErrorLocs[v.ErrorExpr.Name] - if sbd := sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, true, nil, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd) if clientBodyData != nil { if clientBodyData.Def != "" { sd.ClientTypeNames[clientBodyData.Name] = struct{}{} @@ -2040,8 +2044,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } } - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope) var mustValidate bool for _, h := range headers { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -2073,7 +2077,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } } - ref := svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg) + ref := errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)) data[ref] = append(data[ref], &ErrorData{ Name: v.Name, Response: responseData, @@ -2139,10 +2143,12 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * svc = sd.Service httpctx = httpContext(sd.Scope, true, svr) - ep = svc.Method(e.Name()) - pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcctx = methodTypeContext(att, methodTypeDeclaration(e.MethodExpr, ep, att), pkg, svc.Scope) + side = "client" ) + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) name = body.Type.Name() ref = sd.Scope.GoTypeRef(body) @@ -2212,7 +2218,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * AttributeData: &AttributeData{ Name: "payload", VarName: sourceVar, - TypeRef: methodTypeRef(att, methodTypeDeclaration(e.MethodExpr, ep, att), pkg, svc.Scope), + TypeRef: svcctx.Scope.Ref(att, svcctx.Pkg(att)), Type: att.Type, Validate: validateDef, Example: att.Example(sds.Root.API.ExampleGenerator), @@ -2251,7 +2257,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // svr is true if the function is generated for server side code // // view is the view name to add as a suffix to the type name. -func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, loc *codegen.Location, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData) *TypeData { if body.Type == expr.Empty { return nil } @@ -2268,10 +2274,12 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo svc = sd.Service httpctx = httpContext(sd.Scope, false, svr) - pkg = loc.PackageNameOrDefault(svc.PkgName) - method = svc.Method(e.Name()) - svcctx = methodTypeContext(att, methodTypeDeclaration(e.MethodExpr, method, att), pkg, svc.Scope) + side = "client" ) + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) // Project the response body when the design fixes the response to a single // view so the generated transport code uses the effective wire shape. if view != nil && *view != "" { @@ -2388,7 +2396,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo desc = fmt.Sprintf("%s builds the HTTP response body from the result of the %q endpoint of the %q service.", name, e.Name(), svc.Name) if view != nil { - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "server").Enter(att) } src := sourceVar srcAtt := att @@ -2410,10 +2418,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo if view != nil { ref += ".Projected" } - tref := methodTypeRef(att, methodTypeDeclaration(e.MethodExpr, method, att), pkg, svc.Scope) - if view != nil { - tref = svc.ViewScope.GoFullTypeRef(att, svc.ViewsPkg) - } + tref := svcctx.Scope.Ref(att, svcctx.Pkg(att)) arg := InitArgData{ Ref: ref, AttributeData: &AttributeData{ @@ -2449,9 +2454,10 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo return td } -func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData) []*ParamData { var params []*ParamData - sds.extractElements(pathElement, a, service, serviceContext("", scope), scope, func(el *Element, _ *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(pathElement, a, service, svcctx, sd.Scope, func(el *Element, _ *expr.AttributeExpr) { params = append(params, &ParamData{ Map: false, MapStringSlice: false, @@ -2461,9 +2467,10 @@ func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData) []*ParamData { var params []*ParamData - sds.extractElements(queryElement, a, service, serviceContext("", scope), scope, func(el *Element, att *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(queryElement, a, service, svcctx, sd.Scope, func(el *Element, att *expr.AttributeExpr) { mp := expr.AsMap(att.Type) params = append(params, &ParamData{ Map: mp != nil, @@ -2708,16 +2715,17 @@ func collectUserTypes(dt expr.DataType, cb func(expr.UserType), seen ...map[stri } } -func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, unions map[string]*service.UnionTypeData, seen map[string]struct{}) { +func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, unions map[codegen.UnionTypeID]*service.UnionTypeData, seen map[expr.UserType]struct{}) { if att == nil || att.Type == expr.Empty { return } switch dt := att.Type.(type) { case expr.UserType: - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return } - seen[dt.ID()] = struct{}{} + seen[origin] = struct{}{} collectHTTPUnionTypes(dt.Attribute(), scope, unions, seen) case *expr.Object: for _, nat := range sortedNamedAttributes(*dt) { @@ -2729,9 +2737,9 @@ func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, un collectHTTPUnionTypes(dt.KeyType, scope, unions, seen) collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) case *expr.Union: - hash := codegen.UnionTypeHash(dt) - if _, ok := unions[hash]; !ok { - unions[hash] = buildHTTPUnionTypeData(dt, scope) + identity := codegen.NewUnionTypeID(dt) + if _, ok := unions[identity]; !ok { + unions[identity] = buildHTTPUnionTypeData(dt, scope) } for _, nat := range dt.Values { collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) @@ -2889,80 +2897,25 @@ func httpContext(scope *codegen.NameScope, request, svr bool) *codegen.Attribute return ctx } -// serviceContext returns an attribute context for service types. -func serviceContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) -} - -// methodTypeContext binds a named method wrapper to its frozen declaration and -// preserves the existing service scope for primitive method types. -func methodTypeContext(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - if declaration == nil { - return serviceContext(pkg, scope) - } - return service.NewMethodTypeContext(attribute, declaration, pkg, scope) -} - -// methodTypeName returns the frozen name for a named method type and preserves -// the existing spelling for primitive method types. -func methodTypeName(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { - if declaration == nil { - return scope.GoFullTypeName(attribute, pkg) - } - if pkg == "" { - return declaration.Name() - } - return pkg + "." + declaration.Name() -} - -// methodTypeRef returns the frozen reference for a named method type and -// preserves the existing spelling for primitive method types. -func methodTypeRef(attribute *expr.AttributeExpr, declaration *codegen.TypeDeclaration, pkg string, scope *codegen.NameScope) string { - if declaration == nil { - return scope.GoFullTypeRef(attribute, pkg) - } - name := methodTypeName(attribute, declaration, pkg, scope) - if expr.IsObject(attribute.Type) || expr.IsUnion(attribute.Type) { - return "*" + name - } - return name -} - -// methodTypeDeclaration returns the frozen declaration associated with an -// endpoint method attribute. Error and wire attributes do not match one. -func methodTypeDeclaration(method *expr.MethodExpr, data *service.MethodData, attribute *expr.AttributeExpr) *codegen.TypeDeclaration { - userType, ok := attribute.Type.(expr.UserType) - if !ok { - return nil +// serviceTypeContext returns a context that resolves service declarations from +// the generated transport package for side. +func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: sds.ServiceAttributor(sd.Service.Name, outputPackage), } - if methodTypeMatches(method.Payload, userType) { - return data.PayloadDeclaration - } - if methodTypeMatches(method.StreamingPayload, userType) { - return data.StreamingPayloadDeclaration - } - if methodTypeMatches(method.Result, userType) { - return data.ResultDeclaration - } - if methodTypeMatches(method.StreamingResult, userType) { - return data.StreamingResultDeclaration - } - return nil } -// methodTypeMatches reports whether candidate has the exact source origin used -// by a named method attribute. -func methodTypeMatches(candidate *expr.AttributeExpr, userType expr.UserType) bool { - if candidate == nil { - return false +// viewTypeContext returns a context that resolves projected and viewed result +// declarations from the generated transport package for side. +func (sds *ServicesData) viewTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: sds.ViewAttributor(sd.Service.Name, outputPackage), } - candidateType, ok := candidate.Type.(expr.UserType) - return ok && candidateType.Origin() == userType.Origin() -} - -// viewContext returns an attribute context for projected types. -func viewContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(true, false, true, pkg, scope) } // unmarshal initializes a data structure defined by target type from a data diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index 2bc0d50405..7efb84c494 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -1,3 +1,5 @@ +// This file verifies deterministic HTTP wire union identity and confirms that +// detached wire expressions do not retain service package ownership. package codegen import ( @@ -78,8 +80,8 @@ func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testi } scope := cg.NewNameScope() - unions := make(map[string]*svc.UnionTypeData) - collectHTTPUnionTypes(bodies, scope, unions, make(map[string]struct{})) + unions := make(map[cg.UnionTypeID]*svc.UnionTypeData) + collectHTTPUnionTypes(bodies, scope, unions, make(map[expr.UserType]struct{})) emitted := make([]string, 0, len(unions)) for _, union := range unions { @@ -126,6 +128,68 @@ func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value ") } +func TestMakeHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: makeUnionForOrderTest("Choice", "text", "number")}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + + wire := makeHTTPType(&expr.AttributeExpr{Type: outer}) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + +func TestStreamingHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + body := &expr.AttributeExpr{Type: outer} + endpoint := &expr.HTTPEndpointExpr{StreamingBody: body} + + wire := new(shapedBodies).streaming(endpoint) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + func sameShapedValueUnionDSL() { dsl.Attribute("bool", dsl.Boolean) dsl.Attribute("number", dsl.Float64) @@ -133,13 +197,13 @@ func sameShapedValueUnionDSL() { func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { scope := cg.NewNameScope() - seen := make(map[string]struct{}) - unionByHash := make(map[string]*svc.UnionTypeData) - collectHTTPUnionTypes(att, scope, unionByHash, seen) + seen := make(map[expr.UserType]struct{}) + unionTypes := make(map[cg.UnionTypeID]*svc.UnionTypeData) + collectHTTPUnionTypes(att, scope, unionTypes, seen) - names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name + names := make(map[string]string, len(unionTypes)) + for identity, data := range unionTypes { + names[identity.Hash()] = data.Name } return names } diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go index e024fdb98c..307a2c8453 100644 --- a/http/codegen/service_imports.go +++ b/http/codegen/service_imports.go @@ -7,31 +7,37 @@ import ( "strings" "goa.design/goa/v3/codegen" - servicecodegen "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) // addEndpointImports adds the named service-type references used by endpoints // to file's header. The output package is computed from the generated path. -func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { +func addEndpointImports(file *codegen.File, services *ServicesData, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { if file == nil { return nil } outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") - outputPackage := path.Join(genpkg, path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, httpEndpointAttributes(endpoints...)...)...) + outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, ServiceReferenceAttributes(endpoints...)...)...) return file } -// httpEndpointAttributes returns the named service attributes referenced by -// the supplied HTTP endpoint sections. -func httpEndpointAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { +// ServiceReferenceAttributes returns the named service attributes referenced +// by generated HTTP or JSON-RPC endpoint sections, including the nested result +// field selected as SSE event data. +func ServiceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { var attributes []*expr.AttributeExpr for _, endpoint := range endpoints { method := endpoint.MethodExpr - attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) - if method.HasMixedResults() { - attributes = append(attributes, method.StreamingResult) + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) + if endpoint.SSE != nil && endpoint.SSE.DataField != "" { + event := method.Result + if method.HasMixedResults() { + event = method.StreamingResult + } + if object := expr.AsObject(event.Type); object != nil { + attributes = append(attributes, object.Attribute(endpoint.SSE.DataField)) + } } for _, methodError := range method.Errors { attributes = append(attributes, methodError.AttributeExpr) diff --git a/http/codegen/sse.go b/http/codegen/sse.go index d1a7ffc7c9..552b6449d6 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -1,3 +1,6 @@ +// This file builds HTTP server-sent event render data. Service event values +// use frozen service declarations while encoded response bodies remain owned +// by the HTTP transport package. package codegen import ( @@ -59,7 +62,7 @@ type ( ) // initSSEData initializes the SSE related data in ed. -func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { +func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if !e.UsesSSE() { return } @@ -73,9 +76,10 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if e.MethodExpr.HasMixedResults() && e.MethodExpr.StreamingResult != nil { // For mixed results, use StreamingResult for SSE events eventAttr = e.MethodExpr.StreamingResult + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) eventType = &ResultData{ - Name: md.StreamingResult, - Ref: sd.Service.Scope.GoFullTypeRef(eventAttr, svc.PkgName), + Name: svcctx.Scope.Name(eventAttr, svcctx.Pkg(eventAttr), false, true), + Ref: svcctx.Scope.Ref(eventAttr, svcctx.Pkg(eventAttr)), IsStruct: expr.IsObject(eventAttr.Type), } } else { @@ -89,6 +93,7 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { // Convert attribute names to Go field names var dataFieldVar, dataFieldTypeRef, idFieldVar, eventFieldVar, retryFieldVar string + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) if obj := expr.AsObject(eventAttr.Type); obj != nil { for _, nat := range *obj { switch nat.Name { @@ -100,7 +105,8 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { retryFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) case e.SSE.DataField: dataFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) - dataFieldTypeRef = sd.Service.Scope.GoFullTypeRef(nat.Attribute, svc.PkgName) + fieldctx := svcctx.Enter(nat.Attribute) + dataFieldTypeRef = fieldctx.Scope.Ref(nat.Attribute, fieldctx.Pkg(nat.Attribute)) } } } @@ -148,7 +154,7 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { // sseServerFile returns the file implementing the SSE server // streaming implementation if any. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil @@ -169,8 +175,8 @@ func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesD {Path: "time"}, {Path: "encoding/json"}, {Path: "fmt"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), }, ), ) diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index 1296bb46be..db88b281a1 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -10,7 +10,7 @@ import ( // sseClientFile returns the file implementing the SSE client code for SSE endpoints if any. // Relies on SSEData (ed.SSE) for all codegen needs. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil @@ -33,8 +33,8 @@ func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesD {Path: "strings"}, {Path: "strconv"}, {Path: "sync"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), {Path: "goa.design/goa/v3/http", Name: "goahttp"}, }, ), diff --git a/http/codegen/sse_client_test.go b/http/codegen/sse_client_test.go index 7c52f71827..66c640e84e 100644 --- a/http/codegen/sse_client_test.go +++ b/http/codegen/sse_client_test.go @@ -31,7 +31,7 @@ func TestSSEClient(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ClientFiles("", services) + fs := ClientFiles(services) require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/sse_mixed_results_test.go b/http/codegen/sse_mixed_results_test.go index 09422ccc85..7dbd0c8364 100644 --- a/http/codegen/sse_mixed_results_test.go +++ b/http/codegen/sse_mixed_results_test.go @@ -17,7 +17,7 @@ func TestSSE_MixedResults(t *testing.T) { services := CreateHTTPServices(root) t.Run("server", func(t *testing.T) { - files := ServerFiles("", services) + files := ServerFiles(services) var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("server", "sse.go")) { @@ -36,7 +36,7 @@ func TestSSE_MixedResults(t *testing.T) { }) t.Run("client", func(t *testing.T) { - files := ClientFiles("", services) + files := ClientFiles(services) var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("client", "sse.go")) { diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go index 7a32e2a084..8f6ec0278c 100644 --- a/http/codegen/sse_server_test.go +++ b/http/codegen/sse_server_test.go @@ -31,7 +31,7 @@ func TestSSE(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -45,7 +45,7 @@ func TestSSE(t *testing.T) { func TestSSETransportDefaultsToStatusOK(t *testing.T) { root := expr.RunDSL(t, testdata.SSEStringDSL) services := CreateHTTPServices(root) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.Len(t, fs, 3) sections := fs[1].SectionTemplates diff --git a/http/codegen/streaming_test.go b/http/codegen/streaming_test.go index 996ef20587..cc4539e069 100644 --- a/http/codegen/streaming_test.go +++ b/http/codegen/streaming_test.go @@ -206,7 +206,7 @@ func TestServerStreaming(t *testing.T) { filesFn := func(root *expr.RootExpr) []*codegen.File { services := CreateHTTPServices(root) - return ServerFiles("", services) + return ServerFiles(services) } runTests(t, cases, filesFn) } @@ -389,7 +389,7 @@ func TestClientStreaming(t *testing.T) { } filesFn := func(root *expr.RootExpr) []*codegen.File { services := CreateHTTPServices(root) - return ClientFiles("", services) + return ClientFiles(services) } runTests(t, cases, filesFn) } diff --git a/http/codegen/templates/build_stream_request.go.tpl b/http/codegen/templates/build_stream_request.go.tpl index 2cab16afc5..8d63f3c646 100644 --- a/http/codegen/templates/build_stream_request.go.tpl +++ b/http/codegen/templates/build_stream_request.go.tpl @@ -1,10 +1,10 @@ // {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayload | comment }} -func {{ .BuildStreamPayload }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { +func {{ .BuildStreamPayload }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { f, err := os.Open(fpath) if err != nil { return nil, err } - return &{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}{ + return &{{ .ServicePkgName }}.{{ .Method.RequestStruct }}{ {{- if .Payload.Ref }} Payload: payload.({{ .Payload.Ref }}), {{- end }} diff --git a/http/codegen/templates/client_endpoint_init.go.tpl b/http/codegen/templates/client_endpoint_init.go.tpl index e9cf0c79bf..09ac4de957 100644 --- a/http/codegen/templates/client_endpoint_init.go.tpl +++ b/http/codegen/templates/client_endpoint_init.go.tpl @@ -93,7 +93,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder resp.Body.Close() return nil, err } - return &{{ responseStructPkg .Method .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil + return &{{ .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil {{- else }} return decodeResponse(resp) {{- end }} diff --git a/http/codegen/templates/request_encoder.go.tpl b/http/codegen/templates/request_encoder.go.tpl index da463f0b68..4fcbe804e0 100644 --- a/http/codegen/templates/request_encoder.go.tpl +++ b/http/codegen/templates/request_encoder.go.tpl @@ -9,9 +9,9 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht return nil {{- else }} {{- if .Method.SkipRequestBodyEncodeDecode }} - data, ok := v.(*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}) + data, ok := v.(*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}) if !ok { - return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}", v) + return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}", v) } p := data.Payload {{- else }} diff --git a/http/codegen/testdata/error_response_dsls.go b/http/codegen/testdata/error_response_dsls.go index b43c63add4..1a24b6353c 100644 --- a/http/codegen/testdata/error_response_dsls.go +++ b/http/codegen/testdata/error_response_dsls.go @@ -1,3 +1,5 @@ +// This file defines HTTP error response designs used by transport codegen +// tests, including reusable API mappings and service-level error contracts. package testdata import ( @@ -148,7 +150,7 @@ var APINoBodyErrorResponseDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") @@ -171,7 +173,7 @@ var APINoBodyErrorResponseWithContentTypeDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 3bb6ccd15f..14181d7efa 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -20,10 +20,13 @@ func CreateHTTPServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := codegen.NewGeneration("/", []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } + if err := Plan(generation); err != nil { + panic(err) + } if err := generation.Freeze(); err != nil { panic(err) } diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index 3744d2f81b..92063a093b 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -25,7 +25,7 @@ func TestTransformHelperServer(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - f := ServerEncodeDecodeFile("", root.API.HTTP.Services[0], services) + f := ServerEncodeDecodeFile(root.API.HTTP.Services[0], services) sections := f.SectionTemplates require.Greater(t, len(sections), c.Offset) code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) @@ -49,7 +49,7 @@ func TestTransformHelperCLI(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) services := CreateHTTPServices(root) - f := ClientEncodeDecodeFile("", root.API.HTTP.Services[0], services) + f := ClientEncodeDecodeFile(root.API.HTTP.Services[0], services) sections := f.SectionTemplates require.Greater(t, len(sections), c.Offset) code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) diff --git a/http/codegen/types.go b/http/codegen/types.go index d486b76fee..76f798726d 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -10,19 +10,19 @@ import ( ) // ServerTypeFiles returns the HTTP transport type files. -func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +func ServerTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = addEndpointImports(typesFile(genpkg, svc, true, data), genpkg, svc.HTTPEndpoints...) + fw[i] = addEndpointImports(typesFile(svc, true, data), data, svc.HTTPEndpoints...) } return fw } // ClientTypeFiles returns the HTTP transport client types files. -func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +func ClientTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = addEndpointImports(typesFile(genpkg, svc, false, data), genpkg, svc.HTTPEndpoints...) + fw[i] = addEndpointImports(typesFile(svc, false, data), data, svc.HTTPEndpoints...) } return fw } @@ -51,7 +51,7 @@ func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { // // - Response body fields (if the body is a struct) and header variables hold // pointers when not required and have no default value. -func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { +func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { var ( data = services.Get(svc.Name()) svcName = data.Service.PathName @@ -85,12 +85,12 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser {Path: "encoding/json"}, {Path: "fmt"}, {Path: "unicode/utf8"}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), } if len(data.UnionTypes) > 0 { imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) } - views := &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg} + views := services.ViewImport(svc.Name()) if svr { imports = append(imports, codegen.GoaImport(""), views) } else { diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 372bd4dac0..f337727017 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -92,7 +92,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ) md := ed.Method svc := sd.Service - svcctx := methodTypeContext(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) + svcctx := sds.serviceTypeContext(sd, "server").Enter(e.MethodExpr.StreamingPayload) svrSendTypeName := ed.Result.Name svrSendTypeRef := ed.Result.Ref svrSendDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) @@ -101,8 +101,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin cliRecvWithContextDesc := fmt.Sprintf("%s reads instances of %q from the %q endpoint websocket connection with context.", md.ClientStream.RecvWithContextName, svrSendTypeName, md.Name) if e.MethodExpr.Stream == expr.ClientStreamKind || e.MethodExpr.Stream == expr.BidirectionalStreamKind { streamBody := sd.bodies.streaming(e) - svrRecvTypeName = methodTypeName(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) - svrRecvTypeRef = methodTypeRef(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope) + svrRecvTypeName = svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true) + svrRecvTypeRef = svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)) svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type @@ -168,10 +168,10 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin Name: name, Description: desc, ServerArgs: serverArgs, - ReturnTypeName: methodTypeName(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope), - ReturnTypeRef: methodTypeRef(e.MethodExpr.StreamingPayload, md.StreamingPayloadDeclaration, svc.PkgName, svc.Scope), + ReturnTypeName: svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true), + ReturnTypeRef: svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)), ReturnIsStruct: expr.IsObject(e.MethodExpr.StreamingPayload.Type), - ReturnTypePkg: svc.PkgName, + ReturnTypePkg: svcctx.Pkg(e.MethodExpr.StreamingPayload), ServerCode: serverCode, } } @@ -242,7 +242,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin // websocketServerFile returns the file implementing the WebSocket server // streaming implementation if any. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil @@ -258,7 +258,7 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), } structSections := serverStructWSSections(data) wsSections := serverWSSections(data) @@ -275,7 +275,7 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser // WebsocketClientFile returns the file implementing the WebSocket client // streaming implementation if any. -func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func WebsocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil @@ -291,8 +291,8 @@ func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ViewImport(svc.Name()), + services.ServiceImport(svc.Name()), } structSections := clientStructWSSections(data) wsSections := clientWSSections(data) diff --git a/http/codegen/websocket_golden_test.go b/http/codegen/websocket_golden_test.go index e6ab235778..7eb1aa2c78 100644 --- a/http/codegen/websocket_golden_test.go +++ b/http/codegen/websocket_golden_test.go @@ -77,9 +77,9 @@ func TestWebSocketGoldenFiles(t *testing.T) { var files []*codegen.File if c.fileType == "server" { - files = ServerFiles("", services) + files = ServerFiles(services) } else { - files = ClientFiles("", services) + files = ClientFiles(services) } // Find the websocket.go file @@ -123,8 +123,8 @@ func TestWebSocketTemplateExercise(t *testing.T) { services := CreateHTTPServices(root) // Generate both server and client files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := ServerFiles(services) + clientFiles := ClientFiles(services) // Verify WebSocket files were generated var serverWSFile, clientWSFile *codegen.File diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index e1ce4d9805..7cbb4005a1 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -12,20 +12,20 @@ import ( ) // ClientFiles returns the generated JSON-RPC client files. -func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { +func ClientFiles(data *httpcodegen.ServicesData) []*codegen.File { jsvcs := data.Root.API.JSONRPC.Services files := make([]*codegen.File, 0, len(jsvcs)*3) for _, svc := range jsvcs { - files = append(files, addEndpointImports(clientFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) - if f := websocketClientFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, jsonRPCWebSocketEndpoints(svc)...)) + files = append(files, addEndpointImports(clientFile(svc, data), data, svc.HTTPEndpoints...)) + if f := websocketClientFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, jsonRPCWebSocketEndpoints(svc)...)) } - if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, jsonRPCSSEEndpoints(svc)...)) + if f := sseClientFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, jsonRPCSSEEndpoints(svc)...)) } } for _, svc := range jsvcs { - f := httpcodegen.ClientEncodeDecodeFile(genpkg, svc, data) + f := httpcodegen.ClientEncodeDecodeFile(svc, data) if f == nil { continue } @@ -49,13 +49,13 @@ func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File if n := len(data.Get(svc.Name()).Endpoints); swapped != n { panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, svc.Name(), n)) } - files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) + files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } return files } // clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func clientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") @@ -77,8 +77,8 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), }), } sections = append(sections, &codegen.SectionTemplate{ diff --git a/jsonrpc/codegen/example_server.go b/jsonrpc/codegen/example_server.go index c149a47c9b..5f648aa9a5 100644 --- a/jsonrpc/codegen/example_server.go +++ b/jsonrpc/codegen/example_server.go @@ -11,17 +11,18 @@ import ( ) // ExampleServerFiles returns example JSON-RPC server implementation. -func ExampleServerFiles(genpkg string, data *httpcodegen.ServicesData, files []*codegen.File) []*codegen.File { +func ExampleServerFiles(data *httpcodegen.ServicesData, files []*codegen.File) []*codegen.File { var fw []*codegen.File for _, svr := range data.Root.API.Servers { - if m := exampleServer(genpkg, data, svr, files); m != nil { + if m := exampleServer(data, svr, files); m != nil { fw = append(fw, m) } } return fw } -func exampleServer(genpkg string, data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { +func exampleServer(data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { + genpkg := data.GenPkg() svrdata := example.Servers.Get(svr, data.Root) httppath := filepath.Join("cmd", svrdata.Dir, "http.go") @@ -36,7 +37,7 @@ func exampleServer(genpkg string, data *httpcodegen.ServicesData, svr *expr.Serv } } if file == nil { - file = httpcodegen.ExampleServer(genpkg, data.Root, svr, data) + file = httpcodegen.ExampleServer(data.Root, svr, data) } // Add JSON-RPC imports to the HTTP server file diff --git a/jsonrpc/codegen/idempotency_test.go b/jsonrpc/codegen/idempotency_test.go index 89444dab4e..d7c2295240 100644 --- a/jsonrpc/codegen/idempotency_test.go +++ b/jsonrpc/codegen/idempotency_test.go @@ -32,7 +32,7 @@ func TestIdempotentJSONRPCEndpointCodegen(t *testing.T) { }) }) services := CreateJSONRPCServices(root) - clientFiles := ClientFiles("", services) + clientFiles := ClientFiles(services) require.NotEmpty(t, clientFiles) var clientCode string diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go new file mode 100644 index 0000000000..8650cef60d --- /dev/null +++ b/jsonrpc/codegen/plan.go @@ -0,0 +1,43 @@ +// This file declares the fixed import qualifiers used by JSON-RPC-generated +// files before service package aliases are frozen for the generation. +package codegen + +import ( + "goa.design/goa/v3/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// Plan reserves every literal import qualifier used by JSON-RPC render +// templates, including WebSocket and server-sent event support. +func Plan(generation *codegen.Generation) error { + if err := httpcodegen.Plan(generation); err != nil { + return err + } + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("bufio"), + codegen.SimpleImport("bytes"), + codegen.SimpleImport("context"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("mime/multipart"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("path"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("sync"), + codegen.SimpleImport("sync/atomic"), + codegen.SimpleImport("time"), + codegen.SimpleImport("github.com/gorilla/websocket"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + codegen.GoaImport("jsonrpc"), + } + for _, spec := range imports { + if err := generation.RequireImport(spec); err != nil { + return err + } + } + return nil +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go new file mode 100644 index 0000000000..3c99bf9feb --- /dev/null +++ b/jsonrpc/codegen/plan_test.go @@ -0,0 +1,33 @@ +// This file verifies standalone JSON-RPC planning includes the HTTP codecs and +// helpers that JSON-RPC rendering reuses. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("UUID", func() { + dsl.Method("Read", func() { + dsl.JSONRPC(func() {}) + }) + }) + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) + + require.Equal(t, "uuid2", services.ServiceImport("UUID").Name) +} diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index 79e43ec6ec..1bb7a8ea09 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -13,22 +13,22 @@ import ( ) // ServerFiles returns the generated JSON-RPC server files if any. -func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { +func ServerFiles(data *httpcodegen.ServicesData) []*codegen.File { jsvcs := data.Root.API.JSONRPC.Services files := make([]*codegen.File, 0, len(jsvcs)*3) for _, svc := range jsvcs { - files = append(files, addEndpointImports(serverFile(genpkg, svc, data), genpkg, svc.HTTPEndpoints...)) + files = append(files, addEndpointImports(serverFile(svc, data), data, svc.HTTPEndpoints...)) // Generate either WebSocket or SSE file based on transport type if hasJSONRPCSSE(svc) { - if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, jsonRPCSSEEndpoints(svc)...)) + if f := sseServerFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, jsonRPCSSEEndpoints(svc)...)) } - } else if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, addEndpointImports(f, genpkg, jsonRPCWebSocketEndpoints(svc)...)) + } else if f := websocketServerFile(svc, data); f != nil { + files = append(files, addEndpointImports(f, data, jsonRPCWebSocketEndpoints(svc)...)) } } for _, svc := range jsvcs { - f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) + f := httpcodegen.ServerEncodeDecodeFile(svc, data) if f == nil { continue } @@ -41,13 +41,13 @@ func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File } s.Name = "jsonrpc-" + s.Name } - files = append(files, addEndpointImports(f, genpkg, svc.HTTPEndpoints...)) + files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } return files } // serverFile returns the file implementing the JSON-RPC server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func serverFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "server.go") @@ -73,8 +73,8 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(svc.Name()), + services.ViewImport(svc.Name()), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), diff --git a/jsonrpc/codegen/service_imports.go b/jsonrpc/codegen/service_imports.go index e47028e9d2..420d850f7a 100644 --- a/jsonrpc/codegen/service_imports.go +++ b/jsonrpc/codegen/service_imports.go @@ -7,36 +7,19 @@ import ( "strings" "goa.design/goa/v3/codegen" - servicecodegen "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" ) // addEndpointImports adds the named service-type references used by endpoints // to file's header. The output package is computed from the generated path. -func addEndpointImports(file *codegen.File, genpkg string, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { +func addEndpointImports(file *codegen.File, services *httpcodegen.ServicesData, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") - outputPackage := path.Join(genpkg, path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], servicecodegen.AttributeImports(genpkg, outputPackage, jsonRPCEndpointAttributes(endpoints...)...)...) + outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) + codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, httpcodegen.ServiceReferenceAttributes(endpoints...)...)...) return file } -// jsonRPCEndpointAttributes returns the named service attributes referenced by -// the supplied JSON-RPC endpoint sections. -func jsonRPCEndpointAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { - var attributes []*expr.AttributeExpr - for _, endpoint := range endpoints { - method := endpoint.MethodExpr - attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) - if method.HasMixedResults() { - attributes = append(attributes, method.StreamingResult) - } - for _, methodError := range method.Errors { - attributes = append(attributes, methodError.AttributeExpr) - } - } - return attributes -} - // jsonRPCWebSocketEndpoints returns only the endpoints whose stream sections // are rendered into WebSocket files. func jsonRPCWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index 0d0808870a..e3ed22e365 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -14,7 +14,7 @@ import ( // sseServerFile returns the file implementing the JSON-RPC SSE server // streams if any. The file contains the shared SSE stream machinery followed // by one stream implementation per SSE endpoint. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func sseServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) if data == nil { return nil @@ -35,7 +35,7 @@ func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -58,7 +58,7 @@ func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg } // sseClientFile returns the file implementing the SSE client streaming implementation if any. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func sseClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) if data == nil { return nil @@ -86,7 +86,7 @@ func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg {Path: "sync"}, codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), }, ), ) diff --git a/jsonrpc/codegen/sse_dedup_test.go b/jsonrpc/codegen/sse_dedup_test.go index 503aae83c6..091025bef0 100644 --- a/jsonrpc/codegen/sse_dedup_test.go +++ b/jsonrpc/codegen/sse_dedup_test.go @@ -20,7 +20,7 @@ func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { services := CreateJSONRPCServices(root) // Generate JSON-RPC server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.NotEmpty(t, fs) // Render the SSE streams file (sse.go) diff --git a/jsonrpc/codegen/sse_integration_test.go b/jsonrpc/codegen/sse_integration_test.go index a16f91ab80..54ec26473a 100644 --- a/jsonrpc/codegen/sse_integration_test.go +++ b/jsonrpc/codegen/sse_integration_test.go @@ -23,8 +23,8 @@ func TestJSONRPCSSEIntegration(t *testing.T) { services := CreateJSONRPCServices(root) // Generate all files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := ServerFiles(services) + clientFiles := ClientFiles(services) // Combine all files allFiles := make([]*codegen.File, 0, len(serverFiles)+len(clientFiles)) diff --git a/jsonrpc/codegen/sse_test.go b/jsonrpc/codegen/sse_test.go index 546d88e028..02fdbbd9bd 100644 --- a/jsonrpc/codegen/sse_test.go +++ b/jsonrpc/codegen/sse_test.go @@ -27,7 +27,7 @@ func TestJSONRPCSSE(t *testing.T) { services := CreateJSONRPCServices(root) // Generate server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := ServerFiles(services) require.NotEmpty(t, fs, "expected server files to be generated") // Debug: print all generated files diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go index 38dbb13ee2..2ac4a31707 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go @@ -38,7 +38,7 @@ var JSONRPCSSEObjectDSL = func() { Attribute("last_event_id", String, "Last event ID") }) StreamingResult(func() { - ID("id", String, "Event ID") + ID("id", String, "Event ID") Attribute("data", String, "Event data") }) JSONRPC(func() { @@ -49,4 +49,4 @@ var JSONRPCSSEObjectDSL = func() { }) }) }) -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go index 372c7bc842..6dbd6a2ef8 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go @@ -1,30 +1,29 @@ package testdata import ( - . "goa.design/goa/v3/dsl" + . "goa.design/goa/v3/dsl" ) // JSONRPCSSEDuplicateEventDSL defines two JSON-RPC SSE streaming methods that share the // same streaming result type to ensure generated server stream switch does not duplicate cases. var JSONRPCSSEDuplicateEventDSL = func() { - API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) + API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) - var SharedSSEEvent = Type("SharedSSEEvent", func() { - Attribute("data", String) - Required("data") - }) + var SharedSSEEvent = Type("SharedSSEEvent", func() { + Attribute("data", String) + Required("data") + }) - Service("JSONRPCSSEDupeService", func() { - JSONRPC(func() { POST("/stream") }) + Service("JSONRPCSSEDupeService", func() { + JSONRPC(func() { POST("/stream") }) - Method("StreamA", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("StreamB", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) + Method("StreamA", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + Method("StreamB", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + }) } - diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index b603ab9435..3aeb97eb95 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -22,10 +22,13 @@ func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := codegen.NewGeneration("/", []eval.Root{root}) if err := service.Plan(root, generation); err != nil { panic(err) } + if err := Plan(generation); err != nil { + panic(err) + } if err := generation.Freeze(); err != nil { panic(err) } diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go index cb5a5eb7e8..f58383c1c8 100644 --- a/jsonrpc/codegen/websocket_client.go +++ b/jsonrpc/codegen/websocket_client.go @@ -11,7 +11,7 @@ import ( httpcodegen "goa.design/goa/v3/http/codegen" ) -func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func websocketClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) if !httpcodegen.HasWebSocket(data) { return nil @@ -37,7 +37,7 @@ func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), ) sections := []*codegen.SectionTemplate{ diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go index cc3b7acb8c..aca42dd362 100644 --- a/jsonrpc/codegen/websocket_server.go +++ b/jsonrpc/codegen/websocket_server.go @@ -14,7 +14,7 @@ import ( // websocketServerFile returns the file implementing the JSON-RPC WebSocket server // streaming implementation if any. It follows the exact same pattern as the encode/decode // files: get the HTTP file and modify it for JSON-RPC. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { +func websocketServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { data := services.Get(svc.Name()) if !httpcodegen.HasWebSocket(data) { return nil @@ -41,7 +41,7 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *htt codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(svc.Name()), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), From 8dc8ea8eb2f45c237b1f5bccca1d652a53371007 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 05:56:06 -0700 Subject: [PATCH 22/43] fix(codegen): key recursive traversal by declaration origin --- codegen/walk.go | 15 ++-- codegen/walk_test.go | 88 +++++++++++++++++++ expr/http_body_types.go | 16 +++- expr/http_body_types_test.go | 59 +++++++++++++ expr/http_endpoint.go | 12 +-- expr/http_endpoint_internal_test.go | 31 +++++++ grpc/codegen/protobuf.go | 9 +- grpc/codegen/protobuf_test.go | 31 +++++++ grpc/codegen/service_data.go | 15 ++-- grpc/codegen/service_data_traversal_test.go | 49 +++++++++++ http/codegen/service_data.go | 65 ++++++++------ http/codegen/service_data_traversal_test.go | 97 +++++++++++++++++++++ 12 files changed, 429 insertions(+), 58 deletions(-) create mode 100644 codegen/walk_test.go create mode 100644 expr/http_endpoint_internal_test.go create mode 100644 grpc/codegen/service_data_traversal_test.go create mode 100644 http/codegen/service_data_traversal_test.go diff --git a/codegen/walk.go b/codegen/walk.go index a2192912d4..1e64d3e351 100644 --- a/codegen/walk.go +++ b/codegen/walk.go @@ -1,3 +1,5 @@ +// Attribute walkers visit Goa design types once per source declaration while +// preserving the concrete dynamic type supplied to callbacks. package codegen import "goa.design/goa/v3/expr" @@ -10,13 +12,13 @@ type MappedAttributeWalker func(name, elem string, required bool, a *expr.Attrib // Walk traverses the data structure recursively and calls the given function // once on each attribute starting with a. func Walk(a *expr.AttributeExpr, walker func(*expr.AttributeExpr) error) error { - return walk(a, walker, make(map[string]bool)) + return walk(a, walker, make(map[expr.UserType]struct{})) } // WalkType traverses the data structure recursively and calls the given function // once on each attribute starting with the user type attribute. func WalkType(u expr.UserType, walker func(*expr.AttributeExpr) error) error { - return walk(u.Attribute(), walker, map[string]bool{u.ID(): true}) + return walk(u.Attribute(), walker, map[expr.UserType]struct{}{u.Origin(): {}}) } // WalkMappedAttr iterates over the mapped attributes. It calls the given @@ -35,15 +37,16 @@ func WalkMappedAttr(ma *expr.MappedAttributeExpr, it MappedAttributeWalker) erro // Recursive implementation of the Walk methods. Takes care of avoiding infinite // recursions by keeping track of types that have already been walked. -func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[string]bool) error { +func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[expr.UserType]struct{}) error { if err := walker(at); err != nil { return err } walkUt := func(ut expr.UserType) error { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return nil } - seen[ut.ID()] = true + seen[origin] = struct{}{} return walk(ut.Attribute(), walker, seen) } switch actual := at.Type.(type) { @@ -71,7 +74,7 @@ func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen m case *expr.UserTypeExpr: return walkUt(actual) case *expr.ResultTypeExpr: - return walkUt(actual.UserTypeExpr) + return walkUt(actual) default: panic("unknown attribute type") // bug } diff --git a/codegen/walk_test.go b/codegen/walk_test.go new file mode 100644 index 0000000000..e71e66ff6b --- /dev/null +++ b/codegen/walk_test.go @@ -0,0 +1,88 @@ +// This file verifies that attribute traversal distinguishes unrelated design +// declarations while terminating when a declaration refers to itself. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestWalkDistinguishesEqualUIDOrigins(t *testing.T) { + firstLeaf := &expr.AttributeExpr{Type: expr.String} + secondLeaf := &expr.AttributeExpr{Type: expr.Int} + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstLeaf}, + }}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "second", Attribute: secondLeaf}, + }}, + TypeName: "Second", + UID: "shared", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[firstLeaf]) + require.True(t, visited[secondLeaf]) +} + +func TestWalkPreservesDynamicResultTypeOrigin(t *testing.T) { + baseLeaf := &expr.AttributeExpr{Type: expr.String} + base := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: baseLeaf}, + }}, + TypeName: "Base", + } + resultLeaf := &expr.AttributeExpr{Type: expr.Int} + embedded := base.Dup(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "result", Attribute: resultLeaf}, + }}).(*expr.UserTypeExpr) + result := &expr.ResultTypeExpr{ + UserTypeExpr: embedded, + Identifier: "application/vnd.result", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: &expr.AttributeExpr{Type: base}}, + {Name: "result", Attribute: &expr.AttributeExpr{Type: result}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[baseLeaf]) + require.True(t, visited[resultLeaf]) +} + +func TestWalkTypeTerminatesRecursiveCopy(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + self := &expr.AttributeExpr{Type: recursive} + object.Set("self", self) + copy := expr.Dup(recursive).(expr.UserType) + + visits := 0 + require.NoError(t, WalkType(copy, func(*expr.AttributeExpr) error { + visits++ + return nil + })) + require.Equal(t, 2, visits) +} diff --git a/expr/http_body_types.go b/expr/http_body_types.go index d681e90c0c..974e07e0e2 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -1,3 +1,5 @@ +// HTTP body type helpers derive request and response shapes from service types +// without changing the original design declarations. package expr import ( @@ -539,17 +541,23 @@ func extendBodyAttribute(body *MappedAttributeExpr) { // walk traverses the given data type and invokes the given function for each // user type it finds including dt itself. func walk(dt DataType, do func(UserType)) { - walkrec(dt, do, make(map[string]struct{})) + walkrec(dt, do, make(map[UserType]struct{})) } -func walkrec(dt DataType, do func(UserType), seen map[string]struct{}) { +func walkrec(dt DataType, do func(UserType), seen map[UserType]struct{}) { switch dt := dt.(type) { case UserType: - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return } + // Mark the declaration before invoking do because callbacks such as + // appendSuffix rename the declaration and deliberately detach its origin. + seen[origin] = struct{}{} do(dt) - seen[dt.ID()] = struct{}{} + // A callback may detach a copied declaration from its source. Remember the + // resulting declaration too so recursive references do not process it again. + seen[dt.Origin()] = struct{}{} walkrec(dt.Attribute().Type, do, seen) case *Object: for _, nat := range *dt { diff --git a/expr/http_body_types_test.go b/expr/http_body_types_test.go index 7eeb9641a1..f2be49ff5e 100644 --- a/expr/http_body_types_test.go +++ b/expr/http_body_types_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHTTPStreamingBodyValidation(t *testing.T) { @@ -69,3 +70,61 @@ func TestHTTPStreamingBodyValidation(t *testing.T) { }) } } + +func TestRemovePkgPathDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"first/types"}}, + }, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"second/types"}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + RemovePkgPath(root) + require.NotContains(t, first.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, second.Attribute().Meta, "struct:pkg:path") +} + +func TestAppendSuffixDistinguishesEqualUIDOriginsAndTerminatesRecursion(t *testing.T) { + first := &UserTypeExpr{TypeName: "First", UID: "shared"} + firstObject := &Object{} + first.AttributeExpr = &AttributeExpr{Type: firstObject} + firstObject.Set("self", &AttributeExpr{Type: first}) + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: &Object{}}, + TypeName: "Second", + UID: "shared", + } + root := &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + } + + appendSuffix(root, "Body") + require.Equal(t, "FirstBody", first.Name()) + require.Equal(t, "SecondBody", second.Name()) +} + +func TestAppendSuffixTerminatesAfterRecursiveCopyDetachesOrigin(t *testing.T) { + original := &UserTypeExpr{TypeName: "Recursive"} + object := &Object{} + original.AttributeExpr = &AttributeExpr{Type: object} + object.Set("self", &AttributeExpr{Type: original}) + copy := Dup(original).(UserType) + + appendSuffix(copy, "Body") + require.Equal(t, "RecursiveBody", copy.Name()) +} diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index 11f4dad3b6..f8953b2e33 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -1225,12 +1225,12 @@ func isEmpty(a *AttributeExpr) bool { // hasJSONRPCIDField returns true if an attribute or any of its nested attributes // has the "jsonrpc:id" meta tag, indicating it's designated as the JSON-RPC ID field. func hasJSONRPCIDField(attr *AttributeExpr) bool { - return hasJSONRPCIDFieldRec(attr, make(map[*AttributeExpr]struct{}), make(map[string]struct{})) + return hasJSONRPCIDFieldRec(attr, make(map[*AttributeExpr]struct{})) } // hasJSONRPCIDFieldRec walks the attribute graph looking for the jsonrpc:id meta // while guarding against cycles that may occur with recursive user types. -func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, seenUT map[string]struct{}) bool { +func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}) bool { if attr == nil || attr.Type == Empty { return false } @@ -1249,7 +1249,7 @@ func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, // For object types, check all nested attributes if obj := AsObject(attr.Type); obj != nil { for _, nat := range *obj { - if hasJSONRPCIDFieldRec(nat.Attribute, seen, seenUT) { + if hasJSONRPCIDFieldRec(nat.Attribute, seen) { return true } } @@ -1258,11 +1258,7 @@ func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, // For user types, check the underlying attribute (guarding for recursion) if ut, ok := attr.Type.(UserType); ok { if ut != nil { - if _, ok := seenUT[ut.ID()]; ok { - return false - } - seenUT[ut.ID()] = struct{}{} - return hasJSONRPCIDFieldRec(ut.Attribute(), seen, seenUT) + return hasJSONRPCIDFieldRec(ut.Attribute(), seen) } } return false diff --git a/expr/http_endpoint_internal_test.go b/expr/http_endpoint_internal_test.go new file mode 100644 index 0000000000..140b4e9458 --- /dev/null +++ b/expr/http_endpoint_internal_test.go @@ -0,0 +1,31 @@ +// This file verifies recursive HTTP and JSON-RPC expression inspection across +// unrelated declarations that share a semantic identifier. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHasJSONRPCIDFieldDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: String, + Meta: MetaExpr{"jsonrpc:id": {}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + require.True(t, hasJSONRPCIDField(root)) +} diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index d5413c8d6c..0726d3a95a 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -114,20 +114,21 @@ func makeProtoBufMessage(att *expr.AttributeExpr, tname string, sd *ServiceData) } } n := "" - makeProtoBufMessageR(att, &n, sd, make(map[string]struct{})) + makeProtoBufMessageR(att, &n, sd, make(map[expr.UserType]struct{})) return att } // makeProtoBufMessageR is the recursive implementation of makeProtoBufMessage. -func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceData, seen map[string]struct{}) { +func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceData, seen map[expr.UserType]struct{}) { ut, isut := att.Type.(expr.UserType) // handle infinite recursions if isut { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return } - seen[ut.ID()] = struct{}{} + seen[origin] = struct{}{} } wrap := func(att *expr.AttributeExpr, tname string) { diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index fe8dae4b86..d9269f3825 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -287,6 +287,37 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { } } +func TestMakeProtoBufMessageDistinguishesEqualUIDOrigins(t *testing.T) { + first := protobufArrayTraversalType("First", "shared") + second := protobufArrayTraversalType("Second", "shared") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + message := makeProtoBufMessage(body, "Request", &ServiceData{ + Name: "Service", + Scope: codegen.NewNameScope(), + }) + object := expr.AsObject(message.Type.(expr.UserType).Attribute().Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireFirst})) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireSecond})) +} + +// protobufArrayTraversalType builds an authored array declaration that protobuf +// conversion must wrap in a message. +func protobufArrayTraversalType(name, uid string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + TypeName: name, + UID: uid, + } +} + func TestUnwrapAttrPanicsOnNonWrapper(t *testing.T) { cases := []struct { Name string diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index c66d2b11ff..514d26809b 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -864,12 +864,12 @@ func addValidation(att *expr.AttributeExpr, attName string, sd *ServiceData, req // req if true indicates that the validations are generated for validating // request messages. func collectValidations(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData) { - collectValidationsR(att, attName, req, sd, make(map[string]struct{})) + collectValidationsR(att, attName, req, sd, make(map[expr.UserType]struct{})) } // collectValidationsR recurses through the attribute and collects validation -// functions with cycle detection using a seen set of user type IDs. -func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData, seen map[string]struct{}) { +// functions with cycle detection using a seen set of declaration origins. +func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData, seen map[expr.UserType]struct{}) { gattName := codegen.Goify(attName, false) switch dt := att.Type.(type) { case expr.UserType: @@ -878,12 +878,11 @@ func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd * return } // Cycle guard: avoid infinite recursion on recursive user types. - if id := dt.ID(); id != "" { - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} + origin := dt.Origin() + if _, ok := seen[origin]; ok { + return } + seen[origin] = struct{}{} vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) def := codegen.AttributeValidationCode(att, dt, vtx, true, false, gattName, attName) // Match helper function identifiers with validation template calls by diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..0041897305 --- /dev/null +++ b/grpc/codegen/service_data_traversal_test.go @@ -0,0 +1,49 @@ +// This file verifies that gRPC validation discovery distinguishes unrelated +// declarations while stopping recursion through copied declarations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { + minimumLength := 3 + minimum := 5.0 + first := grpcValidationTraversalType("First", "shared", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimumLength}, + }) + second := grpcValidationTraversalType("Second", "shared", &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := &ServiceData{PkgName: "pb", Scope: codegen.NewNameScope()} + + collectValidations(root, "root", true, sd) + var names []string + for _, validation := range sd.validations { + names = append(names, validation.SrcName) + } + require.ElementsMatch(t, []string{"First", "Second"}, names) +} + +// grpcValidationTraversalType builds an authored message declaration with one +// constrained field so validation discovery must emit a helper for it. +func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: field}, + }}, + TypeName: name, + UID: uid, + } +} diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 26759b00b9..82e400e5ae 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1094,10 +1094,10 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { // * changes unions into structs with Type and Value fields. func makeHTTPType(att *expr.AttributeExpr) *expr.AttributeExpr { att = expr.DupAtt(att) - return makeHTTPTypeRecursive(att, make(map[string]struct{})) + return makeHTTPTypeRecursive(att, make(map[expr.UserType]struct{})) } -func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *expr.AttributeExpr { +func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) *expr.AttributeExpr { delete(att.Meta, "struct:pkg:path") switch dt := att.Type.(type) { case expr.UserType: @@ -1121,10 +1121,11 @@ func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *e att.DefaultValue = dt.Attribute().DefaultValue att.UserExamples = dt.Attribute().UserExamples } - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return att } - seen[dt.ID()] = struct{}{} + seen[origin] = struct{}{} dt.SetAttribute(makeHTTPTypeRecursive(dt.Attribute(), seen)) case *expr.Array: dt.ElemType = makeHTTPTypeRecursive(dt.ElemType, seen) @@ -2152,7 +2153,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * name = body.Type.Name() ref = sd.Scope.GoTypeRef(body) - addMarshalTags(body, make(map[string]struct{})) + addMarshalTags(body) if ut, ok := body.Type.(expr.UserType); ok { varname = codegen.Goify(ut.Name(), true) @@ -2304,7 +2305,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e ref = sd.Scope.GoTypeRef(body) mustInit = att.Type != expr.Empty && needInit(body.Type) - addMarshalTags(body, make(map[string]struct{})) + addMarshalTags(body) if ut, ok := body.Type.(expr.UserType); ok { // response body is a user type. @@ -2681,37 +2682,38 @@ func errorInitArg(el *Element) *InitArgData { // collectUserTypes traverses the given data type recursively and calls back the // given function for each attribute using a user type. -func collectUserTypes(dt expr.DataType, cb func(expr.UserType), seen ...map[string]struct{}) { +func collectUserTypes(dt expr.DataType, cb func(expr.UserType)) { + collectUserTypesRecursive(dt, cb, make(map[expr.UserType]struct{})) +} + +// collectUserTypesRecursive follows nested declarations once per authored +// origin so recursive copies terminate without hiding unrelated declarations. +func collectUserTypesRecursive(dt expr.DataType, cb func(expr.UserType), seen map[expr.UserType]struct{}) { if dt == expr.Empty { return } - var s map[string]struct{} - if len(seen) > 0 { - s = seen[0] - } else { - s = make(map[string]struct{}) - } switch actual := dt.(type) { case *expr.Object: for _, nat := range *actual { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Union: for _, nat := range actual.Values { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Array: - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case *expr.Map: - collectUserTypes(actual.KeyType.Type, cb, seen...) - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.KeyType.Type, cb, seen) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case expr.UserType: - if _, ok := s[actual.ID()]; ok { + origin := actual.Origin() + if _, ok := seen[origin]; ok { return } - s[actual.ID()] = struct{}{} + seen[origin] = struct{}{} cb(actual) - collectUserTypes(actual.Attribute().Type, cb, s) + collectUserTypesRecursive(actual.Attribute().Type, cb, seen) } } @@ -2983,26 +2985,33 @@ func isStringMetaType(c *expr.AttributeExpr) bool { } // addMarshalTags adds JSON, XML and Form tags to all inline object attributes recursively. -func addMarshalTags(att *expr.AttributeExpr, seen map[string]struct{}) { +func addMarshalTags(att *expr.AttributeExpr) { + addMarshalTagsRecursive(att, make(map[expr.UserType]struct{})) +} + +// addMarshalTagsRecursive annotates every inline object reachable through one +// declaration origin and stops when recursive copies return to that origin. +func addMarshalTagsRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) { if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := seen[ut.Hash()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return // avoid infinite recursions } - seen[ut.Hash()] = struct{}{} + seen[origin] = struct{}{} if expr.IsObject(ut.Attribute().Type) { for _, att := range *(expr.AsObject(att.Type)) { - addMarshalTags(att.Attribute, seen) + addMarshalTagsRecursive(att.Attribute, seen) } } return } if expr.IsArray(att.Type) { - addMarshalTags(expr.AsArray(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsArray(att.Type).ElemType, seen) return } if expr.IsMap(att.Type) { - addMarshalTags(expr.AsMap(att.Type).KeyType, seen) - addMarshalTags(expr.AsMap(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).KeyType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).ElemType, seen) return } if !expr.IsObject(att.Type) { diff --git a/http/codegen/service_data_traversal_test.go b/http/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..ac0f935f94 --- /dev/null +++ b/http/codegen/service_data_traversal_test.go @@ -0,0 +1,97 @@ +// This file verifies that HTTP body shaping and traversal visit unrelated +// declarations even when their semantic identifiers or structures match. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestMakeHTTPTypeDistinguishesEqualUIDOrigins(t *testing.T) { + first := locatedHTTPTraversalType("First", "shared", "first/types") + second := locatedHTTPTraversalType("Second", "shared", "second/types") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + wire := makeHTTPType(body) + object := expr.AsObject(wire.Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.NotContains(t, wireFirst.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireSecond.Attribute().Meta, "struct:pkg:path") +} + +func TestCollectUserTypesDistinguishesEqualUIDOriginsAndStopsRecursion(t *testing.T) { + first := &expr.UserTypeExpr{TypeName: "First", UID: "shared"} + firstObject := &expr.Object{} + first.AttributeExpr = &expr.AttributeExpr{Type: firstObject} + firstObject.Set("self", &expr.AttributeExpr{Type: first}) + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, + TypeName: "Second", + UID: "shared", + } + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + var names []string + collectUserTypes(outer, func(userType expr.UserType) { + names = append(names, userType.Name()) + }) + require.Equal(t, []string{"Outer", "First", "Second"}, names) +} + +func TestAddMarshalTagsDistinguishesEqualStructuralOrigins(t *testing.T) { + first := marshalTagTraversalType() + second := marshalTagTraversalType() + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + addMarshalTags(&expr.AttributeExpr{Type: outer}) + firstValue := expr.AsObject(expr.AsObject(first).Attribute("nested").Type).Attribute("value") + secondValue := expr.AsObject(expr.AsObject(second).Attribute("nested").Type).Attribute("value") + require.Equal(t, []string{"value"}, firstValue.Meta["struct:tag:json"]) + require.Equal(t, []string{"value"}, secondValue.Meta["struct:tag:json"]) +} + +// locatedHTTPTraversalType builds an authored declaration whose package path +// must be removed when Goa derives its transport type. +func locatedHTTPTraversalType(name, uid, packagePath string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{}, + Meta: expr.MetaExpr{"struct:pkg:path": {packagePath}}, + }, + TypeName: name, + UID: uid, + } +} + +// marshalTagTraversalType builds a declaration with an inline object whose +// fields must receive transport serialization tags. +func marshalTagTraversalType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}}, + }}, + TypeName: "Shared", + UID: "shared", + } +} From c6a0e1e0e8c508efc6149c79286239c054b7b69e Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 06:05:30 -0700 Subject: [PATCH 23/43] codegen: freeze generated transport aliases --- codegen/cli/cli.go | 10 +-- codegen/example/example_server.go | 23 +++---- codegen/example/example_server_test.go | 3 +- codegen/example/plan.go | 47 ++++++++++++++ ...erated_transport_alias_integration_test.go | 61 +++++++++++++++++++ codegen/generator/transport.go | 4 ++ grpc/codegen/client_cli.go | 4 +- grpc/codegen/example_cli.go | 21 +++++-- grpc/codegen/example_server.go | 26 +++----- grpc/codegen/plan.go | 21 ++++++- grpc/codegen/plan_test.go | 46 ++++++++++++++ grpc/codegen/service_data.go | 8 +++ grpc/codegen/templates/do_grpc_cli.go.tpl | 6 +- .../codegen/templates/server_grpc_init.go.tpl | 6 +- .../testdata/client-interceptors.golden | 2 +- grpc/codegen/testing.go | 4 ++ http/codegen/client_cli.go | 4 +- http/codegen/example_cli.go | 35 +++++++---- http/codegen/example_server.go | 46 ++++---------- http/codegen/plan.go | 28 +++++++++ http/codegen/plan_test.go | 31 ++++++++++ http/codegen/service_data.go | 8 +++ http/codegen/templates/cli_end.go.tpl | 2 +- http/codegen/templates/cli_usage.go.tpl | 4 +- .../codegen/templates/server_configure.go.tpl | 14 ++--- http/codegen/testing.go | 4 ++ jsonrpc/codegen/example_server.go | 18 +++--- jsonrpc/codegen/kitchen_sink_test.go | 4 ++ jsonrpc/codegen/plan.go | 28 +++++++++ jsonrpc/codegen/plan_test.go | 29 +++++++++ .../cmd/kitchen_sink-cli/jsonrpc.go.golden | 8 +-- .../jsonrpc/cli/kitchen_sink/cli.go.golden | 6 +- 32 files changed, 437 insertions(+), 124 deletions(-) create mode 100644 codegen/example/plan.go create mode 100644 codegen/generator/generated_transport_alias_integration_test.go create mode 100644 grpc/codegen/plan_test.go diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index f83ef6b082..8151a42055 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -31,8 +31,7 @@ type ( // Example is a valid command invocation, starting with the // command name. Example string - // PkgName is the service HTTP client package import name, - // e.g. "storagec". + // PkgName is the transport client package import name, e.g. "storagec". PkgName string // Interceptors contains the data for client interceptors if any. Interceptors *InterceptorData @@ -186,8 +185,9 @@ type ( ) // BuildCommandData builds the data needed by CLI code generators to render the -// parsing of the service command. -func BuildCommandData(data *service.Data) *CommandData { +// parsing of the service command. clientPkgName is the frozen qualifier for +// the generated transport client package. +func BuildCommandData(data *service.Data, clientPkgName string) *CommandData { description := data.Description if description == "" { description = fmt.Sprintf("Make requests to the %q service", data.Name) @@ -205,7 +205,7 @@ func BuildCommandData(data *service.Data) *CommandData { Name: codegen.KebabCase(data.Name), VarName: codegen.Goify(data.Name, false), Description: description, - PkgName: data.PkgName + "c", + PkgName: clientPkgName, Interceptors: interceptors, } } diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index fa58ff3f0c..031c9bed4a 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -1,8 +1,10 @@ +// This file renders the shared example server entrypoint and resolves every +// generated service, application, and interceptor import through the frozen +// generation catalog. package example import ( "os" - "path" "path/filepath" "strings" @@ -67,24 +69,23 @@ func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, se // Iterate through services listed in the server expression. svcData := make([]*service.Data, len(svr.Services)) - scope := codegen.NewNameScope() hasInterceptors := false for i, svc := range svr.Services { sd := services.Get(svc) svcData[i] = sd - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, sd.PathName), - Name: scope.Unique(sd.PkgName, "svc"), - }) + serviceImport := services.ServiceImport(svc) + specs = append(specs, serviceImport) hasInterceptors = hasInterceptors || len(sd.ServerInterceptors) > 0 } - interPkg := scope.Unique("interceptors", "ex") - rootPath := RootPath(genpkg) - apiPkg := APIPkg(root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + apiImport := services.PackageImport(rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) + var interPkg string if hasInterceptors { - specs = append(specs, &codegen.ImportSpec{Path: path.Join(rootPath, "interceptors"), Name: interPkg}) + interceptorImport := services.PackageImport(rootPath + "/interceptors") + interPkg = interceptorImport.Name + specs = append(specs, interceptorImport) } sections := []*codegen.SectionTemplate{ diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 5eb478f21b..f20ae9bca8 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -66,10 +66,11 @@ func TestExampleServerFiles(t *testing.T) { root := codegen.RunDSL(t, c.DSL) generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) services, err := service.NewServicesData(root, generation) require.NoError(t, err) - fs := ServerFiles("", root, services) + fs := ServerFiles(generation.GenPkg(), root, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/codegen/example/plan.go b/codegen/example/plan.go new file mode 100644 index 0000000000..15aa681a41 --- /dev/null +++ b/codegen/example/plan.go @@ -0,0 +1,47 @@ +// This file declares application packages imported by generated examples so +// their qualifiers are selected with the same catalog as generated services. +package example + +import ( + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// Plan reserves the application and interceptor package aliases consumed by +// example server and client files before the generation catalog freezes. +func Plan(generation *codegen.Generation) error { + rootPath := RootPath(generation.GenPkg()) + for _, root := range generation.Roots() { + design, ok := root.(*expr.RootExpr) + if !ok { + continue + } + scope := codegen.NewNameScope() + for _, service := range design.Services { + scope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + packageName := scope.Unique(strings.ToLower(codegen.Goify(design.API.Name, false)), "api") + if err := generation.DeclareImport(codegen.NewImport(packageName, rootPath)); err != nil { + return err + } + if hasInterceptors(design) { + if err := generation.DeclareImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { + return err + } + } + } + return nil +} + +// hasInterceptors reports whether generated examples import the application +// interceptor package for at least one service. +func hasInterceptors(root *expr.RootExpr) bool { + for _, service := range root.Services { + if len(service.ServerInterceptors) > 0 || len(service.ClientInterceptors) > 0 { + return true + } + } + return false +} diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go new file mode 100644 index 0000000000..85692e7ef1 --- /dev/null +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -0,0 +1,61 @@ +// This file verifies generated transport packages and example applications +// consume the same complete-path aliases selected during planning. +package generator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGeneratedTransportPackagesCompileWithServiceAliasCollisions proves that +// client, server, protobuf, CLI, and service imports remain paired when their +// preferred qualifiers collide. +func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace", func() {}) + for _, name := range []string{"Foo", "Fooc", "Foosvr", "Foojssvr"} { + dsl.Service(name, func() { + if name == "Foo" { + dsl.ClientInterceptor(interceptor) + } + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, planTransportData(generation)) + require.NoError(t, generation.Freeze()) + files, err := Service(generation) + require.NoError(t, err) + transport, err := Transport(generation) + require.NoError(t, err) + files = append(files, transport...) + examples, err := Example(generation) + require.NoError(t, err) + files = append(files, examples...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index a028ca183b..2fa36bf7ad 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -4,6 +4,7 @@ package generator import ( "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" @@ -56,6 +57,9 @@ func planTransportData(generation *codegen.Generation) error { if err := planServiceData(generation); err != nil { return err } + if err := example.Plan(generation); err != nil { + return err + } var hasHTTP, hasGRPC, hasJSONRPC bool for _, root := range serviceRoots(generation.Roots()) { hasHTTP = hasHTTP || len(root.API.HTTP.Services) > 0 diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index ddab8dd44d..3a20a6a56e 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -26,7 +26,7 @@ func ClientCLIFiles(services *ServicesData) []*codegen.File { continue } sd := services.Get(svc.Name()) - command := cli.BuildCommandData(sd.Service) + command := cli.BuildCommandData(sd.Service, sd.ClientPkgName) for _, e := range sd.Endpoints { flags, buildFunction := buildFlags(e) subcmd := cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags) @@ -84,7 +84,7 @@ func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.Co } svcName := sd.Service.PathName specs = append(specs, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, "client"), Name: sd.Service.PkgName + "c"}, + services.PackageImport(path.Join(genpkg, "grpc", svcName, "client")), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName))) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index 663807752e..49a80fd2dc 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -1,3 +1,5 @@ +// This file renders runnable gRPC client examples whose generated CLI and +// interceptor imports use the qualifiers selected during planning. package codegen import ( @@ -21,8 +23,8 @@ func ExampleCLIFiles(services *ServicesData) []*codegen.File { return files } -// exampleCLI returns an example client tool HTTP implementation for the given -// server expression. +// exampleCLI returns an example gRPC client tool for the given server +// expression. func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, services.Root) @@ -31,6 +33,7 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { return nil // file already exists, skip it. } rootPath := example.RootPath(genpkg) + cliImport := services.PackageImport(path.Join(genpkg, "grpc", "cli", svrdata.Dir)) specs := []*codegen.ImportSpec{ {Path: "context"}, @@ -43,16 +46,23 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { {Path: "time"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: rootPath + "/interceptors"}, - {Path: path.Join(genpkg, "grpc", "cli", svrdata.Dir), Name: "cli"}, + cliImport, } var svcData []*ServiceData + hasClientInterceptors := false for _, svc := range svr.Services { if data := services.Get(svc); data != nil { svcData = append(svcData, data) + hasClientInterceptors = hasClientInterceptors || len(data.Service.ClientInterceptors) > 0 } } + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(rootPath + "/interceptors") + interceptorsPkg = interceptorImport.Name + specs = append(specs, interceptorImport) + } sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), @@ -62,7 +72,8 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { Data: map[string]any{ "DefaultTransport": svrdata.DefaultTransport(), "Services": svcData, - "InterceptorsPkg": "interceptors", + "InterceptorsPkg": interceptorsPkg, + "CLIPkg": cliImport.Name, }, }, } diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index ed7a7abdbd..f0e39da93e 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -1,3 +1,6 @@ +// This file renders runnable gRPC servers whose generated service, transport, +// protobuf, and application imports use the qualifiers selected during +// planning. package codegen import ( @@ -34,8 +37,6 @@ func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { return nil // file already exists, skip it. } - var scope = codegen.NewNameScope() - specs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "fmt"}, @@ -51,24 +52,15 @@ func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { for _, svc := range services.Root.API.GRPC.Services { sd := services.Get(svc.Name()) svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, pbPkgName), - Name: scope.Unique(svcName + pbPkgName), - }) + serverImport := services.PackageImport(path.Join(genpkg, "grpc", svcName, "server")) + serviceImport := services.ServiceImport(svc.Name()) + protobufImport := services.PackageImport(path.Join(genpkg, "grpc", svcName, pbPkgName)) + specs = append(specs, serverImport, serviceImport, protobufImport) } rootPath := example.RootPath(genpkg) - apiPkg := example.APIPkg(services.Root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + apiImport := services.PackageImport(rootPath) + specs = append(specs, apiImport) var ( sections []*codegen.SectionTemplate diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go index 683f5c983f..6dd6f327d5 100644 --- a/grpc/codegen/plan.go +++ b/grpc/codegen/plan.go @@ -4,6 +4,7 @@ package codegen import ( "path" + "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -50,11 +51,25 @@ func Plan(generation *codegen.Generation) error { continue } for _, service := range design.API.GRPC.Services { - protobufName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) - protobufPath := path.Join(generation.GenPkg(), "grpc", protobufName, pbPkgName) - if err := generation.ReserveGeneratedImport(codegen.NewImport(protobufName+"pb", protobufPath)); err != nil { + pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) + packageName := strings.ToLower(codegen.Goify(service.Name(), false)) + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client"))); err != nil { return err } + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server"))); err != nil { + return err + } + if err := generation.ReserveGeneratedImport(codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName))); err != nil { + return err + } + } + if len(design.API.GRPC.Services) > 0 { + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "grpc", "cli", serverName))); err != nil { + return err + } + } } } return nil diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go new file mode 100644 index 0000000000..11ea81809d --- /dev/null +++ b/grpc/codegen/plan_test.go @@ -0,0 +1,46 @@ +// This file verifies gRPC planning reserves static and generated package +// imports before the generation catalog freezes. +package codegen + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPlanReservesGeneratedGRPCPackages verifies that client, server, +// protobuf, and CLI packages consume exact frozen import records. +func TestPlanReservesGeneratedGRPCPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foosvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { dsl.GRPC(func() {}) }) + }) + } + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) + + client := services.PackageImport("generated.local/gen/grpc/foo/client") + server := services.PackageImport("generated.local/gen/grpc/foo/server") + protobuf := services.PackageImport("generated.local/gen/grpc/foo/pb") + cli := services.PackageImport(path.Join( + "generated.local/gen/grpc/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + )) + require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) + require.NotEqual(t, services.ServiceImport("Foosvr").Name, server.Name) + require.NotEmpty(t, protobuf.Name) + require.NotEmpty(t, cli.Name) +} diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 514d26809b..2522c582c8 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -25,6 +25,12 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the frozen qualifier for the generated gRPC client + // package. + ClientPkgName string + // ServerPkgName is the frozen qualifier for the generated gRPC server + // package. + ServerPkgName string // PkgName is the name of the generated package in *.pb.go. PkgName string // ProtoImports is the list of proto package imports. @@ -510,6 +516,8 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { svcVarN := scope.HashedUnique(gs.ServiceExpr, codegen.Goify(svc.Name, true)) sd := &ServiceData{ Service: svc, + ClientPkgName: d.PackageImport(path.Join(d.GenPkg(), "grpc", svc.PathName, "client")).Name, + ServerPkgName: d.PackageImport(path.Join(d.GenPkg(), "grpc", svc.PathName, "server")).Name, Name: svcVarN, Description: svc.Description, PkgName: pkg, diff --git a/grpc/codegen/templates/do_grpc_cli.go.tpl b/grpc/codegen/templates/do_grpc_cli.go.tpl index 706280ddec..7dcf781ff3 100644 --- a/grpc/codegen/templates/do_grpc_cli.go.tpl +++ b/grpc/codegen/templates/do_grpc_cli.go.tpl @@ -8,7 +8,7 @@ func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { {{ .Service.VarName }}Interceptors := {{ $.InterceptorsPkg }}.New{{ .Service.StructName }}ClientInterceptors() {{- end }} {{- end }} - return cli.ParseEndpoint( + return {{ .CLIPkg }}.ParseEndpoint( conn, {{- range .Services }} {{- if .Service.ClientInterceptors }} @@ -20,10 +20,10 @@ func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { {{ if eq .DefaultTransport.Type "grpc" }} func grpcUsageCommands() []string { - return cli.UsageCommands() + return {{ .CLIPkg }}.UsageCommands() } func grpcUsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.UsageExamples() } {{- end }} diff --git a/grpc/codegen/templates/server_grpc_init.go.tpl b/grpc/codegen/templates/server_grpc_init.go.tpl index 439bf7bf43..6a5dcaf5f8 100644 --- a/grpc/codegen/templates/server_grpc_init.go.tpl +++ b/grpc/codegen/templates/server_grpc_init.go.tpl @@ -5,15 +5,15 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.Server {{- end }} ) { {{- range .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- end }} {{- end }} } diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index a391d06b41..ec81887a64 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -3,7 +3,7 @@ import ( cli "grpc/cli/test" "os" - "./interceptors" + interceptors "./interceptors" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index a64e6aed01..f98d578542 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -7,6 +7,7 @@ import ( "testing" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" @@ -44,6 +45,9 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err := Plan(generation); err != nil { panic(err) } + if err := example.Plan(generation); err != nil { + panic(err) + } if err := generation.Freeze(); err != nil { panic(err) } diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index b85b71a8d2..f6845eccba 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -55,7 +55,7 @@ func ClientCLIFiles(data *ServicesData) []*codegen.File { sd := data.Get(svc.Name()) if len(sd.Endpoints) > 0 { command := &commandData{ - CommandData: cli.BuildCommandData(sd.Service), + CommandData: cli.BuildCommandData(sd.Service, sd.ClientPkgName), NeedDialer: HasWebSocket(sd), JSONRPC: sd.Endpoints[0].IsJSONRPC, } @@ -133,7 +133,7 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa } specs = append(specs, &codegen.ImportSpec{ Path: genpkg + "/" + services.dir() + "/" + sd.Service.PathName + "/client", - Name: sd.Service.PkgName + "c", + Name: sd.ClientPkgName, }) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index 8821c59a92..8c513c479f 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -1,7 +1,11 @@ +// This file renders runnable HTTP and JSON-RPC client examples whose generated +// CLI, service, application, and interceptor imports use the qualifiers +// selected during planning. package codegen import ( "os" + "path" "path/filepath" "goa.design/goa/v3/codegen" @@ -26,8 +30,8 @@ func ExampleCLIFiles(services *ServicesData) []*codegen.File { func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, services.Root) - path := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") - if _, err := os.Stat(path); !os.IsNotExist(err) { + outputPath := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") + if _, err := os.Stat(outputPath); !os.IsNotExist(err) { return nil // file already exists, skip it. } funcSuffix := "HTTP" @@ -35,6 +39,7 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { funcSuffix = "JSONRPC" } rootPath := example.RootPath(genpkg) + cliImport := services.PackageImport(path.Join(genpkg, services.dir(), "cli", svrdata.Dir)) specs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "encoding/json"}, @@ -48,18 +53,24 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + services.dir() + "/cli/" + svrdata.Dir, Name: "cli"}, + cliImport, } - importScope := codegen.NewNameScope() + hasClientInterceptors := false for _, svc := range services.Root.Services { data := services.ServicesData.Get(svc.Name) - specs = append(specs, &codegen.ImportSpec{Path: genpkg + "/" + data.PkgName}) - importScope.Unique(data.PkgName) + serviceImport := services.ServiceImport(svc.Name) + specs = append(specs, serviceImport) + hasClientInterceptors = hasClientInterceptors || len(data.ClientInterceptors) > 0 } - interceptorsPkg := importScope.Unique("interceptors", "ex") - specs = append(specs, &codegen.ImportSpec{Path: rootPath + "/interceptors", Name: interceptorsPkg}) - apiPkg := example.APIPkg(services.Root, importScope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(rootPath + "/interceptors") + interceptorsPkg = interceptorImport.Name + specs = append(specs, interceptorImport) + } + apiImport := services.PackageImport(rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) var svcData []*ServiceData for _, svc := range svr.Services { @@ -94,6 +105,7 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { Data: map[string]any{ "Services": svcData, "APIPkg": apiPkg, + "CLIPkg": cliImport.Name, }, FuncMap: map[string]any{ "needDialer": NeedDialer, @@ -105,11 +117,12 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { Source: httpTemplates.Read(cliUsageT), Data: map[string]any{ "VarPrefix": services.dir(), + "CLIPkg": cliImport.Name, }, }, } return &codegen.File{ - Path: path, + Path: outputPath, SectionTemplates: sections, SkipExist: true, } diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index a490585061..b8ee84b8c9 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,12 +1,14 @@ // This file renders example HTTP server wiring and multipart stubs, attaching // relocated type imports only to the example file that references them. +// This file renders runnable HTTP servers and multipart helpers whose +// generated service, transport, and application imports use the qualifiers +// selected during planning. package codegen import ( "os" "path" "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" @@ -22,7 +24,7 @@ func ExampleServerFiles(data *ServicesData) []*codegen.File { } } for _, svc := range data.Expressions.Services { - if f := dummyMultipartFile(data.Root, svc, data); f != nil { + if f := dummyMultipartFile(svc, data); f != nil { fw = append(fw, f) } } @@ -50,24 +52,18 @@ func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services } specs = append(specs, baseSpecs...) - scope := codegen.NewNameScope() for _, svc := range root.API.HTTP.Services { sd := services.Get(svc.Name()) svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "http", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) + serverImport := services.PackageImport(path.Join(genpkg, "http", svcName, "server")) + serviceImport := services.ServiceImport(svc.Name()) + specs = append(specs, serverImport, serviceImport) } rootPath := example.RootPath(genpkg) - apiPkg := scope.Unique(strings.ToLower(codegen.Goify(services.Root.API.Name, false) + "api")) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + apiImport := services.PackageImport(rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) var svcdata []*ServiceData for _, svc := range svr.Services { @@ -131,7 +127,7 @@ func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services // dummyMultipartFile returns a dummy implementation of the multipart decoders // and encoders. -func dummyMultipartFile(root *expr.RootExpr, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { genpkg := services.GenPkg() mpath := "multipart.go" if _, err := os.Stat(mpath); !os.IsNotExist(err) { @@ -140,20 +136,7 @@ func dummyMultipartFile(root *expr.RootExpr, svc *expr.HTTPServiceExpr, services var ( sections []*codegen.SectionTemplate mustGen bool - - scope = codegen.NewNameScope() ) - // determine the unique API package name different from the service names - for _, httpSvc := range root.API.HTTP.Services { - s := services.Get(httpSvc.Name()) - if s == nil { - panic("unknown http service, " + httpSvc.Name()) // bug - } - if s.Service == nil { - panic("unknown service, " + httpSvc.Name()) // bug - } - scope.Unique(s.Service.PkgName) - } { specs := make([]*codegen.ImportSpec, 0, 2) specs = append(specs, &codegen.ImportSpec{Path: "mime/multipart"}) @@ -164,13 +147,10 @@ func dummyMultipartFile(root *expr.RootExpr, svc *expr.HTTPServiceExpr, services multipartEndpoints = append(multipartEndpoints, svc.Endpoint(endpoint.Method.Name)) } } - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, data.Service.PathName), - Name: scope.Unique(data.Service.PkgName, "svc"), - }) + specs = append(specs, services.ServiceImport(svc.Name())) specs = append(specs, services.AttributeImports(example.RootPath(genpkg), ServiceReferenceAttributes(multipartEndpoints...)...)...) - apiPkg := example.APIPkg(root, scope) + apiPkg := services.PackageImport(example.RootPath(genpkg)).Name sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} for _, e := range data.Endpoints { if e.MultipartRequestDecoder != nil { diff --git a/http/codegen/plan.go b/http/codegen/plan.go index 310d5d409c..8810f45936 100644 --- a/http/codegen/plan.go +++ b/http/codegen/plan.go @@ -3,7 +3,11 @@ package codegen import ( + "path" + "strings" + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" ) // Plan reserves every literal import qualifier used by HTTP render templates. @@ -42,5 +46,29 @@ func Plan(generation *codegen.Generation) error { return err } } + for _, root := range generation.Roots() { + design, ok := root.(*expr.RootExpr) + if !ok { + continue + } + for _, service := range design.API.HTTP.Services { + pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) + packageName := strings.ToLower(codegen.Goify(service.Name(), false)) + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "http", pathName, "client"))); err != nil { + return err + } + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "http", pathName, "server"))); err != nil { + return err + } + } + if len(design.API.HTTP.Services) > 0 { + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "http", "cli", serverName))); err != nil { + return err + } + } + } + } return nil } diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 19ea177c49..2f754cd3c9 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -3,6 +3,7 @@ package codegen import ( + "path" "testing" "github.com/stretchr/testify/require" @@ -36,3 +37,33 @@ func TestPlanRejectsFrozenGeneration(t *testing.T) { require.Error(t, Plan(generation)) } + +// TestPlanReservesGeneratedHTTPPackages verifies that client, server, and CLI +// packages receive aliases from the generation catalog before it freezes. +func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foosvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/" + name) }) + }) + }) + } + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) + + client := services.PackageImport("generated.local/gen/http/foo/client") + server := services.PackageImport("generated.local/gen/http/foo/server") + cli := services.PackageImport(path.Join( + "generated.local/gen/http/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + )) + require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) + require.NotEqual(t, services.ServiceImport("Foosvr").Name, server.Name) + require.NotEmpty(t, cli.Name) +} diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 82e400e5ae..6c9eabc0ae 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -50,6 +50,12 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the frozen qualifier for the generated transport + // client package. + ClientPkgName string + // ServerPkgName is the frozen qualifier for the generated transport + // server package. + ServerPkgName string // Endpoints describes the endpoint data for this service. Endpoints []*EndpointData // FileServers lists the file servers for this service. @@ -706,6 +712,8 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { scope.Unique(svc.PkgName) sd := &ServiceData{ Service: svc, + ClientPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client")).Name, + ServerPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name, ServerStruct: "Server", MountPointStruct: "MountPoint", ServerInit: "New", diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl index 808f04b9fc..2cfed6fd8e 100644 --- a/http/codegen/templates/cli_end.go.tpl +++ b/http/codegen/templates/cli_end.go.tpl @@ -1,4 +1,4 @@ -endpoint, payload, err := cli.ParseEndpoint( +endpoint, payload, err := {{ .CLIPkg }}.ParseEndpoint( scheme, host, doer, diff --git a/http/codegen/templates/cli_usage.go.tpl b/http/codegen/templates/cli_usage.go.tpl index 49b881eb34..7de9c399a7 100644 --- a/http/codegen/templates/cli_usage.go.tpl +++ b/http/codegen/templates/cli_usage.go.tpl @@ -1,8 +1,8 @@ func {{ .VarPrefix }}UsageCommands() []string { - return cli.UsageCommands() + return {{ .CLIPkg }}.UsageCommands() } func {{ .VarPrefix }}UsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.UsageExamples() } diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl index a65e268891..706f184af1 100644 --- a/http/codegen/templates/server_configure.go.tpl +++ b/http/codegen/templates/server_configure.go.tpl @@ -5,10 +5,10 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.Server {{- end }} {{- range .JSONRPCServices }} - {{ .Service.VarName }}JSONRPCServer *{{ .Service.PkgName }}jssvr.Server + {{ .Service.VarName }}JSONRPCServer *{{ .ServerPkgName }}.Server {{- end }} ) { @@ -18,23 +18,23 @@ {{- end }} {{- range $svc := .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncName }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncName }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) {{- end }} {{- end }} {{- range $svcData := .JSONRPCServices }} {{- if .Endpoints }} {{- $svc := . }} - {{ .Service.VarName }}JSONRPCServer = {{ .Service.PkgName }}jssvr.New({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) + {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.New({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) {{- end }} {{- end }} } // Configure the mux. {{- range .Services }} - {{ .Service.PkgName }}svr.Mount(mux, {{ .Service.VarName }}Server) + {{ .ServerPkgName }}.Mount(mux, {{ .Service.VarName }}Server) {{- end }} {{- range .JSONRPCServices }} - {{ .Service.PkgName }}jssvr.Mount(mux, {{ .Service.VarName }}JSONRPCServer) + {{ .ServerPkgName }}.Mount(mux, {{ .Service.VarName }}JSONRPCServer) {{- end }} diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 14181d7efa..7bb01518be 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -4,6 +4,7 @@ package codegen import ( "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" @@ -27,6 +28,9 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := Plan(generation); err != nil { panic(err) } + if err := example.Plan(generation); err != nil { + panic(err) + } if err := generation.Freeze(); err != nil { panic(err) } diff --git a/jsonrpc/codegen/example_server.go b/jsonrpc/codegen/example_server.go index 5f648aa9a5..e6cf14625b 100644 --- a/jsonrpc/codegen/example_server.go +++ b/jsonrpc/codegen/example_server.go @@ -1,3 +1,6 @@ +// This file augments runnable HTTP server examples with JSON-RPC mounts while +// preserving the generated service and transport aliases selected during +// planning. package codegen import ( @@ -21,6 +24,8 @@ func ExampleServerFiles(data *httpcodegen.ServicesData, files []*codegen.File) [ return fw } +// exampleServer adds a server's JSON-RPC imports and mount code to its shared +// HTTP example file. func exampleServer(data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { genpkg := data.GenPkg() svrdata := example.Servers.Get(svr, data.Root) @@ -40,20 +45,13 @@ func exampleServer(data *httpcodegen.ServicesData, svr *expr.ServerExpr, files [ file = httpcodegen.ExampleServer(data.Root, svr, data) } - // Add JSON-RPC imports to the HTTP server file + // Add JSON-RPC imports to the HTTP server file. header := file.SectionTemplates[0] - scope := codegen.NewNameScope() for _, svc := range data.Root.API.JSONRPC.Services { sd := data.Get(svc.Name()) svcName := sd.Service.PathName - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, "jsonrpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "jssvr"), - }) + codegen.AddImport(header, data.ServiceImport(svc.Name())) + codegen.AddImport(header, data.PackageImport(path.Join(genpkg, "jsonrpc", svcName, "server"))) } // Add JSON-RPC to the HTTP server file diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index e733c5a2ad..607b29459b 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -11,11 +11,13 @@ import ( "github.com/stretchr/testify/require" goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/generator" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" "goa.design/goa/v3/jsonrpc/codegen/testdata" ) @@ -33,6 +35,8 @@ func TestJSONRPCKitchenSink(t *testing.T) { roots := []eval.Root{root} generation := goacodegen.NewGeneration("kitchensink", roots) require.NoError(t, service.Plan(root, generation)) + require.NoError(t, jsonrpccodegen.Plan(generation)) + require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) tfiles, err := generator.Transport(generation) diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go index 8650cef60d..1ec6f21256 100644 --- a/jsonrpc/codegen/plan.go +++ b/jsonrpc/codegen/plan.go @@ -3,7 +3,11 @@ package codegen import ( + "path" + "strings" + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) @@ -39,5 +43,29 @@ func Plan(generation *codegen.Generation) error { return err } } + for _, root := range generation.Roots() { + design, ok := root.(*expr.RootExpr) + if !ok { + continue + } + for _, service := range design.API.JSONRPC.Services { + pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) + packageName := strings.ToLower(codegen.Goify(service.Name(), false)) + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "jsonrpc", pathName, "client"))); err != nil { + return err + } + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"jssvr", path.Join(generation.GenPkg(), "jsonrpc", pathName, "server"))); err != nil { + return err + } + } + if len(design.API.JSONRPC.Services) > 0 { + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "jsonrpc", "cli", serverName))); err != nil { + return err + } + } + } + } return nil } diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go index 3c99bf9feb..d6e1732042 100644 --- a/jsonrpc/codegen/plan_test.go +++ b/jsonrpc/codegen/plan_test.go @@ -3,6 +3,7 @@ package codegen import ( + "path" "testing" "github.com/stretchr/testify/require" @@ -31,3 +32,31 @@ func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { require.Equal(t, "uuid2", services.ServiceImport("UUID").Name) } + +// TestPlanReservesGeneratedJSONRPCPackages verifies that the JSON-RPC client, +// server, and CLI imports are frozen by their complete generated paths. +func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foojssvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + } + }) + generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, Plan(generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation) + require.NoError(t, err) + + client := services.PackageImport("generated.local/gen/jsonrpc/foo/client") + server := services.PackageImport("generated.local/gen/jsonrpc/foo/server") + cli := services.PackageImport(path.Join( + "generated.local/gen/jsonrpc/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + )) + require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) + require.NotEqual(t, services.ServiceImport("Foojssvr").Name, server.Name) + require.Equal(t, "cli", cli.Name) +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden index 00e4e3bdde..774a6c155b 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden @@ -2,7 +2,7 @@ package main import ( "fmt" - cli "kitchensink/jsonrpc/cli/kitchen_sink" + cli2 "kitchensink/jsonrpc/cli/kitchen_sink" "net/http" "time" @@ -29,7 +29,7 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, dialer = websocket.DefaultDialer } - endpoint, payload, err := cli.ParseEndpoint( + endpoint, payload, err := cli2.ParseEndpoint( scheme, host, doer, @@ -46,9 +46,9 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, } func jsonrpcUsageCommands() []string { - return cli.UsageCommands() + return cli2.UsageCommands() } func jsonrpcUsageExamples() string { - return cli.UsageExamples() + return cli2.UsageExamples() } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index 79aaaa6d12..c9fbe6d6ae 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -13,7 +13,7 @@ import ( calcc "kitchensink/jsonrpc/calc/client" chatc "kitchensink/jsonrpc/chat/client" feedc "kitchensink/jsonrpc/feed/client" - mixedc "kitchensink/jsonrpc/mixed/client" + mixedc2 "kitchensink/jsonrpc/mixed/client" "net/http" "os" @@ -212,11 +212,11 @@ func ParseEndpoint( data, err = feedc.BuildWatchPayload(*feedWatchBodyFlag) } case "mixed": - c := mixedc.NewClient(scheme, host, doer, enc, dec, restore) + c := mixedc2.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "lookup": endpoint = c.Lookup() - data, err = mixedc.BuildLookupPayload(*mixedLookupBodyFlag) + data, err = mixedc2.BuildLookupPayload(*mixedLookupBodyFlag) } } } From 393e910260c35a0e9091bdfa26a6a5c8213f9740 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 06:39:19 -0700 Subject: [PATCH 24/43] codegen: catalog protobuf wire declarations --- .../oneof_anonymous_user_union_test.go | 3 +- grpc/codegen/protobuf.go | 112 ++- grpc/codegen/protobuf_catalog.go | 835 ++++++++++++++++++ grpc/codegen/protobuf_transform_test.go | 17 +- grpc/codegen/service_data.go | 530 +++++------ grpc/codegen/service_data_traversal_test.go | 241 ++++- ...ection-to-result-type-collection.go.golden | 2 +- 7 files changed, 1388 insertions(+), 352 deletions(-) create mode 100644 grpc/codegen/protobuf_catalog.go diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 48c90daac6..2f48be2376 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -47,11 +47,12 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { sd := &ServiceData{Name: "Svc", Scope: codegen.NewNameScope()} svcCtx := codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) - pbCtx := protoBufTypeContext("proto", sd.Scope, true) // Transform protobuf -> Go for Container target := &expr.AttributeExpr{Type: root.UserType("Container")} source := makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) + freezeProtoBufTransformMessages(sd, source) + pbCtx := protoBufTypeContext("proto", sd, true) code, _, err := protoBufTransform(source, target, "source", "target", pbCtx, svcCtx, false, true) require.NoError(t, err) diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index 0726d3a95a..f29f191257 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -17,8 +17,8 @@ import ( type ( // protoBufScope is the scope for protocol buffer attribute types. protoBufScope struct { - scope *codegen.NameScope - pkg string + service *ServiceData + pkg string } ) @@ -36,12 +36,12 @@ const ( // Name returns the protocol buffer type name. func (p *protoBufScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { - return protoBufGoFullTypeName(att, pkg, p.scope) + return protoBufGoFullTypeName(att, pkg, p.service) } // Ref returns the protocol buffer type reference. func (p *protoBufScope) Ref(att *expr.AttributeExpr, pkg string) string { - return protoBufGoFullTypeRef(att, pkg, p.scope) + return protoBufGoFullTypeRef(att, pkg, p.service) } // Package returns the protocol buffer package qualifier for att. @@ -69,13 +69,13 @@ func (*protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper boo // Scope returns the name scope. func (p *protoBufScope) Scope() *codegen.NameScope { - return p.scope + return p.service.Scope } // protoBufTypeContext returns a contextual attribute for the protocol buffer type. -func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) *codegen.AttributeContext { - ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, scope) - ctx.Scope = &protoBufScope{scope: scope, pkg: pkg} +func protoBufTypeContext(pkg string, service *ServiceData, useDefault bool) *codegen.AttributeContext { + ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, service.Scope) + ctx.Scope = &protoBufScope{service: service, pkg: pkg} return ctx } @@ -135,12 +135,12 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceDat switch { case expr.IsArray(att.Type): wrapAttr(att, "ArrayOf"+tname+ - protoBufify(protoBufMessageDef(expr.AsArray(att.Type).ElemType, sd), true, true), true, sd) + protoBufify(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType), true, true), true, sd) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) wrapAttr(att, tname+"MapOf"+ - protoBufify(protoBufMessageDef(m.KeyType, sd), true, true)+ - protoBufify(protoBufMessageDef(m.ElemType, sd), true, true), true, sd) + protoBufify(protoBufShapeTypeName(m.KeyType), true, true)+ + protoBufify(protoBufShapeTypeName(m.ElemType), true, true), true, sd) } } @@ -254,40 +254,44 @@ func unwrapAttr(att *expr.AttributeExpr) *expr.AttributeExpr { // protoBufMessageName returns the protocol buffer message name of the given // attribute type. -func protoBufMessageName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufFullMessageName(att, "", s) +func protoBufMessageName(att *expr.AttributeExpr, service *ServiceData) string { + return protoBufFullMessageName(att, "", service) } // protoBufFullMessageName returns the protocol buffer message name of the // given user type qualified with the given package name if applicable. -func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { switch actual := att.Type.(type) { - case expr.UserType, *expr.Union: - n := s.HashedUnique(actual, protoBufify(actual.Name(), true, true), "") - if name := att.Meta["struct:name:proto"]; len(name) > 0 { - n = name[0] + case expr.UserType: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf message %q has no package catalog", actual.Name())) + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no frozen declaration", actual.Name())) } + n := record.name if pkg == "" { return n } return pkg + "." + n case expr.CompositeExpr: - return protoBufFullMessageName(actual.Attribute(), pkg, s) + return protoBufFullMessageName(actual.Attribute(), pkg, service) default: - panic(fmt.Sprintf("data type is not a user type or union: received type %T", actual)) // bug + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", actual)) // bug } } // protoBufGoTypeName returns the protocol buffer type name for the given // attribute generated after compiling the proto file (in *.pb.go). -func protoBufGoTypeName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufGoFullTypeName(att, "", s) +func protoBufGoTypeName(att *expr.AttributeExpr, service *ServiceData) string { + return protoBufGoFullTypeName(att, "", service) } // protoBufGoFullTypeName returns the protocol buffer type name qualified with // the given package name for the given attribute generated after compiling // the proto file (in *.pb.go). -func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { if proto := att.Meta["struct:field:proto"]; len(proto) > 2 { typ := proto[2] if len(att.Meta["struct:field:proto"]) > 3 { @@ -296,24 +300,68 @@ func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.Name } return typ } + if primitive := getPrimitive(att); primitive != nil { + return protoBufGoFullTypeName(primitive, pkg, service) + } switch actual := att.Type.(type) { - case expr.UserType, expr.CompositeExpr, *expr.Union: - return protoBufFullMessageName(att, pkg, s) + case *expr.Union: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf oneof %q has no package catalog", actual.Name())) + } + name := service.protobuf.unionName(att) + if pkg == "" { + return name + } + return pkg + "." + name + case expr.UserType, expr.CompositeExpr: + return protoBufFullMessageName(att, pkg, service) case expr.Primitive: return protoBufNativeGoTypeName(att.Type) case *expr.Array: - return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, s) + return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, service) case *expr.Map: return fmt.Sprintf("map[%s]%s", - protoBufGoFullTypeRef(actual.KeyType, pkg, s), - protoBufGoFullTypeRef(actual.ElemType, pkg, s)) + protoBufGoFullTypeRef(actual.KeyType, pkg, service), + protoBufGoFullTypeRef(actual.ElemType, pkg, service)) case *expr.Object: - return s.GoTypeDef(att, false, false) + return service.Scope.GoTypeDef(att, false, false) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } } +// protoBufShapeTypeName returns the stable type fragment used while wrapping +// nested collection values before the package declaration catalog is frozen. +// It reads authored type facts but never allocates an emitted message name. +func protoBufShapeTypeName(att *expr.AttributeExpr) string { + if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { + return protos[0] + } + switch actual := att.Type.(type) { + case expr.Primitive: + return protoNativeType(actual) + case expr.UserType: + if names := att.Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + if names := actual.Attribute().Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + return protoBufify(actual.Name(), true, true) + case expr.CompositeExpr: + return protoBufShapeTypeName(actual.Attribute()) + case *expr.Object: + return "Object" + case *expr.Union: + if actual.TypeName != "" { + return protoBufify(actual.TypeName, true, true) + } + return "Union" + default: + panic(fmt.Sprintf("unknown protobuf shaping type %T", actual)) // bug + } +} + // protoType returns the protocol buffer type name for the given attribute. func protoType(att *expr.AttributeExpr, sd *ServiceData) string { if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { @@ -370,7 +418,7 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { if prim := getPrimitive(att); prim != nil { return protoBufMessageDef(prim, sd) } - return protoBufMessageName(att, sd.Scope) + return protoBufMessageName(att, sd) case *expr.Object: var ss []string ss = append(ss, " {") @@ -424,8 +472,8 @@ func protoJSONOption(att *expr.AttributeExpr) string { // protoBufGoFullTypeRef returns the Go code qualified with package name that // refers to the Go type generated by compiling the protocol buffer // (in *.pb.go) for the given attribute. -func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { - name := protoBufGoFullTypeName(att, pkg, s) +func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, service *ServiceData) string { + name := protoBufGoFullTypeName(att, pkg, service) if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { return "*" + name } diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go new file mode 100644 index 0000000000..0242ab52ac --- /dev/null +++ b/grpc/codegen/protobuf_catalog.go @@ -0,0 +1,835 @@ +// This file owns the protobuf declarations and validation helpers emitted by +// one generated gRPC protobuf package. It separates declaration identity from +// traversal identity and freezes names before conversion data refers to them. +package codegen + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // protobufPackageCatalog owns every protobuf message and validator emitted + // into one generated protobuf package. + protobufPackageCatalog struct { + packageName string + messages []*protobufMessageRecord + messageUses map[*expr.AttributeExpr]*protobufMessageRecord + unions []*protobufUnionRecord + unionUses map[*expr.AttributeExpr]*protobufUnionRecord + syntheticSources map[expr.UserType]protobufMessageSource + reservedNames []string + validators []*protobufValidationRecord + frozen bool + messagesRendered bool + validationsFrozen bool + } + + // protobufEndpointMessages contains every detached protobuf-shaped value for + // one endpoint before conversion and render records are built. + protobufEndpointMessages struct { + request *expr.AttributeExpr + streamingRequest *expr.AttributeExpr + requestEnvelope *expr.AttributeExpr + response *expr.AttributeExpr + errors map[string]*expr.AttributeExpr + } + + // protobufMessageRecord is the canonical declaration selected for one typed + // protobuf wire contract. + protobufMessageRecord struct { + identity protobufMessageIdentity + uses []*expr.AttributeExpr + name string + goRef string + data *service.UserTypeData + } + + // protobufMessageIdentity contains the source declaration and the wire facts + // that can change the emitted protobuf message. + protobufMessageIdentity struct { + source protobufMessageSource + preferredName string + explicitName bool + userType expr.UserType + attribute *expr.AttributeExpr + } + + // protobufUnionRecord identifies one oneof declaration nested in an owning + // protobuf message. Generated transformation helpers use this typed owner + // instead of allocating a name during lookup. + protobufUnionRecord struct { + owner *protobufMessageRecord + attribute *expr.AttributeExpr + fieldName string + uses []*expr.AttributeExpr + name string + } + + // protobufMessageSource identifies either an authored declaration or a + // synthetic endpoint message whose declaration does not exist in the design. + protobufMessageSource struct { + origin expr.UserType + synthetic protobufSyntheticMessage + } + + // protobufSyntheticMessage identifies a compiler-created endpoint message. + protobufSyntheticMessage struct { + endpoint *expr.GRPCEndpointExpr + error *expr.GRPCErrorExpr + role protobufSyntheticRole + } + + // protobufSyntheticRole identifies which endpoint wire value a synthetic + // protobuf message represents. + protobufSyntheticRole uint8 + + // protobufValidationRecord is the canonical validation helper emitted in one + // generated client or server package. + protobufValidationRecord struct { + declaration *protobufMessageRecord + attribute *expr.AttributeExpr + side validateKind + targetName string + contextName string + uses []*expr.AttributeExpr + name string + data *ValidationData + } + + // protobufAttributePair breaks cycles while comparing two typed wire or + // validation graphs. + protobufAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } + + // protobufValidationScope resolves nested validation calls through frozen + // validator records while delegating fields and type references to protobuf. + protobufValidationScope struct { + *protoBufScope + catalog *protobufPackageCatalog + side validateKind + } +) + +const ( + protobufRequestMessage protobufSyntheticRole = iota + 1 + protobufStreamingRequestMessage + protobufStreamEnvelopeMessage + protobufResponseMessage + protobufErrorMessage + protobufWrapperMessage +) + +// newProtobufPackageCatalog constructs the declaration owner for one actual +// generated protobuf package. +func newProtobufPackageCatalog(packageName string) *protobufPackageCatalog { + return &protobufPackageCatalog{ + packageName: packageName, + messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), + unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), + syntheticSources: make(map[expr.UserType]protobufMessageSource), + } +} + +// reserveName prevents message declarations from colliding with another +// package-level protobuf declaration such as the service interface. +func (c *protobufPackageCatalog) reserveName(name string) { + if c.frozen { + panic("cannot reserve a protobuf package name after the catalog freezes") + } + c.reservedNames = append(c.reservedNames, name) +} + +// bindSyntheticSource associates a shaped synthetic declaration and all of +// its later copies with the endpoint role that created it. +func (c *protobufPackageCatalog) bindSyntheticSource(attribute *expr.AttributeExpr, source protobufMessageSource) { + userType, ok := attribute.Type.(expr.UserType) + if !ok || source.synthetic.role == 0 { + return + } + c.syntheticSources[userType.Origin()] = source +} + +// collectMessage records every protobuf declaration reachable from attribute. +// source identifies a synthetic root; nested authored declarations retain +// their own origins. +func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource, sd *ServiceData) []string { + if c.frozen { + panic("cannot collect a protobuf message after the package catalog is frozen") + } + return c.collectMessageRecursive(attribute, source, true, nil, "", sd) +} + +// freezeMessages assigns every declaration its final protobuf and generated Go +// names, binds all occurrences, and builds immutable template records. +func (c *protobufPackageCatalog) freezeMessages(sd *ServiceData) []*service.UserTypeData { + c.freezeMessageNames() + if c.messagesRendered { + return c.messageData() + } + c.messagesRendered = true + for _, record := range c.messages { + identity := record.identity + userType := identity.userType + definition := protoBufMessageDef(userTypeAttribute(userType), sd) + for _, use := range record.uses[1:] { + other := protoBufMessageDef(userTypeAttribute(use.Type.(expr.UserType)), sd) + if other != definition { + panic(fmt.Sprintf("protobuf declaration %q has one typed identity but different wire definitions", record.name)) + } + } + record.data = &service.UserTypeData{ + Name: record.name, + VarName: record.name, + Description: userType.Attribute().Description, + Def: definition, + Ref: record.goRef, + Type: userType, + } + } + return c.messageData() +} + +// freezeMessageNames assigns every declaration its final package-level name +// without rendering .proto definitions. Transformation-only consumers use +// this phase because they need references but do not emit declarations. +func (c *protobufPackageCatalog) freezeMessageNames() { + if c.frozen { + return + } + c.frozen = true + used := make(map[string]struct{}, len(c.messages)) + counts := make(map[string]int, len(c.messages)) + for _, name := range c.reservedNames { + used[name] = struct{}{} + counts[name] = 1 + } + for _, record := range c.messages { + record.name = uniqueProtobufName(record.identity.preferredName, used, counts) + record.goRef = "*" + c.packageName + "." + record.name + } + for _, record := range c.unions { + record.name = record.owner.name + "_" + protoBufify(record.fieldName, true, true) + } +} + +// collectValidation records the validation helper needed for attribute and all +// nested protobuf message declarations on one generated side. +func (c *protobufPackageCatalog) collectValidation(attribute *expr.AttributeExpr, side validateKind, targetName, contextName string) { + if !c.frozen { + panic("cannot collect protobuf validators before message declarations freeze") + } + if c.validationsFrozen { + panic("cannot collect a protobuf validator after validators freeze") + } + c.collectValidationRecursive(attribute, side, targetName, contextName, make(map[*protobufValidationRecord]struct{})) +} + +// freezeValidations assigns helper names independently in the generated client +// and server packages, then renders definitions through those frozen records. +func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*ValidationData { + if c.validationsFrozen { + return c.validationData() + } + c.validationsFrozen = true + used := map[validateKind]map[string]struct{}{ + validateServer: {}, + validateClient: {}, + } + counts := map[validateKind]map[string]int{ + validateServer: {}, + validateClient: {}, + } + for _, record := range c.validators { + base := "Validate" + record.declaration.name + record.name = uniqueProtobufName(base, used[record.side], counts[record.side]) + } + for _, record := range c.validators { + validationAttribute := expr.DupAtt(record.attribute) + c.bindEquivalentMessageUses(record.attribute, validationAttribute, make(map[protobufAttributePair]struct{})) + removeMeta(validationAttribute) + userType := validationAttribute.Type.(expr.UserType) + context := protoBufTypeContext(c.packageName, sd, false) + context.Scope = &protobufValidationScope{ + protoBufScope: context.Scope.(*protoBufScope), + catalog: c, + side: record.side, + } + definition := codegen.AttributeValidationCode( + userTypeAttribute(userType), + userType, + context, + true, + false, + record.targetName, + record.contextName, + ) + if definition == "" { + continue + } + record.data = &ValidationData{ + Name: record.name, + Def: definition, + ArgName: record.targetName, + SrcName: record.declaration.name, + SrcRef: record.declaration.goRef, + Kind: record.side, + } + } + return c.validationData() +} + +// message returns the frozen declaration bound to attribute. +func (c *protobufPackageCatalog) message(attribute *expr.AttributeExpr) *protobufMessageRecord { + if !c.frozen { + panic("cannot resolve a protobuf message before the package catalog freezes") + } + if record := c.messageUses[attribute]; record != nil { + return record + } + if _, ok := attribute.Type.(expr.UserType); !ok { + return nil + } + userType := attribute.Type.(expr.UserType) + source := protobufMessageSource{origin: userType.Origin()} + if synthetic, ok := c.syntheticSources[userType.Origin()]; ok { + source = synthetic + } + identity := protobufMessageIdentityFor(attribute, source) + for _, record := range c.messages { + if sameProtobufMessageIdentity(record.identity, identity) { + return record + } + } + if len(userType.Attribute().Meta[wrappedAttrMeta]) > 0 { + identity.source = protobufMessageSource{synthetic: protobufSyntheticMessage{role: protobufWrapperMessage}} + for _, record := range c.messages { + if sameProtobufMessageIdentity(record.identity, identity) { + return record + } + } + } + return nil +} + +// unionName returns the frozen helper identity for one oneof declaration. +func (c *protobufPackageCatalog) unionName(attribute *expr.AttributeExpr) string { + if !c.frozen { + panic("cannot resolve a protobuf oneof before the package catalog freezes") + } + record := c.unionUses[attribute] + if record == nil { + for _, candidate := range c.unions { + if !sameProtobufWireAttribute(candidate.attribute, attribute, make(map[protobufAttributePair]struct{})) { + continue + } + if record != nil && record != candidate { + panic(fmt.Sprintf("protobuf oneof %q matches multiple frozen declarations", attribute.Type.Name())) + } + record = candidate + } + } + if record == nil { + panic(fmt.Sprintf("protobuf oneof %q has no frozen declaration", attribute.Type.Name())) + } + return record.name +} + +// validation returns the frozen validator bound to attribute on side. +func (c *protobufPackageCatalog) validation(attribute *expr.AttributeExpr, side validateKind) *ValidationData { + if !c.validationsFrozen { + panic("cannot resolve a protobuf validator before validators freeze") + } + declaration := c.message(attribute) + if declaration == nil { + return nil + } + for _, record := range c.validators { + if record.side == side && record.declaration == declaration && + sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record.data + } + } + return nil +} + +// Name returns the frozen protobuf declaration name, or the validator-specific +// source name while validation code is being rendered. +func (s *protobufValidationScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + if validator := s.catalog.validationRecord(attribute, s.side); validator != nil { + return strings.TrimPrefix(validator.name, "Validate") + } + return s.protoBufScope.Name(attribute, pkg, pointer, useDefault) +} + +// collectMessageRecursive gathers imports and declarations while using record +// identity itself as the cycle guard. +func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string, sd *ServiceData) []string { + if attribute == nil { + return nil + } + imports := protobufAttributeImports(attribute, sd) + if expr.IsPrimitive(attribute.Type) { + if attribute.Type.Kind() == expr.AnyKind { + imports = append(imports, "google/protobuf/struct.proto") + } + return imports + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + identitySource := protobufMessageSource{origin: origin} + if synthetic, ok := c.syntheticSources[origin]; ok { + identitySource = synthetic + } + if !root && len(actual.Attribute().Meta[wrappedAttrMeta]) > 0 { + identitySource = protobufMessageSource{synthetic: protobufSyntheticMessage{ + role: protobufWrapperMessage, + }} + } + if root && source.synthetic.role != 0 { + identitySource = source + c.syntheticSources[origin] = source + } + identity := protobufMessageIdentityFor(attribute, identitySource) + record := c.findMessage(identity) + if record != nil { + record.uses = append(record.uses, attribute) + c.messageUses[attribute] = record + c.bindEquivalentMessageUses(record.uses[0], attribute, make(map[protobufAttributePair]struct{})) + return imports + } + record = &protobufMessageRecord{identity: identity, uses: []*expr.AttributeExpr{attribute}} + c.messages = append(c.messages, record) + c.messageUses[attribute] = record + imports = append(imports, c.collectMessageRecursive(userTypeAttribute(actual), protobufMessageSource{}, false, record, "", sd)...) + case *expr.Object: + for _, named := range *actual { + imports = append(imports, c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, named.Name, sd)...) + } + case *expr.Array: + imports = append(imports, c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem", sd)...) + case *expr.Map: + imports = append(imports, c.collectMessageRecursive(actual.KeyType, protobufMessageSource{}, false, owner, fieldName+"Key", sd)...) + imports = append(imports, c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem", sd)...) + case *expr.Union: + if owner == nil { + panic(fmt.Sprintf("protobuf oneof %q has no owning message", actual.Name())) + } + if fieldName == "" { + fieldName = actual.Name() + } + record := c.findUnion(owner, fieldName, attribute) + if record == nil { + record = &protobufUnionRecord{ + owner: owner, + attribute: attribute, + fieldName: fieldName, + } + c.unions = append(c.unions, record) + } + record.uses = append(record.uses, attribute) + c.unionUses[attribute] = record + for _, named := range actual.Values { + imports = append(imports, c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, fieldName+named.Name, sd)...) + } + } + return imports +} + +// bindEquivalentMessageUses associates every nested declaration occurrence in +// a reused wire graph with the canonical records already collected for the +// first occurrence. +func (c *protobufPackageCatalog) bindEquivalentMessageUses(canonical, duplicate *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) { + pair := protobufAttributePair{left: canonical, right: duplicate} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + switch canonicalType := canonical.Type.(type) { + case expr.UserType: + if record := c.messageUses[canonical]; record != nil { + if c.messageUses[duplicate] == nil { + c.messageUses[duplicate] = record + record.uses = append(record.uses, duplicate) + } + } + duplicateType := duplicate.Type.(expr.UserType) + c.bindEquivalentMessageUses(userTypeAttribute(canonicalType), userTypeAttribute(duplicateType), seen) + case *expr.Object: + duplicateType := duplicate.Type.(*expr.Object) + for index, named := range *canonicalType { + c.bindEquivalentMessageUses(named.Attribute, (*duplicateType)[index].Attribute, seen) + } + case *expr.Array: + c.bindEquivalentMessageUses(canonicalType.ElemType, duplicate.Type.(*expr.Array).ElemType, seen) + case *expr.Map: + duplicateType := duplicate.Type.(*expr.Map) + c.bindEquivalentMessageUses(canonicalType.KeyType, duplicateType.KeyType, seen) + c.bindEquivalentMessageUses(canonicalType.ElemType, duplicateType.ElemType, seen) + case *expr.Union: + if record := c.unionUses[canonical]; record != nil { + c.unionUses[duplicate] = record + record.uses = append(record.uses, duplicate) + } + duplicateType := duplicate.Type.(*expr.Union) + for index, named := range canonicalType.Values { + c.bindEquivalentMessageUses(named.Attribute, duplicateType.Values[index].Attribute, seen) + } + } +} + +// findUnion returns the oneof declaration with the same owning message, field, +// and typed wire schema. +func (c *protobufPackageCatalog) findUnion(owner *protobufMessageRecord, fieldName string, attribute *expr.AttributeExpr) *protobufUnionRecord { + for _, record := range c.unions { + if record.owner == owner && record.fieldName == fieldName && + sameProtobufWireAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record + } + } + return nil +} + +// collectValidationRecursive declares one validator per message, rule graph, +// and generated side, then descends to the nested declarations it may call. +func (c *protobufPackageCatalog) collectValidationRecursive(attribute *expr.AttributeExpr, side validateKind, targetName, contextName string, seen map[*protobufValidationRecord]struct{}) { + switch actual := attribute.Type.(type) { + case expr.UserType: + if expr.IsPrimitive(actual) { + return + } + declaration := c.message(attribute) + if declaration == nil { + panic(fmt.Sprintf("no protobuf declaration collected for validation type %q", actual.Name())) + } + record := c.findValidation(declaration, attribute, side) + if record == nil { + record = &protobufValidationRecord{ + declaration: declaration, + attribute: attribute, + side: side, + targetName: targetName, + contextName: contextName, + uses: []*expr.AttributeExpr{attribute}, + } + c.validators = append(c.validators, record) + } else { + record.uses = append(record.uses, attribute) + } + if _, ok := seen[record]; ok { + return + } + seen[record] = struct{}{} + c.collectValidationRecursive(userTypeAttribute(actual), side, targetName, contextName, seen) + case *expr.Object: + for _, named := range *actual { + c.collectValidationRecursive(named.Attribute, side, codegen.Goify(named.Name, false), named.Name, seen) + } + case *expr.Array: + c.collectValidationRecursive(actual.ElemType, side, "elem", "elem", seen) + case *expr.Map: + c.collectValidationRecursive(actual.KeyType, side, "key", "key", seen) + c.collectValidationRecursive(actual.ElemType, side, "val", "val", seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectValidationRecursive(named.Attribute, side, codegen.Goify(named.Name, false), named.Name, seen) + } + } +} + +// findMessage returns the existing declaration with the same typed identity. +func (c *protobufPackageCatalog) findMessage(identity protobufMessageIdentity) *protobufMessageRecord { + for _, record := range c.messages { + if sameProtobufMessageIdentity(record.identity, identity) { + return record + } + } + return nil +} + +// findValidation returns the existing validator with the same declaration, +// validation contract, and generated side. +func (c *protobufPackageCatalog) findValidation(declaration *protobufMessageRecord, attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { + for _, record := range c.validators { + if record.declaration == declaration && record.side == side && + sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record + } + } + return nil +} + +// validationRecord resolves the validator called for a nested message use. +func (c *protobufPackageCatalog) validationRecord(attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { + declaration := c.message(attribute) + if declaration == nil { + return nil + } + return c.findValidation(declaration, attribute, side) +} + +// messageData returns only declarations with completed immutable render data. +func (c *protobufPackageCatalog) messageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// validationData returns only validators whose typed rules emit code. +func (c *protobufPackageCatalog) validationData() []*ValidationData { + data := make([]*ValidationData, 0, len(c.validators)) + for _, record := range c.validators { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// protobufMessageIdentityFor derives message identity without consulting a +// naming scope or rendered source. +func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMessageSource) protobufMessageIdentity { + userType := attribute.Type.(expr.UserType) + if source.origin == nil && source.synthetic.role == 0 { + source.origin = userType.Origin() + } + preferred := protoBufify(userType.Name(), true, true) + explicit := false + names := attribute.Meta["struct:name:proto"] + if len(names) == 0 { + names = userType.Attribute().Meta["struct:name:proto"] + } + if len(names) > 0 { + preferred = names[0] + explicit = true + } + return protobufMessageIdentity{ + source: source, + preferredName: preferred, + explicitName: explicit, + userType: userType, + attribute: userTypeAttribute(userType), + } +} + +// sameProtobufMessageIdentity compares the typed source and every protobuf +// schema fact rather than expression hashes or generated names. +func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { + if left.preferredName != right.preferredName || left.explicitName != right.explicitName { + return false + } + if left.source != right.source { + // An explicit protobuf name declares intentional reuse across endpoint + // roles or authored origins when the complete wire schemas also match. + if !left.explicitName { + return false + } + } + return sameProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// sameProtobufWireAttribute compares facts that affect a protobuf declaration. +func sameProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || left.Description != right.Description { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !sameProtobufMeta(left.Meta, right.Meta) { + return false + } + if leftObject, rightObject := expr.AsObject(left.Type), expr.AsObject(right.Type); leftObject != nil && rightObject != nil { + for _, named := range *leftObject { + if expr.IsPrimitive(named.Attribute.Type) && left.IsRequired(named.Name) != right.IsRequired(named.Name) { + return false + } + } + } + return sameProtobufWireType(left.Type, right.Type, seen) +} + +// sameProtobufWireType compares protobuf-native type, ordered field, oneof, +// collection, and nested declaration contracts. +func sameProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) bool { + if left.Kind() != right.Kind() { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case expr.UserType: + right := right.(expr.UserType) + sameSource := left.Origin() == right.Origin() + bothSyntheticWrappers := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 && + len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + return (sameSource || bothSyntheticWrappers) && sameProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.(*expr.Array) + return sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + return sameProtobufWireAttribute(left.KeyType, right.KeyType, seen) && sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// sameProtobufValidationAttribute compares typed validation provenance and +// rules independently from protobuf wire declaration identity. +func sameProtobufValidationAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || !reflect.DeepEqual(left.Validation, right.Validation) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || + !reflect.DeepEqual(left.Meta["struct:field:type"], right.Meta["struct:field:type"]) { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if left.Type.Kind() != right.Type.Kind() { + return false + } + switch left := left.Type.(type) { + case expr.Primitive: + return left == right.Type.(expr.Primitive) + case expr.UserType: + right := right.Type.(expr.UserType) + return left.Origin() == right.Origin() && sameProtobufValidationAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.Type.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.Type.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.Type.(*expr.Map) + return sameProtobufValidationAttribute(left.KeyType, right.KeyType, seen) && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.Type.(*expr.Union) + if len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf validation type %T", left)) + } +} + +// protobufAttributeImports returns imports declared directly on attribute. +func protobufAttributeImports(attribute *expr.AttributeExpr, sd *ServiceData) []string { + proto := attribute.Meta["struct:field:proto"] + if len(proto) <= 1 { + return nil + } + protobufImport := proto[1] + for _, spec := range sd.Service.ProtoImports { + if spec.Path == protobufImport { + return nil + } + } + if len(proto) > 3 { + elements := strings.Split(proto[3], "/") + sd.Service.ProtoImports = append(sd.Service.ProtoImports, &codegen.ImportSpec{ + Path: proto[3], + Name: elements[len(elements)-1], + }) + } + return []string{protobufImport} +} + +// uniqueProtobufName reserves one deterministic package-level identifier. +func uniqueProtobufName(base string, used map[string]struct{}, counts map[string]int) string { + if _, ok := used[base]; !ok { + used[base] = struct{}{} + counts[base] = 1 + return base + } + for index := counts[base] + 1; ; index++ { + candidate := base + strconv.Itoa(index) + if _, ok := used[candidate]; ok { + continue + } + used[candidate] = struct{}{} + counts[base] = index + return candidate + } +} + +// sameProtobufMeta compares metadata that changes protobuf field numbers, +// external types, explicit names, wrapper layout, or JSON names. +func sameProtobufMeta(left, right expr.MetaExpr) bool { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if !reflect.DeepEqual(left[name], right[name]) { + return false + } + } + return true +} diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index fce44b6061..0086d0e38d 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -56,7 +56,6 @@ func TestProtoBufTransform(t *testing.T) { // attribute contexts used in test cases svcCtx = codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) ptrCtx = pointerContext("proto", sd.Scope) - pbCtx = protoBufTypeContext("proto", sd.Scope, true) ) // gRPC does not support any @@ -172,10 +171,12 @@ func TestProtoBufTransform(t *testing.T) { tgtCtx := c.Ctx if c.ToProto { target = makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) - tgtCtx = pbCtx + freezeProtoBufTransformMessages(sd, target) + tgtCtx = protoBufTypeContext("proto", sd, true) } else { source = makeProtoBufMessage(expr.DupAtt(source), source.Type.Name(), sd) - srcCtx = pbCtx + freezeProtoBufTransformMessages(sd, source) + srcCtx = protoBufTypeContext("proto", sd, true) } code, _, err := protoBufTransform(source, target, "source", "target", srcCtx, tgtCtx, c.ToProto, true) require.NoError(t, err) @@ -191,7 +192,7 @@ func TestProtoBufTransformAnyType(t *testing.T) { var ( sd = &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} svcCtx = codegen.NewAttributeContext(false, false, true, "", sd.Scope) - pbCtx = protoBufTypeContext("", sd.Scope, false) + pbCtx = protoBufTypeContext("", sd, false) ) cases := []struct { @@ -239,6 +240,14 @@ func TestProtoBufTransformAnyType(t *testing.T) { } } +// freezeProtoBufTransformMessages prepares the message names consumed by one +// standalone transformation test outside full service analysis. +func freezeProtoBufTransformMessages(sd *ServiceData, attribute *expr.AttributeExpr) { + sd.protobuf = newProtobufPackageCatalog("proto") + sd.protobuf.collectMessage(attribute, protobufMessageSource{}, sd) + sd.protobuf.freezeMessageNames() +} + func pointerContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { return codegen.NewAttributeContext(true, false, true, pkg, scope) } diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 2522c582c8..5625566c95 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -5,7 +5,6 @@ package codegen import ( "fmt" "path" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" @@ -63,6 +62,10 @@ type ( // Scope is the name scope for protocol buffers Scope *codegen.NameScope + // protobuf owns declarations emitted in this service's actual protobuf + // output package. + protobuf *protobufPackageCatalog + // transformHelpers is the list of transform functions required by the // constructors. transformHelpers []*codegen.TransformFunctionData @@ -451,9 +454,6 @@ const ( // validateClient generates the validation code for response messages in the // client package. validateClient - // validateBoth generates the validation code in both server and client - // packages. - validateBoth ) // Get retrieves the transport data for the service with the given name @@ -530,72 +530,23 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { ClientInterfaceInit: fmt.Sprintf("%s.New%sClient", pkg, svcVarN), Scope: scope, } - seen, imported := make(map[string]struct{}), make(map[string]struct{}) - for _, e := range gs.GRPCEndpoints { + sd.protobuf = newProtobufPackageCatalog(pkg) + sd.protobuf.reserveName(sd.Name) + protobufMessages := prepareProtobufPackage(gs, sd) + for index, e := range gs.GRPCEndpoints { hasRequestMessage := !isEmpty(e.Request.Type) - useStreamEnvelope := usesStreamEnvelope(e) - - // Derive protocol buffer shaped copies of the request and response - // attributes. The design expressions are inputs to the analysis and - // must not be mutated: the shaped attributes are kept in locals and - // threaded explicitly to the data builders below. - requestMessage := makeProtoBufMessage(e.Request, protoBufify(e.Name()+"_request", true, true), sd) - streamingRequest := e.StreamingRequest - if e.MethodExpr.StreamingPayload.Type != expr.Empty { - streamMessageName := protoBufify(e.Name()+"_streaming_request", true, true) - if useStreamEnvelope { - streamMessageName = protoBufify(e.Name()+"_stream_item", true, true) - } - streamingRequest = makeProtoBufMessage(e.StreamingRequest, streamMessageName, sd) - } - var requestEnvelope *expr.AttributeExpr - if useStreamEnvelope { - requestEnvelope = makeProtoBufStreamEnvelope( - requestMessage, - streamingRequest, - protoBufify(e.Name()+"_streaming_request", true, true), - sd, - ) - } - responseMessage := makeProtoBufMessage(e.Response.Message, protoBufify(e.Name()+"_response", true, true), sd) - errorMessages := make(map[string]*expr.AttributeExpr, len(e.GRPCErrors)) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue + messages := protobufMessages[index] + requestMessage := messages.request + streamingRequest := messages.streamingRequest + requestEnvelope := messages.requestEnvelope + responseMessage := messages.response + errorMessages := messages.errors + collect := func(attribute *expr.AttributeExpr) *service.UserTypeData { + record := sd.protobuf.message(attribute) + if record == nil || record.data == nil { + panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", attribute.Type.Name())) // bug } - errorMessages[er.Name] = makeProtoBufMessage(er.Response.Message, protoBufify(e.Name()+"_"+er.Name+"_error", true, true), sd) - } - - // collect all the nested messages and return the top-level message - // Also collect all proto imports specified via Meta. - collect := func(att *expr.AttributeExpr) *service.UserTypeData { - msgs, imports := collectMessages(att, sd, seen) - if len(imports) > 0 { - for _, imp := range imports { - if _, ok := imported[imp]; ok { - continue - } - imported[imp] = struct{}{} - sd.ProtoImports = append(sd.ProtoImports, imp) - } - } - if len(msgs) > 0 { - sd.Messages = append(sd.Messages, msgs...) - return msgs[0] - } - // lookup message in sd.Messages - if ut, ok := att.Type.(expr.UserType); ok { - name := ut.Name() - if n := att.Meta["struct:name:proto"]; n != nil { - name = n[0] - } - for _, t := range sd.Messages { - if t.Name == name { - return t - } - } - } - panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", att.Type.Name())) // bug + return record.data } var ( @@ -616,13 +567,6 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { viewedResultRef = md.ViewedResult.FullRef } errors := d.buildErrorsData(e, errorMessages, sd) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue - } - collect(errorMessages[er.Name]) - } - // build request data reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server") request := &RequestData{ @@ -639,8 +583,8 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { request.CLIArgs = append(request.CLIArgs, &InitArgData{ Name: "message", Ref: "message", - TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd.Scope), - TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd.Scope), + TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd), + TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), Example: requestMessage.Example(d.Root.API.ExampleGenerator), }) } @@ -724,222 +668,151 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { return sd } -// collectMessages recurses through the attribute to gather all the messages. -func collectMessages(at *expr.AttributeExpr, sd *ServiceData, seen map[string]struct{}) (data []*service.UserTypeData, imports []string) { - if at == nil { - return data, imports - } - if proto := at.Meta["struct:field:proto"]; len(proto) > 1 { - imp := proto[1] - found := false - for _, i := range sd.Service.ProtoImports { - if i.Path == imp { - found = true - break +// prepareProtobufPackage shapes every endpoint message, collects the complete +// package declaration set, and freezes messages and validators before any +// conversion or template data resolves their names. +func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) []*protobufEndpointMessages { + prepared := make([]*protobufEndpointMessages, len(serviceExpr.GRPCEndpoints)) + for index, endpoint := range serviceExpr.GRPCEndpoints { + useStreamEnvelope := usesStreamEnvelope(endpoint) + request := makeProtoBufMessage(endpoint.Request, protoBufify(endpoint.Name()+"_request", true, true), sd) + streamingRequest := endpoint.StreamingRequest + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + name := protoBufify(endpoint.Name()+"_streaming_request", true, true) + if useStreamEnvelope { + name = protoBufify(endpoint.Name()+"_stream_item", true, true) } + streamingRequest = makeProtoBufMessage(endpoint.StreamingRequest, name, sd) } - if !found { - imports = append(imports, imp) - if len(proto) > 3 { - elems := strings.Split(proto[3], "/") - sd.Service.ProtoImports = append(sd.Service.ProtoImports, &codegen.ImportSpec{Path: proto[3], Name: elems[len(elems)-1]}) + var requestEnvelope *expr.AttributeExpr + if useStreamEnvelope { + requestEnvelope = makeProtoBufStreamEnvelope( + request, + streamingRequest, + protoBufify(endpoint.Name()+"_streaming_request", true, true), + sd, + ) + } + response := makeProtoBufMessage(endpoint.Response.Message, protoBufify(endpoint.Name()+"_response", true, true), sd) + errors := make(map[string]*expr.AttributeExpr, len(endpoint.GRPCErrors)) + for _, grpcError := range endpoint.GRPCErrors { + if grpcError.Type == expr.ErrorResult || !expr.IsObject(grpcError.Type) { + continue } + errors[grpcError.Name] = makeProtoBufMessage( + grpcError.Response.Message, + protoBufify(endpoint.Name()+"_"+grpcError.Name+"_error", true, true), + sd, + ) + } + prepared[index] = &protobufEndpointMessages{ + request: request, + streamingRequest: streamingRequest, + requestEnvelope: requestEnvelope, + response: response, + errors: errors, } } - if expr.IsPrimitive(at.Type) { - // Add google.protobuf.Value import when Any type is used - if at.Type.Kind() == expr.AnyKind { - found := false - for _, imp := range imports { - if imp == "google/protobuf/struct.proto" { - found = true - break - } - } - if !found { - imports = append(imports, "google/protobuf/struct.proto") + + imported := make(map[string]struct{}) + collect := func(attribute *expr.AttributeExpr, source protobufMessageSource) { + for _, protobufImport := range sd.protobuf.collectMessage(attribute, source, sd) { + if _, ok := imported[protobufImport]; ok { + continue } + imported[protobufImport] = struct{}{} + sd.ProtoImports = append(sd.ProtoImports, protobufImport) } - return data, imports } - collect := func(at *expr.AttributeExpr) ([]*service.UserTypeData, []string) { - return collectMessages(at, sd, seen) + for index, endpoint := range serviceExpr.GRPCEndpoints { + messages := prepared[index] + requestSource := protobufRootMessageSource(endpoint.Request, endpoint, nil, protobufRequestMessage) + streamingSource := protobufRootMessageSource(endpoint.StreamingRequest, endpoint, nil, protobufStreamingRequestMessage) + responseSource := protobufRootMessageSource(endpoint.Response.Message, endpoint, nil, protobufResponseMessage) + sd.protobuf.bindSyntheticSource(messages.request, requestSource) + if messages.streamingRequest.Type != expr.Empty { + sd.protobuf.bindSyntheticSource(messages.streamingRequest, streamingSource) + } + sd.protobuf.bindSyntheticSource(messages.response, responseSource) + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + errorSource := protobufRootMessageSource( + grpcError.Response.Message, + endpoint, + grpcError, + protobufErrorMessage, + ) + sd.protobuf.bindSyntheticSource(message, errorSource) + collect(message, errorSource) + } + requestNeeded := !isEmpty(endpoint.Request.Type) || + (messages.requestEnvelope == nil && messages.streamingRequest.Type == expr.Empty) + if requestNeeded { + collect(messages.request, requestSource) + } + if messages.requestEnvelope != nil { + envelopeSource := protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + role: protobufStreamEnvelopeMessage, + }} + sd.protobuf.bindSyntheticSource(messages.requestEnvelope, envelopeSource) + collect(messages.requestEnvelope, envelopeSource) + } + if messages.streamingRequest.Type != expr.Empty { + collect(messages.streamingRequest, streamingSource) + } + if messages.response.Type != expr.Empty || !endpoint.MethodExpr.IsStreaming() { + collect(messages.response, responseSource) + } } - switch dt := at.Type.(type) { - case expr.UserType: - name := dt.Name() - if n := at.Meta["struct:name:proto"]; n != nil { - name = n[0] + sd.Messages = sd.protobuf.freezeMessages(sd) + + for index, endpoint := range serviceExpr.GRPCEndpoints { + messages := prepared[index] + if sd.protobuf.message(messages.request) != nil { + sd.protobuf.collectValidation(messages.request, validateServer, "message", "message") } - if _, ok := seen[name]; ok { - return data, imports + if sd.protobuf.message(messages.response) != nil { + sd.protobuf.collectValidation(messages.response, validateClient, "message", "message") } - att := userTypeAttribute(dt) - data = append(data, &service.UserTypeData{ - Name: name, - VarName: protoBufMessageName(at, sd.Scope), - Description: dt.Attribute().Description, - Def: protoBufMessageDef(att, sd), - Ref: protoBufGoFullTypeRef(at, sd.PkgName, sd.Scope), - Type: dt, - }) - seen[name] = struct{}{} - d, i := collect(att) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Object: - for _, nat := range *dt { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + for _, message := range messages.errors { + sd.protobuf.collectValidation(message, validateClient, "errmsg", "errmsg") } - case *expr.Array: - d, i := collect(dt.ElemType) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Map: - dk, ik := collect(dt.KeyType) - data = append(data, dk...) - imports = append(imports, ik...) - de, ie := collect(dt.ElemType) - data = append(data, de...) - imports = append(imports, ie...) - case *expr.Union: - for _, nat := range dt.Values { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + sd.protobuf.collectValidation(messages.streamingRequest, validateServer, "stream", "stream") } } - return data, imports + sd.validations = sd.protobuf.freezeValidations(sd) + return prepared +} + +// protobufRootMessageSource retains authored declaration provenance and uses a +// typed endpoint role only when message shaping created the root declaration. +func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRPCEndpointExpr, grpcError *expr.GRPCErrorExpr, role protobufSyntheticRole) protobufMessageSource { + if userType, ok := attribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} + } + return protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + error: grpcError, + role: role, + }} } -// addValidation adds a validation function (if any) for the given user type -// and recurses through the user type adding other validation functions -// (if any). +// addValidation returns the frozen validation helper for the given protobuf +// message on the generated server or client side. // // req if true indicates that the validation is generated for validating // request (server-side) messages. -func addValidation(att *expr.AttributeExpr, attName string, sd *ServiceData, req bool) *ValidationData { - ut, ok := att.Type.(expr.UserType) - if !ok { - return nil - } - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - // Validation helper names must be derived from the same protobuf-aware - // scope used by the validation templates so that function declarations - // and call sites (e.g. Message_) stay in sync regardless of traversal - // order or reserved-name handling. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - ref := protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope) +func addValidation(att *expr.AttributeExpr, sd *ServiceData, req bool) *ValidationData { kind := validateClient if req { kind = validateServer } - att = userTypeAttribute(ut) - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != kind { - n.Kind = validateBoth - collectValidations(att, attName, req, sd) - } - return n - } - } - removeMeta(att) - if def := codegen.ValidationCode(att, ut, vtx, true, expr.IsAlias(att.Type), false, attName); def != "" { - v := &ValidationData{ - // Validation function names must match the identifiers used by - // validation templates. The template uses the scoped type name - // directly (no Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: attName, - SrcName: name, - SrcRef: ref, - Kind: kind, - } - sd.validations = append(sd.validations, v) - collectValidations(att, attName, req, sd) - return v - } - return nil -} - -// collectValidations recurses through the attribute and collects the -// validation functions. -// -// req if true indicates that the validations are generated for validating -// request messages. -func collectValidations(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData) { - collectValidationsR(att, attName, req, sd, make(map[expr.UserType]struct{})) -} - -// collectValidationsR recurses through the attribute and collects validation -// functions with cycle detection using a seen set of declaration origins. -func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData, seen map[expr.UserType]struct{}) { - gattName := codegen.Goify(attName, false) - switch dt := att.Type.(type) { - case expr.UserType: - if expr.IsPrimitive(dt) { - // Alias type - validation is generate inline in parent type validation code. - return - } - // Cycle guard: avoid infinite recursion on recursive user types. - origin := dt.Origin() - if _, ok := seen[origin]; ok { - return - } - seen[origin] = struct{}{} - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - def := codegen.AttributeValidationCode(att, dt, vtx, true, false, gattName, attName) - // Match helper function identifiers with validation template calls by - // using the same protobuf-aware scope for the type name. This keeps - // names like Message_ consistent between declarations and call sites. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - kind := validateClient - if req { - kind = validateServer - } - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != validateBoth && n.Kind != kind { - n.Kind = validateBoth - goto collect - } - return - } - } - if def != "" { - sd.validations = append(sd.validations, &ValidationData{ - // Match helper function identifiers with validation template - // calls. The template uses the scoped type name directly (no - // Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: gattName, - SrcName: name, - SrcRef: protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope), - Kind: kind, - }) - } - collect: - att := userTypeAttribute(dt) - collectValidationsR(att, attName, req, sd, seen) - case *expr.Object: - for _, nat := range *dt { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - case *expr.Array: - collectValidationsR(dt.ElemType, "elem", req, sd, seen) - case *expr.Map: - collectValidationsR(dt.KeyType, "key", req, sd, seen) - collectValidationsR(dt.ElemType, "val", req, sd, seen) - case *expr.Union: - for _, nat := range dt.Values { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - } + return sd.protobuf.validation(att, kind) } // userTypeAttribute returns the attribute of the given user type. @@ -981,6 +854,7 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE } svc := sd.Service + method := svc.Method(e.Name()) side := "client" if svr { side = "server" @@ -988,29 +862,29 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side - data := d.buildInitData(request, payload, "message", "v", svcCtx, false, false, sd) + data := d.buildInitData(request, payload, "message", "v", svcCtx, method.Payload, false, false, sd) data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) // pass the metadata as arguments to payload constructor in server data.Args = append(data.Args, initArgsFromMetadata(md)...) return &ConvertData{ - SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd), + SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd), TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), Init: data, - Validation: addValidation(request, "message", sd, true), + Validation: addValidation(request, sd, true), } } // client side - data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, false, sd) + data := d.buildInitData(payload, request, "payload", "message", svcCtx, method.Payload, true, false, sd) data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ SrcName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), - TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), + TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd), + TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd), Init: data, } } @@ -1046,7 +920,7 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi } if expr.IsObject(payload.Type) { svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) - init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, false, sd) + init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, sd.Service.Method(e.Name()).Payload, false, false, sd) init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) init.Args = append(init.Args, initArgsFromMetadata(md)...) @@ -1072,21 +946,26 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute return nil } svc := sd.Service + method := svc.Method(e.Name()) + resultName := method.Result + if _, ok := result.Type.(expr.UserType); ok { + resultName = codegen.Goify(result.Type.Name(), true) + } if svr { // server side - data := d.buildInitData(result, response, "result", "message", svcCtx, true, false, sd) + data := d.buildInitData(result, response, "result", "message", svcCtx, resultName, true, false, sd) data.Description = fmt.Sprintf("%s builds the gRPC response type from the result of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), + TgtName: protoBufGoFullTypeName(response, sd.PkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.PkgName, sd), Init: data, } } // client side - data := d.buildInitData(response, result, "message", "result", svcCtx, false, false, sd) + data := d.buildInitData(response, result, "message", "result", svcCtx, resultName, false, false, sd) data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) // pass the headers as arguments to result constructor in client @@ -1094,12 +973,12 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // pass the trailers as arguments to result constructor in client data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) return &ConvertData{ - SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd), TgtName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), Init: data, - Validation: addValidation(response, "message", sd, false), + Validation: addValidation(response, sd, false), } } @@ -1111,8 +990,8 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // transformation // svcCtx is the attribute context for service type // proto if true indicates the target type is a protocol buffer type -func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto, usesrc bool, sd *ServiceData) *InitData { - pbCtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) +func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, serviceTypeName string, proto, usesrc bool, sd *ServiceData) *InitData { + pbCtx := protoBufTypeContext(sd.PkgName, sd, false) name := "New" srcCtx := pbCtx tgtCtx := svcCtx @@ -1121,15 +1000,25 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV tgtCtx = pbCtx name += "Proto" } + var sourceTypeName, targetTypeName func() string + if proto { + sourceTypeName = func() string { return serviceTypeName } + targetTypeName = func() string { return protoBufGoTypeName(target, sd) } + } else { + sourceTypeName = func() string { return protoBufGoTypeName(source, sd) } + targetTypeName = func() string { return serviceTypeName } + } isStruct := expr.IsObject(target.Type) || expr.IsUnion(target.Type) if _, ok := source.Type.(expr.UserType); ok && usesrc { - name += protoBufGoTypeName(source, sd.Scope) + name += sourceTypeName() } - n := protoBufGoTypeName(target, sd.Scope) - if !isStruct { + n := serviceTypeName + if isStruct { + n = targetTypeName() + } else { // If target is array, map, or primitive the name will be suffixed with // the definition (e.g int, []string, map[int]string) which is incorrect. - n = protoBufGoTypeName(source, sd.Scope) + n = sourceTypeName() } name += n code, helpers, err := protoBufTransform(source, target, sourceVar, targetVar, srcCtx, tgtCtx, proto, true) @@ -1198,31 +1087,38 @@ func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRP side = "server" } svcCtx := d.serviceTypeContext(sd, side).Enter(ge.AttributeExpr) + errorTypeName := "" + for _, serviceError := range sd.Service.Method(e.Name()).Errors { + if serviceError.ErrName == ge.Name { + errorTypeName = serviceError.TypeName + break + } + } if svr { // server side - data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, false, sd) + data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, errorTypeName, true, false, sd) data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) data.Description = fmt.Sprintf("%s builds the gRPC error response type from the error of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ SrcName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), - TgtName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + TgtName: protoBufGoFullTypeName(message, sd.PkgName, sd), + TgtRef: protoBufGoFullTypeRef(message, sd.PkgName, sd), Init: data, } } // client side - data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, false, sd) + data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, errorTypeName, false, false, sd) data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) data.Description = fmt.Sprintf("%s builds the error type of the %q endpoint of the %q service from the gRPC error response type.", data.Name, e.Name(), svc.Name) return &ConvertData{ - SrcName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(message, sd.PkgName, sd), + SrcRef: protoBufGoFullTypeRef(message, sd.PkgName, sd), TgtName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), Init: data, - Validation: addValidation(message, "errmsg", sd, false), + Validation: addValidation(message, sd, false), } } @@ -1255,12 +1151,20 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques svc := sd.Service ed := sd.Endpoint(e.Name()) md := ed.Method + streamingPayloadName := md.StreamingPayload + resultName := md.StreamingResult + if resultName == "" { + resultName = md.Result + } side := "client" if svr { side = "server" } svcCtx := d.serviceTypeContext(sd, side).Enter(e.MethodExpr.StreamingPayload) result, resCtx := d.resultContext(e, sd, side) + if _, ok := result.Type.(expr.UserType); ok { + resultName = codegen.Goify(result.Type.Name(), true) + } resVar := "result" if md.ViewedResult != nil { resVar = "vresult" @@ -1277,9 +1181,9 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendConvert = &ConvertData{ SrcName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), SrcRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), - Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, true, true, sd), + TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd), + TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), + Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, resultName, true, true, sd), } } if e.MethodExpr.StreamingPayload.Type != expr.Empty { @@ -1287,12 +1191,12 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques recvWithContextName = md.ServerStream.RecvWithContextName recvRef = svcCtx.Scope.Ref(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload)) recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd), + SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), TgtName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: recvRef, - Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, true, sd), - Validation: addValidation(streamingRequest, "stream", sd, true), + Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, streamingPayloadName, false, true, sd), + Validation: addValidation(streamingRequest, sd, true), } } mustClose = md.ServerStream.MustClose @@ -1308,9 +1212,9 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendConvert = &ConvertData{ SrcName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: sendRef, - TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), - Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, true, sd), + TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd), + TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), + Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, streamingPayloadName, true, true, sd), } } if e.MethodExpr.Result.Type != expr.Empty { @@ -1318,12 +1222,12 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques recvWithContextName = md.ClientStream.RecvWithContextName recvRef = ed.ResultRef recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd), + SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), TgtName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), TgtRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, false, true, sd), - Validation: addValidation(responseMessage, "stream", sd, false), + Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, resultName, false, true, sd), + Validation: addValidation(responseMessage, sd, false), } } mustClose = md.ClientStream.MustClose @@ -1478,7 +1382,7 @@ func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname strin func buildStreamEnvelopeData(envelope *expr.AttributeExpr, message *service.UserTypeData, sd *ServiceData) *StreamEnvelopeData { body := envelope.Find("body") union := expr.AsUnion(body.Type) - scope := &protoBufScope{scope: sd.Scope} + scope := &protoBufScope{service: sd} fieldName := scope.Field(body, union.TypeName, true) initialFieldName := scope.Field(union.Values[0].Attribute, union.Values[0].Name, true) streamItemFieldName := scope.Field(union.Values[1].Attribute, union.Values[1].Name, true) diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go index 0041897305..18d6b93917 100644 --- a/grpc/codegen/service_data_traversal_test.go +++ b/grpc/codegen/service_data_traversal_test.go @@ -8,9 +8,207 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) +func TestCollectMessagesDistinguishesEqualNameAndUIDOrigins(t *testing.T) { + first := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + second := grpcMessageTraversalType("Shared", "shared", expr.Int, "2") + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.NotEqual(t, messages[0].Ref, messages[1].Ref) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 2") +} + +func TestCollectMessagesDistinguishesOneOriginWithDifferentWireShape(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + expr.AsObject(second).Attribute("value").Meta["rpc:tag"] = []string{"2"} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "string value = 2") +} + +func TestCollectMessagesReusesIdenticalDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + require.Len(t, messages, 1) +} + +func TestCollectMessagesDistinguishesProtoOverrides(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"FirstWire"}}, + } + second := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"SecondWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "FirstWire", messages[0].VarName) + require.Equal(t, "SecondWire", messages[1].VarName) +} + +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentSchemas(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.Int, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 1") +} + +func TestCollectMessagesStopsAtRecursiveCopy(t *testing.T) { + message := grpcMessageTraversalType("Recursive", "recursive", expr.String, "1") + object := expr.AsObject(message) + *object = append(*object, &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{ + Type: message, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + }, + }) + + messages := freezeTraversalMessages(grpcTraversalServiceData(), &expr.AttributeExpr{Type: expr.Dup(message)}) + require.Len(t, messages, 1) + require.Contains(t, messages[0].Def, "Recursive next = 2") +} + +func TestProtoBufMessageNameRequiresFrozenDeclaration(t *testing.T) { + message := grpcMessageTraversalType("Unbound", "unbound", expr.String, "1") + sd := grpcTraversalServiceData() + + require.Panics(t, func() { + protoBufMessageName(&expr.AttributeExpr{Type: message}, sd) + }) +} + +func TestProtoBufMessageNameIgnoresLateScopeAllocations(t *testing.T) { + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + messages := freezeTraversalMessages(sd, attribute) + require.Len(t, messages, 1) + + sd.Scope.HashedUnique(grpcMessageTraversalType("Other", "other", expr.Int, "1"), "Shared") + require.Equal(t, messages[0].VarName, protoBufMessageName(attribute, sd)) +} + +func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + firstMinimum := 2 + secondMinimum := 5 + expr.AsObject(first).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &firstMinimum} + expr.AsObject(second).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &secondMinimum} + sd := grpcTraversalServiceData() + firstAttribute := &expr.AttributeExpr{Type: first} + secondAttribute := &expr.AttributeExpr{Type: second} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstAttribute}, + {Name: "second", Attribute: secondAttribute}, + }} + freezeTraversalMessages(sd, root) + sd.protobuf.collectValidation(firstAttribute, validateServer, "message", "message") + sd.protobuf.collectValidation(secondAttribute, validateServer, "message", "message") + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(firstAttribute, sd, true) + secondValidation := addValidation(secondAttribute, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.Len(t, sd.validations, 2) + require.NotEqual(t, firstValidation.Name, secondValidation.Name) + require.Contains(t, firstValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 2, true)`) + require.Contains(t, secondValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 5, true)`) +} + +func TestAddValidationDistinguishesGeneratedSide(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, "message", "message") + sd.protobuf.collectValidation(attribute, validateClient, "message", "message") + sd.validations = sd.protobuf.freezeValidations(sd) + + server := addValidation(attribute, sd, true) + client := addValidation(attribute, sd, false) + require.NotNil(t, server) + require.NotNil(t, client) + require.Len(t, sd.validations, 2) + require.Equal(t, validateServer, server.Kind) + require.Equal(t, validateClient, client.Kind) +} + +func TestAddValidationReusesIdenticalRulesOnOneSide(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + first := &expr.AttributeExpr{Type: expr.Dup(message)} + second := &expr.AttributeExpr{Type: expr.Dup(message)} + sd := grpcTraversalServiceData() + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + freezeTraversalMessages(sd, root) + sd.protobuf.collectValidation(first, validateServer, "message", "message") + sd.protobuf.collectValidation(second, validateServer, "message", "message") + sd.validations = sd.protobuf.freezeValidations(sd) + + require.Len(t, sd.validations, 1) + require.Same(t, addValidation(first, sd, true), addValidation(second, sd, true)) +} + func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { minimumLength := 3 minimum := 5.0 @@ -28,7 +226,9 @@ func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { }} sd := &ServiceData{PkgName: "pb", Scope: codegen.NewNameScope()} - collectValidations(root, "root", true, sd) + freezeTraversalMessages(sd, root) + sd.protobuf.collectValidation(root, validateServer, "message", "message") + sd.validations = sd.protobuf.freezeValidations(sd) var names []string for _, validation := range sd.validations { names = append(names, validation.SrcName) @@ -39,6 +239,10 @@ func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { // grpcValidationTraversalType builds an authored message declaration with one // constrained field so validation discovery must emit a helper for it. func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *expr.UserTypeExpr { + if field.Meta == nil { + field.Meta = make(expr.MetaExpr) + } + field.Meta["rpc:tag"] = []string{"1"} return &expr.UserTypeExpr{ AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ {Name: "value", Attribute: field}, @@ -47,3 +251,38 @@ func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *e UID: uid, } } + +// grpcMessageTraversalType builds a protobuf message declaration with one +// explicitly numbered field. +func grpcMessageTraversalType(name, uid string, fieldType expr.DataType, tag string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: fieldType, + Meta: expr.MetaExpr{"rpc:tag": {tag}}, + }}, + }}, + TypeName: name, + UID: uid, + } +} + +// grpcTraversalServiceData supplies the protobuf package and field scope used +// by focused declaration and validator catalog tests. +func grpcTraversalServiceData() *ServiceData { + return &ServiceData{ + Name: "Service", + PkgName: "servicepb", + Scope: codegen.NewNameScope(), + Service: &service.Data{}, + } +} + +// freezeTraversalMessages collects and freezes every message reachable from +// root in the focused test protobuf package. +func freezeTraversalMessages(sd *ServiceData, root *expr.AttributeExpr) []*service.UserTypeData { + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + sd.protobuf.collectMessage(root, protobufMessageSource{}, sd) + sd.Messages = sd.protobuf.freezeMessages(sd) + return sd.Messages +} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden index 372d2c3c91..16ec026b91 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.ResultTypeCollection{} if source.Collection != nil { - target.Collection = &proto.ResultTypeCollection{} + target.Collection = &proto.ResultTypeCollection2{} target.Collection.Field = make([]*proto.ResultType, len(source.Collection)) for i, val := range source.Collection { target.Collection.Field[i] = &proto.ResultType{} From ddfc04727218f2f86f591cddd5d63c37cb8122c3 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 07:44:04 -0700 Subject: [PATCH 25/43] fix(codegen): own transport wire contracts --- codegen/ARCHITECTURE.md | 61 +- ...generate_grpc_metadata_integration_test.go | 161 +++++ ...erate_http_union_shape_integration_test.go | 12 +- codegen/go_transform_test.go | 30 +- dsl/error.go | 6 +- dsl/grpc.go | 10 +- dsl/headers.go | 6 +- expr/error_contract.go | 244 ++++++- expr/error_contract_test.go | 225 ++++++ expr/grpc_endpoint.go | 2 + expr/grpc_endpoint_test.go | 11 + expr/http_body_types.go | 43 +- expr/http_body_types_test.go | 33 +- expr/testdata/endpoint_dsls.go | 42 ++ expr/transport_error_contract_test.go | 110 +++ grpc/codegen/client.go | 13 + grpc/codegen/client_cli.go | 10 +- grpc/codegen/proto_test.go | 3 + grpc/codegen/protobuf_catalog.go | 47 +- grpc/codegen/protobuf_test.go | 2 + grpc/codegen/server.go | 18 + grpc/codegen/service_data.go | 293 ++++++-- grpc/codegen/service_data_traversal_test.go | 51 ++ .../service_metadata_reference_test.go | 50 +- .../partial/convert_string_to_type.go.tpl | 2 - .../partial/convert_type_to_string.go.tpl | 2 - .../partial/slice_item_conversion.go.tpl | 2 - .../partial/string_conversion.go.tpl | 2 - .../templates/partial/type_conversion.go.tpl | 2 - grpc/codegen/templates/request_encoder.go.tpl | 23 +- .../codegen/templates/response_decoder.go.tpl | 8 +- .../codegen/templates/response_encoder.go.tpl | 19 +- grpc/codegen/templates/type_init.go.tpl | 6 +- grpc/codegen/testdata/dsls.go | 25 + ...distinct-custom-message-names.proto.golden | 22 + ...st-encoder-payload-with-metadata.go.golden | 4 +- ...payload-with-security-attributes.go.golden | 16 +- ...st-encoder-payload-with-validate.go.golden | 4 +- ...nse-decoder-result-with-metadata.go.golden | 4 +- ...nse-decoder-result-with-validate.go.golden | 4 +- ...nse-encoder-result-with-metadata.go.golden | 12 +- ...nse-encoder-result-with-validate.go.golden | 12 +- grpc/codegen/types.go | 3 - http/codegen/idempotency_test.go | 6 +- http/codegen/oneof_http_codegen_test.go | 2 +- .../TestSections/with-map_file0.golden | 2 +- .../TestSections/with-map_file1.golden | 2 +- .../TestValidations/array_file0.golden | 24 +- .../TestValidations/array_file1.golden | 16 +- .../testdata/golden/alias-type_file0.golden | 32 +- .../testdata/golden/alias-type_file1.golden | 32 +- .../v3/testdata/golden/array_file0.golden | 90 +-- .../v3/testdata/golden/array_file1.golden | 58 +- .../golden/v3.2/alias-type_file0.golden | 30 +- .../golden/v3.2/alias-type_file1.golden | 30 +- http/codegen/service_data.go | 549 +++++++------- .../service_data_union_nilability_test.go | 10 +- http/codegen/service_data_union_order_test.go | 45 +- http/codegen/templates/union_type.go.tpl | 4 +- ...t_body_type_decl_body-user-inner.go.golden | 2 +- ...dy-primitive-array-user-validate.go.golden | 10 +- ...nit_body-streaming-aliased-array.go.golden | 4 +- ...t_body_type_init_body-user-inner.go.golden | 2 +- ...esult-explicit-body-object-views.go.golden | 2 +- ...init_result-explicit-body-object.go.golden | 2 +- .../golden/client_cli_multi-build.go.golden | 4 +- ...ient_cli_payload-array-user-type.go.golden | 4 +- ...client_cli_payload-map-user-type.go.golden | 6 +- ...dy-primitive-array-user-validate.go.golden | 2 +- ...rvices-same-payload-and-result_0.go.golden | 2 +- ...rvices-same-payload-and-result_1.go.golden | 2 +- ...types_client-mixed-payload-attrs.go.golden | 18 +- ...methods-with-array-type-payloads.go.golden | 38 +- ...nt_types_client-multiple-methods.go.golden | 17 +- ...treaming-payload-required-fields.go.golden | 8 +- ...pes_client-with-error-custom-pkg.go.golden | 4 +- ...es_client-with-result-collection.go.golden | 38 +- ...nt_types_client-with-result-view.go.golden | 10 +- ...dy-primitive-array-user-required.go.golden | 4 +- ...dy-primitive-array-user-validate.go.golden | 4 +- .../server_decode_decode-deep-user.go.golden | 12 +- ...-result-collection-explicit-view.go.golden | 2 +- ...result-collection-multiple-views.go.golden | 4 +- ...al_array-alias-extended_section0.go.golden | 6 +- ...al_array-alias-extended_section1.go.golden | 8 +- ...mbedded-custom-pkg-type_section0.go.golden | 6 +- ...mbedded-custom-pkg-type_section1.go.golden | 8 +- ...al_extension-with-alias_section0.go.golden | 8 +- ...al_extension-with-alias_section1.go.golden | 6 +- ...al_extension-with-alias_section2.go.golden | 10 +- ...al_extension-with-alias_section3.go.golden | 10 +- ...al_extension-with-alias_section4.go.golden | 8 +- ...oad_types_body-inline-array-user.go.golden | 4 +- ...yload_types_body-inline-map-user.go.golden | 6 +- ...types_body-inline-recursive-user.go.golden | 2 +- ...s_body-query-user-union-validate.go.golden | 2 +- ...load_types_body-query-user-union.go.golden | 2 +- ...ad_types_body-user-inner-default.go.golden | 2 +- ...er_payload_types_body-user-inner.go.golden | 2 +- ...types_server-mixed-payload-attrs.go.golden | 27 +- ...er_types_server-multiple-methods.go.golden | 19 +- ...ver-payload-with-validated-alias.go.golden | 6 +- ...treaming-payload-required-fields.go.golden | 13 +- ...pes_server-with-error-custom-pkg.go.golden | 2 +- ...lection-sibling-user-type-fields.go.golden | 29 +- ...es_server-with-result-collection.go.golden | 18 +- ...h-result-nested-user-type-fields.go.golden | 18 +- ...-result-sibling-user-type-fields.go.golden | 12 +- ...er_types_server-with-result-view.go.golden | 10 +- http/codegen/testdata/streaming_code.go | 40 +- http/codegen/types.go | 86 +-- http/codegen/websocket.go | 12 +- http/codegen/wire_catalog.go | 675 ++++++++++++++++++ http/codegen/wire_catalog_test.go | 152 ++++ jsonrpc/codegen/kitchen_sink_test.go | 2 + .../gen/jsonrpc/calc/client/types.go.golden | 2 +- .../gen/jsonrpc/calc/server/types.go.golden | 2 +- 117 files changed, 2990 insertions(+), 1013 deletions(-) create mode 100644 codegen/generator/generate_grpc_metadata_integration_test.go create mode 100644 grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden create mode 100644 http/codegen/wire_catalog.go create mode 100644 http/codegen/wire_catalog_test.go diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 2877470143..5a23d12ad7 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -68,12 +68,17 @@ conflicting requirements are rejected; generated and metadata qualifiers may receive deterministic suffixes. Each generated file still imports only the paths used by the declarations and references it renders. -Each transport plans the literal imports used by its own templates before the -catalog freezes. JSON-RPC planning includes HTTP planning because it reuses the -HTTP type, codec, and command-line renderers. The service planner does not know -about transport packages. Render functions derive their output import paths -from the same generation-backed service analysis; they do not accept another -generated module path that could redirect files away from their imports. +Each transport plans both the literal imports used by its templates and every +generated client, server, protobuf, and command-line package it will reference +before the catalog freezes. Preferred qualifiers come from the authored service +path, never by appending text to an already allocated qualifier. JSON-RPC +planning includes HTTP planning because it reuses the HTTP type, codec, and +command-line renderers. Example planning also reserves the application, +interceptor, transport-server, and command-line paths before the common freeze. +The service planner does not know about transport packages. Render functions +derive their output import paths from the same generation-backed service +analysis; they do not accept another generated module path that could redirect +files away from their imports. Planning a declaration returns its canonical record. Once every selected generator and plugin has planned its output, the context freezes the catalog. @@ -111,7 +116,41 @@ catalog: This is the only supported route for resolving generated service types inside HTTP, gRPC, JSON-RPC, conversion, and validation helpers. Transport-specific -scopes still own transport-only wire declarations. +scopes still own transport-only wire declarations. Each actual HTTP, +JSON-RPC, or protobuf output package has its own wire declaration catalog. The +catalog first collects the complete detached wire shapes and validation rules, +then freezes deterministic declaration and validator names before templates +request references. Traversal provenance only stops recursion; it never decides +that two emitted declarations are interchangeable. + +HTTP body shaping renames only the endpoint's top-level wrapper. Nested copied +declarations retain their authored `Origin()` until the client or server wire +catalog assigns the name used in that output package. This keeps transport-local +correlation out of the expression graph and lets one authored declaration reuse +one request record while still receiving a distinct response record when pointer, +view, default, or validation policy changes. + +An HTTP union record combines its authored JSON shape with the exact frozen +wire declaration records used by every branch. Equal JSON shapes are therefore +reused only when their generated branch types are also the same. The catalog +plans the union type, discriminator type, branch constants, and constructors +before freezing; transforms and validators consume those records instead of +reconstructing names from authored types. + +gRPC request headers, response headers, and trailers are native wire values: +one primitive or an array of primitives. Analysis recursively removes named +service aliases from a detached copy while preserving validation, defaults, and +requiredness. Metadata parsing and serialization use that local native value; +Goa's normal transformer converts between it and the frozen service field in +the actual client or server package. Objects, maps, and unions are rejected by +DSL validation rather than reaching templates as unsupported cases. + +An explicit protobuf message name is a preferred emitted name, not declaration +identity. Root messages retain the `Origin()` of the service declaration whose +value they carry, even when gRPC shaping builds a separate wire object for an +endpoint role. Two authored declarations with different `Origin()` values +remain separate catalog records even when they request the same protobuf name +and have the same current wire shape. Each side of a conversion enters its own attribute independently. A copied HTTP body remains owned by the HTTP package even if the source service declaration @@ -123,8 +162,12 @@ full-path import binding used by that file's imports. Reusable API- or service-level HTTP and gRPC error mappings are response policy, not replacement service types. When an endpoint inherits a mapping by error name, the mapping's error attribute must equal the method's effective error -attribute, including its named type shape, validations, defaults, and struct -metadata. Validation rejects incompatible shadowing before code generation. +attribute after References and Bases are materialized, including its named type +shape, validations, defaults, and struct metadata. Validation finalizes a +complete detached copy of each cyclic graph; it never mutates or registers the +evaluated declarations. Union branches compare by position because transforms +pair branches by position. Validation rejects incompatible shadowing before +code generation. Finalization then binds the mapping to the method error declaration, so service constructors, transport encoders and decoders, and generated references all use one concrete error value. For example, an API mapping for a string diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go new file mode 100644 index 0000000000..15331f10c6 --- /dev/null +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -0,0 +1,161 @@ +// This file verifies that generated gRPC metadata codecs convert between +// native header values and relocated service aliases in both directions. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { + t.Cleanup(func() { Generators = generators }) + Generators = func(string) ([]Genfunc, error) { + return []Genfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }, nil + } + + _ = codegen.RunDSL(t, func() { + d.API("metadata", func() {}) + value := d.Type("Value", d.Int, func() { + d.Enum(1, 2) + d.Meta("struct:pkg:path", "shared/types") + }) + values := d.Type("Values", d.ArrayOf(value), func() { + d.Meta("struct:pkg:path", "shared/types") + }) + payload := d.Type("Payload", func() { + d.Field(1, "required_values", values) + d.Field(2, "optional_value", value) + d.Field(3, "anonymous_values", d.ArrayOf(value)) + d.Field(4, "optional_values", values) + d.Required("required_values", "anonymous_values") + }) + result := d.Type("Result", func() { + d.Field(1, "header_values", values) + d.Field(2, "trailer_value", value) + d.Field(3, "optional_header_values", values) + d.Required("header_values") + }) + d.Service("Metadata", func() { + d.Method("Exchange", func() { + d.Payload(payload) + d.Result(result) + d.GRPC(func() { + d.Metadata(func() { + d.Attribute("required_values") + d.Attribute("optional_value") + d.Attribute("anonymous_values") + d.Attribute("optional_values") + }) + d.Response(func() { + d.Headers(func() { + d.Attribute("header_values") + d.Attribute("optional_header_values") + }) + d.Trailers(func() { d.Attribute("trailer_value") }) + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "gen") + if _, err := Generate(dir, "gen", false); err != nil { + t.Fatalf("generate gRPC metadata module: %v", err) + } + writeGRPCMetadataRoundTripTest(t, genDir) + runGeneratedTests(t, genDir) +} + +// writeGRPCMetadataRoundTripTest adds consumer code outside the generated +// packages that exercises both metadata directions through their public API. +func writeGRPCMetadataRoundTripTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "roundtrip") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create metadata round-trip package: %v", err) + } + const source = `package roundtrip_test + +import ( + "context" + "testing" + + genclient "gen/grpc/metadata/client" + genserver "gen/grpc/metadata/server" + genmetadata "gen/metadata" + gentypes "gen/shared/types" + "google.golang.org/grpc/metadata" +) + +func TestMetadataRoundTrip(t *testing.T) { + ctx := context.Background() + optional := gentypes.Value(2) + payload := &genmetadata.Payload{ + RequiredValues: gentypes.Values{1, 2}, + OptionalValue: &optional, + AnonymousValues: []gentypes.Value{2, 1}, + OptionalValues: gentypes.Values{1}, + } + requestMetadata := metadata.MD{} + message, err := genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err := genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + gotPayload := decoded.(*genmetadata.Payload) + if len(gotPayload.RequiredValues) != 2 || gotPayload.RequiredValues[1] != 2 || *gotPayload.OptionalValue != 2 || len(gotPayload.AnonymousValues) != 2 || gotPayload.AnonymousValues[1] != 1 || len(gotPayload.OptionalValues) != 1 || gotPayload.OptionalValues[0] != 1 { + t.Fatalf("unexpected payload: %#v", gotPayload) + } + for _, optionalValues := range []gentypes.Values{nil, {}} { + payload.OptionalValues = optionalValues + requestMetadata = metadata.MD{} + message, err = genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err = genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + if got := decoded.(*genmetadata.Payload); len(got.OptionalValues) != 0 { + t.Fatalf("unexpected absent optional values: %#v", got.OptionalValues) + } + } + + trailer := gentypes.Value(1) + result := &genmetadata.Result{ + HeaderValues: gentypes.Values{2, 1}, + TrailerValue: &trailer, + OptionalHeaderValues: gentypes.Values{1}, + } + headers, trailers := metadata.MD{}, metadata.MD{} + response, err := genserver.EncodeExchangeResponse(ctx, result, &headers, &trailers) + if err != nil { + t.Fatal(err) + } + decoded, err = genclient.DecodeExchangeResponse(ctx, response, headers, trailers) + if err != nil { + t.Fatal(err) + } + gotResult := decoded.(*genmetadata.Result) + if len(gotResult.HeaderValues) != 2 || gotResult.HeaderValues[0] != 2 || *gotResult.TrailerValue != 1 || len(gotResult.OptionalHeaderValues) != 1 || gotResult.OptionalHeaderValues[0] != 1 { + t.Fatalf("unexpected result: %#v", gotResult) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "roundtrip_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write metadata round-trip test: %v", err) + } +} diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index 47bfa03a02..5bf4a990e5 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -74,8 +74,8 @@ func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { runGeneratedTests(t, genDir) } -// assertGeneratedUnionDeclarations proves the two request copies share one -// declaration while the differently shaped response receives another. +// assertGeneratedUnionDeclarations proves identical request derivations reuse +// one union while the differently shaped response receives another. func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { t.Helper() path := filepath.Join(genDir, "http", "front", "server", "types.go") @@ -91,7 +91,13 @@ func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { t.Fatalf("expected one response Scope2 declaration:\n%s", code) } if strings.Contains(code, "type Scope3 struct {") { - t.Fatalf("copied request union produced a third declaration:\n%s", code) + t.Fatalf("identical request derivation produced a third union declaration:\n%s", code) + } + if strings.Contains(code, "SiteSetRequestBody") { + t.Fatalf("identical request derivation produced a second branch declaration:\n%s", code) + } + if strings.Count(code, "\tSiteSet *SiteSet\n") != 1 { + t.Fatalf("request union does not reference its canonical branch declaration:\n%s", code) } } diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 1df0782d23..0f389547fb 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -268,8 +268,8 @@ func TestGoTransformUnionAcrossTransportBoundary(t *testing.T) { } func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { - source := transformOwnerTestType("Envelope", "Choice") - target := transformOwnerTestType("Envelope", "Choice") + source := transformOwnerTestType("SourceEnvelope", "SourceChoice", "source/types") + target := transformOwnerTestType("TargetEnvelope", "TargetSelection", "target/models") sourceOwner := newTransformOwnerAttributor("source") targetOwner := newTransformOwnerAttributor("target") @@ -285,14 +285,14 @@ func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { ) require.NoError(t, err) require.NotEmpty(t, helpers) - require.Contains(t, helpers[0].ParamTypeRef, "sourceChoiceContainer.ChoiceContainer") - require.Contains(t, helpers[0].ResultTypeRef, "targetChoiceContainer.ChoiceContainer") - require.Contains(t, *sourceOwner.entered, "sourceEnvelope") - require.Contains(t, *sourceOwner.entered, "sourceChoiceContainer") - require.Contains(t, *sourceOwner.entered, "sourceChoice") - require.Contains(t, *targetOwner.entered, "targetEnvelope") - require.Contains(t, *targetOwner.entered, "targetChoiceContainer") - require.Contains(t, *targetOwner.entered, "targetChoice") + require.Contains(t, helpers[0].ParamTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") + require.Contains(t, helpers[0].ResultTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, *sourceOwner.entered, "sourceSourceEnvelope") + require.Contains(t, *sourceOwner.entered, "sourceSourceChoiceContainer") + require.Contains(t, *sourceOwner.entered, "sourceSourceChoice") + require.Contains(t, *targetOwner.entered, "targetTargetEnvelope") + require.Contains(t, *targetOwner.entered, "targetTargetSelectionContainer") + require.Contains(t, *targetOwner.entered, "targetTargetSelection") reverseSource := newTransformOwnerAttributor("source") reverseTarget := newTransformOwnerAttributor("target") @@ -308,8 +308,8 @@ func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { ) require.NoError(t, err) require.NotEmpty(t, reverseHelpers) - require.Contains(t, reverseHelpers[0].ParamTypeRef, "targetChoiceContainer.ChoiceContainer") - require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceChoiceContainer.ChoiceContainer") + require.Contains(t, reverseHelpers[0].ParamTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") } func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { @@ -356,7 +356,7 @@ func (a *transformOwnerAttributor) Scope() *NameScope { return a.scope } -func transformOwnerTestType(name, unionName string) expr.UserType { +func transformOwnerTestType(name, unionName, location string) expr.UserType { union := &expr.Union{ TypeName: unionName, Values: []*expr.NamedAttributeExpr{ @@ -370,7 +370,7 @@ func transformOwnerTestType(name, unionName string) expr.UserType { Type: &expr.Object{ {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, }, - Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, }, } return &expr.UserTypeExpr{ @@ -379,7 +379,7 @@ func transformOwnerTestType(name, unionName string) expr.UserType { Type: &expr.Object{ {Name: "inner", Attribute: &expr.AttributeExpr{Type: container}}, }, - Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, }, } } diff --git a/dsl/error.go b/dsl/error.go index 8f0c172d07..203ce6f8c0 100644 --- a/dsl/error.go +++ b/dsl/error.go @@ -68,8 +68,10 @@ const ( // A reusable API or service transport response mapping is matched to a method // error by name, but it does not replace the method's error type. If a method or // service shadows the reusable error with the same name, both error attributes -// must define the same type, validations, defaults, and struct metadata. Goa -// rejects incompatible definitions during design validation. +// must define the same effective type, validations, defaults, and struct +// metadata after Reference and Extend inheritance is applied. Goa compares a +// detached finalized copy and rejects incompatible definitions during design +// validation without changing the authored declarations. // // See Attribute for details on the Error arguments. // diff --git a/dsl/grpc.go b/dsl/grpc.go index e494b4458d..4f8c3f25bc 100644 --- a/dsl/grpc.go +++ b/dsl/grpc.go @@ -1,3 +1,5 @@ +// This file defines the gRPC transport DSL for endpoint messages, metadata, +// status responses, streaming behavior, and protobuf field mappings. package dsl import ( @@ -251,7 +253,10 @@ func Message(fn func()) { // typed stream frame rather than being rewritten into metadata. // // Metadata takes one argument of function type which lists the attributes -// that must be set in the request metadata instead of the message. +// that must be set in the request metadata instead of the message. Each +// selected attribute must have an effective primitive type or an array whose +// elements have an effective primitive type. Named aliases are accepted and +// converted to the native metadata value by generated client and server code. // If Metadata is set in the gRPC endpoint expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method payload. @@ -302,6 +307,9 @@ func Metadata(fn func()) { // // Trailers takes one argument of function type which lists the attributes // that must be set in the trailer response metadata instead of the message. +// Each selected attribute must have an effective primitive type or an array +// whose elements have an effective primitive type. Named aliases are accepted +// and converted to the native metadata value by generated code. // If Trailers is set in the gRPC response expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method result. diff --git a/dsl/headers.go b/dsl/headers.go index 8828cd6586..2f351cbdab 100644 --- a/dsl/headers.go +++ b/dsl/headers.go @@ -1,3 +1,5 @@ +// This file defines HTTP request and response header DSL and the shared entry +// point used to select gRPC response metadata fields. package dsl import ( @@ -9,7 +11,9 @@ import ( // When used in a HTTP expression, it groups a set of Header expressions and // makes it possible to list required headers using the Required function. // When used in a GRPC response expression, it defines the headers to be sent -// in the response metadata. +// in the response metadata. A gRPC response header must have an effective +// primitive type or an array whose elements have an effective primitive type; +// generated codecs convert named service aliases to and from native values. // // To define HTTP headers, Headers must appear in an Service HTTP expression // to define request headers common to all the service methods. Headers may diff --git a/expr/error_contract.go b/expr/error_contract.go index b7bc8964d7..a6852fe7ee 100644 --- a/expr/error_contract.go +++ b/expr/error_contract.go @@ -16,6 +16,15 @@ type ( first *AttributeExpr second *AttributeExpr } + + // effectiveErrorCopier owns a detached graph while inherited error + // attributes are materialized for comparison. Both maps are keyed by the + // source node so recursive declarations and inheritance edges reconnect to + // their copied counterparts. + effectiveErrorCopier struct { + attributes map[*AttributeExpr]*AttributeExpr + userTypes map[UserType]UserType + } ) // equivalentErrorAttributes reports whether two error attributes generate the @@ -29,9 +38,232 @@ func equivalentErrorAttributes(first, second *AttributeExpr) bool { if first == nil || second == nil { return false } + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) return equivalentErrorAttributeNodes(first, second, make(map[attributePair]struct{})) } +// effectiveErrorAttribute returns a detached copy with References and Bases +// applied by AttributeExpr.Finalize. Validation can therefore compare the +// value contracts code generation will see without mutating evaluated design. +func effectiveErrorAttribute(source *AttributeExpr) *AttributeExpr { + copier := &effectiveErrorCopier{ + attributes: make(map[*AttributeExpr]*AttributeExpr), + userTypes: make(map[UserType]UserType), + } + result := copier.attribute(source) + result.Finalize() + return result +} + +// attribute copies one attribute shell before following its type and +// inheritance edges so self-recursive graphs terminate on the copied shell. +func (c *effectiveErrorCopier) attribute(source *AttributeExpr) *AttributeExpr { + if source == nil { + return nil + } + if copied, ok := c.attributes[source]; ok { + return copied + } + copied := &AttributeExpr{ + Description: source.Description, + DefaultValue: cloneErrorContractValue(source.DefaultValue), + DSLFunc: source.DSLFunc, + } + c.attributes[source] = copied + if source.Docs != nil { + docs := *source.Docs + copied.Docs = &docs + } + if source.Validation != nil { + copied.Validation = cloneErrorValidation(source.Validation) + } + if source.Meta != nil { + copied.Meta = source.Meta.Dup() + } + if len(source.UserExamples) > 0 { + copied.UserExamples = make([]*ExampleExpr, len(source.UserExamples)) + for index, example := range source.UserExamples { + copy := *example + copy.Value = cloneErrorContractValue(example.Value) + copied.UserExamples[index] = © + } + } + copied.Type = c.dataType(source.Type) + copied.Bases = c.dataTypes(source.Bases) + copied.References = c.dataTypes(source.References) + return copied +} + +// cloneErrorValidation detaches slices and scalar pointers that +// ValidationExpr.Dup deliberately shares with its source. +func cloneErrorValidation(source *ValidationExpr) *ValidationExpr { + copied := source.Dup() + copied.Values = make([]any, len(source.Values)) + for index, value := range source.Values { + copied.Values[index] = cloneErrorContractValue(value) + } + copied.ExclusiveMinimum = dupFloat(source.ExclusiveMinimum) + copied.Minimum = dupFloat(source.Minimum) + copied.Maximum = dupFloat(source.Maximum) + copied.ExclusiveMaximum = dupFloat(source.ExclusiveMaximum) + copied.MinLength = dupInt(source.MinLength) + copied.MaxLength = dupInt(source.MaxLength) + return copied +} + +// cloneErrorContractValue copies the collection values accepted by defaults, +// enum validations, and examples. Primitive values are immutable and can be +// shared safely. +func cloneErrorContractValue(source any) any { + switch actual := source.(type) { + case Val: + copied := make(Val, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case ArrayVal: + copied := make(ArrayVal, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case MapVal: + copied := make(MapVal, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + default: + return actual + } +} + +// dataTypes reconnects inheritance declarations to the same copied graph used +// by attribute types. +func (c *effectiveErrorCopier) dataTypes(source []DataType) []DataType { + if len(source) == 0 { + return nil + } + copied := make([]DataType, len(source)) + for index, dataType := range source { + copied[index] = c.dataType(dataType) + } + return copied +} + +// dataType copies each concrete type without registering generated result +// types. User-type shells are installed before their attributes are followed. +func (c *effectiveErrorCopier) dataType(source DataType) DataType { + switch actual := source.(type) { + case nil: + return nil + case Primitive: + return actual + case *Object: + copied := make(Object, 0, len(*actual)) + for _, field := range *actual { + copied = append(copied, &NamedAttributeExpr{ + Name: field.Name, + Attribute: c.attribute(field.Attribute), + }) + } + return &copied + case *Array: + return &Array{ + ElemType: c.attribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, + } + case *Map: + return &Map{ + KeyType: c.attribute(actual.KeyType), + ElemType: c.attribute(actual.ElemType), + } + case *Union: + copied := &Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*NamedAttributeExpr, len(actual.Values)), + } + for index, branch := range actual.Values { + copied.Values[index] = &NamedAttributeExpr{ + Name: branch.Name, + Attribute: c.attribute(branch.Attribute), + } + } + return copied + case *ResultTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &ResultTypeExpr{ + UserTypeExpr: &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + }, + Identifier: actual.Identifier, + ContentType: actual.ContentType, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + copied.Views = make([]*ViewExpr, len(actual.Views)) + for index, view := range actual.Views { + copied.Views[index] = &ViewExpr{ + AttributeExpr: c.attribute(view.AttributeExpr), + Name: view.Name, + Parent: copied, + } + } + return copied + case *UserTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + return copied + case UserType: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := actual.Dup(nil) + c.userTypes[origin] = copied + copied.SetAttribute(c.attribute(actual.Attribute())) + return copied + default: + panic("unknown error attribute type") + } +} + // equivalentErrorAttributeNodes compares every contract-bearing node while // stopping when recursive user types revisit the same declaration pair. func equivalentErrorAttributeNodes(first, second *AttributeExpr, seen map[attributePair]struct{}) bool { @@ -88,15 +320,9 @@ func equivalentErrorAttributeNodes(first, second *AttributeExpr, seen map[attrib len(firstType.Values) != len(secondType.Values) { return false } - for _, branch := range firstType.Values { - var other *AttributeExpr - for _, candidate := range secondType.Values { - if candidate.Name == branch.Name { - other = candidate.Attribute - break - } - } - if other == nil || !equivalentErrorAttributeNodes(branch.Attribute, other, seen) { + for index, branch := range firstType.Values { + other := secondType.Values[index] + if branch.Name != other.Name || !equivalentErrorAttributeNodes(branch.Attribute, other.Attribute, seen) { return false } } diff --git a/expr/error_contract_test.go b/expr/error_contract_test.go index 74a1ae7323..f34a98081b 100644 --- a/expr/error_contract_test.go +++ b/expr/error_contract_test.go @@ -30,6 +30,231 @@ func TestEquivalentErrorAttributesIgnoreRequiredOrder(t *testing.T) { require.True(t, equivalentErrorAttributes(first, second)) } +func TestEquivalentErrorAttributesMaterializeBases(t *testing.T) { + base := &UserTypeExpr{ + TypeName: "BaseError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + composed := &AttributeExpr{Type: &Object{}, Bases: []DataType{base}} + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(composed, explicit)) + require.Len(t, composed.Bases, 1) + require.False(t, composed.finalized) + require.Empty(t, *AsObject(composed.Type)) + require.False(t, base.AttributeExpr.finalized) +} + +func TestEquivalentErrorAttributesRejectDifferentEffectiveBases(t *testing.T) { + stringBase := errorBase("Base", String) + integerBase := errorBase("Base", Int) + first := &AttributeExpr{Type: &Object{}, Bases: []DataType{stringBase}} + second := &AttributeExpr{Type: &Object{}, Bases: []DataType{integerBase}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesMaterializeReferences(t *testing.T) { + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(referenced, explicit)) + require.Len(t, referenced.References, 1) + require.Nil(t, referenced.Find("message").Validation) + require.Nil(t, referenced.Find("message").DefaultValue) +} + +func TestEquivalentErrorAttributesRejectReferenceContractDrift(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*AttributeExpr) + }{ + {"validation", func(attribute *AttributeExpr) { + minimum := 3 + attribute.Validation.MinLength = &minimum + }}, + {"default", func(attribute *AttributeExpr) { attribute.DefaultValue = "different" }}, + {"metadata", func(attribute *AttributeExpr) { + attribute.Meta["struct:field:name"] = []string{"Different"} + }}, + } { + t.Run(test.name, func(t *testing.T) { + referenced, explicit := referencedErrorContracts() + test.mutate(explicit.Find("message")) + + require.False(t, equivalentErrorAttributes(referenced, explicit)) + }) + } +} + +func TestEquivalentErrorAttributesCompareUnionBranchesPositionally(t *testing.T) { + first := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + }}} + second := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + }}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesCopiesRecursiveDeclarations(t *testing.T) { + first := recursiveErrorType("RecursiveError") + second := recursiveErrorType("RecursiveError") + + require.True(t, equivalentErrorAttributes( + &AttributeExpr{Type: first}, + &AttributeExpr{Type: second}, + )) + require.False(t, first.AttributeExpr.finalized) + require.Same(t, first, first.Find("next").Type) +} + +func TestEffectiveErrorAttributeSharesNoMutableContractValues(t *testing.T) { + minimum := 2 + source := &AttributeExpr{ + Type: String, + Docs: &DocsExpr{Description: "source"}, + Validation: &ValidationExpr{ + MinLength: &minimum, + Values: []any{"first", "second"}, + }, + DefaultValue: []any{map[string]any{"message": "invalid"}}, + UserExamples: []*ExampleExpr{{Value: map[string]any{"message": "invalid"}}}, + } + + effective := effectiveErrorAttribute(source) + *effective.Validation.MinLength = 5 + effective.Validation.Values[0] = "changed" + effective.DefaultValue.([]any)[0].(map[string]any)["message"] = "changed" + effective.Docs.Description = "changed" + effective.UserExamples[0].Value.(map[string]any)["message"] = "changed" + + require.Equal(t, 2, *source.Validation.MinLength) + require.Equal(t, "first", source.Validation.Values[0]) + require.Equal(t, "invalid", source.DefaultValue.([]any)[0].(map[string]any)["message"]) + require.Equal(t, "source", source.Docs.Description) + require.Equal(t, "invalid", source.UserExamples[0].Value.(map[string]any)["message"]) +} + +func TestEffectiveErrorAttributeReconnectsCopiesByOrigin(t *testing.T) { + source := recursiveErrorType("RecursiveError") + first := Dup(source).(UserType) + second := Dup(source).(UserType) + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + effective := effectiveErrorAttribute(root) + firstCopy := effective.Find("first").Type.(UserType) + secondCopy := effective.Find("second").Type.(UserType) + + require.Same(t, firstCopy, secondCopy) + require.Same(t, firstCopy, firstCopy.Origin()) + require.NotSame(t, source, firstCopy.Origin()) +} + +// errorBase returns an object declaration whose only field has the given +// primitive type. The shared type name proves comparison uses effective shape. +func errorBase(name string, fieldType DataType) *UserTypeExpr { + return &UserTypeExpr{ + TypeName: name, + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "value", Attribute: &AttributeExpr{Type: fieldType}}, + }}, + } +} + +// recursiveErrorType returns an unfinalized self-referential declaration. +func recursiveErrorType(name string) *UserTypeExpr { + result := &UserTypeExpr{TypeName: name} + result.AttributeExpr = &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + {Name: "next", Attribute: &AttributeExpr{Type: result}}, + }} + return result +} + +// referencedErrorContracts returns one inherited and one explicit error with +// the same field validation, default, and generated Go name. +func referencedErrorContracts() (*AttributeExpr, *AttributeExpr) { + referenceMinimum := 2 + field := &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &referenceMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + } + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: field}, + }}, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicitMinimum := 2 + explicit := &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &explicitMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }} + return referenced, explicit +} + // requiredObject returns the same two-field error object with the requested // validation order so the test distinguishes authored order from semantics. func requiredObject(required ...string) *AttributeExpr { diff --git a/expr/grpc_endpoint.go b/expr/grpc_endpoint.go index 2e1d723279..c6bf7bd410 100644 --- a/expr/grpc_endpoint.go +++ b/expr/grpc_endpoint.go @@ -572,6 +572,8 @@ func validateMetadata(metAtt *MappedAttributeExpr, serviceAtt *AttributeExpr, e for _, nat := range *AsObject(metAtt.Type) { if a := serviceAtt.Find(nat.Name); a == nil { verr.Add(e, "%s metadata attribute %q is not found in %s", metKind, nat.Name, serviceKind) + } else if !isMetadataEncodable(a.Type) { + verr.Add(e, "%s metadata attribute %q must be a primitive or an array of primitives, got %s", metKind, nat.Name, a.Type.Name()) } } } else { diff --git a/expr/grpc_endpoint_test.go b/expr/grpc_endpoint_test.go index 953e1430d0..357f691e5b 100644 --- a/expr/grpc_endpoint_test.go +++ b/expr/grpc_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies gRPC endpoint preparation and validation, including the +// native primitive contract required by request and response metadata. package expr_test import ( @@ -40,6 +42,15 @@ service "Service" gRPC endpoint "Method": field number 2 in attribute "key_dup_i DSL: testdata.GRPCEndpointWithExtendedTypes, Errors: []string{}, }, + "endpoint-with-composite-metadata": { + DSL: testdata.GRPCEndpointWithCompositeMetadata, + Errors: []string{`service "Service" gRPC endpoint "Method": Request metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Request metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Request metadata attribute "choice" must be a primitive or an array of primitives, got choice +service "Service" gRPC endpoint "Method": Response metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Response metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Response metadata attribute "choice" must be a primitive or an array of primitives, got choice`}, + }, "endpoint-with-inherit-error": { DSL: testdata.GRPCEndpointWithInheritErrorDSL, Errors: []string{}, diff --git a/expr/http_body_types.go b/expr/http_body_types.go index 974e07e0e2..933e1c4029 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -131,13 +131,12 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { // by removing the attributes of the method payload used to define headers and // parameters. func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { - const suffix = "RequestBody" var ( name = concat(a.Name(), "Request", "Body") ) if a.Body != nil { a.Body = DupAtt(a.Body) - renameType(a.Body, name, suffix) + renameType(a.Body, name) if ut, ok := a.Body.Type.(*UserTypeExpr); ok { ut.UID = a.Service.Name() + "#" + name } @@ -161,7 +160,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { if bodyOnly { payload = DupAtt(payload) RemovePkgPath(payload) - renameType(payload, name, suffix) + renameType(payload, name) return payload } return &AttributeExpr{Type: Empty} @@ -193,8 +192,6 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { TypeName: name, UID: a.Service.Name() + "#" + a.Name(), } - appendSuffix(ut.Attribute().Type, suffix) - if t, ok := payload.Type.(UserType); ok { copyOpenAPITypeMeta(t, ut) } @@ -216,7 +213,6 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { if !IsObject(att.Type) { return DupAtt(att) } - const suffix = "StreamingBody" dupped := DupAtt(att) // Method attributes that reference user types keep validation on the // referenced type. Promote it to the computed body so HTTP type generation @@ -227,7 +223,6 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { } } RemovePkgPath(dupped) - appendSuffix(dupped.Type, suffix) ut := &UserTypeExpr{ AttributeExpr: dupped, TypeName: concat(e.Name(), "Streaming", "Body"), @@ -266,7 +261,6 @@ func httpErrorResponseBody(e *HTTPEndpointExpr, v *HTTPErrorExpr) *AttributeExpr } func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, svc *HTTPServiceExpr) *AttributeExpr { - const suffix = "ResponseBody" name = concat(name, "Response", "Body") if attr == nil || attr.Type == Empty { return &AttributeExpr{Type: Empty} @@ -284,7 +278,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE return &AttributeExpr{Type: Empty} } att := DupAtt(resp.Body) - renameType(att, name, suffix) + renameType(att, name) if ut, ok := att.Type.(*UserTypeExpr); ok { ut.UID = svc.Name() + "#" + name } @@ -302,7 +296,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE if resp.Headers.IsEmpty() && resp.Cookies.IsEmpty() { attr = DupAtt(attr) RemovePkgPath(attr) - renameType(attr, name, "Response") // Do not use ResponseBody as it could clash with name of element + renameType(attr, name) return attr } return &AttributeExpr{Type: Empty} @@ -343,7 +337,6 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE copyOpenAPITypeMeta(t, userType) } - appendSuffix(userType.Attribute().Type, suffix) rt, isrt := attr.Type.(*ResultTypeExpr) if !isrt { return &AttributeExpr{ @@ -451,19 +444,10 @@ func concat(strs ...string) string { return name } -func renameType(att *AttributeExpr, name, suffix string) { +func renameType(att *AttributeExpr, name string) { RemovePkgPath(att) - rt := att.Type - switch rtt := rt.(type) { - case UserType: - rtt.Rename(name) - appendSuffix(rtt.Attribute().Type, suffix) - case *Object: - appendSuffix(rt, suffix) - case *Array: - appendSuffix(rt, suffix) - case *Map: - appendSuffix(rt, suffix) + if userType, ok := att.Type.(UserType); ok { + userType.Rename(name) } } @@ -480,14 +464,6 @@ func RemovePkgPath(attr *AttributeExpr) { } } -// appendSuffix recursively traverses the given data type and appends the given -// suffix to all the user type names. -func appendSuffix(dt DataType, suffix string) { - walk(dt, func(ut UserType) { - ut.Rename(ut.Name() + suffix) - }) -} - func removeAttributes(attr, sub *MappedAttributeExpr) { o := AsObject(sub.Type) for _, nat := range *o { @@ -551,13 +527,8 @@ func walkrec(dt DataType, do func(UserType), seen map[UserType]struct{}) { if _, ok := seen[origin]; ok { return } - // Mark the declaration before invoking do because callbacks such as - // appendSuffix rename the declaration and deliberately detach its origin. seen[origin] = struct{}{} do(dt) - // A callback may detach a copied declaration from its source. Remember the - // resulting declaration too so recursive references do not process it again. - seen[dt.Origin()] = struct{}{} walkrec(dt.Attribute().Type, do, seen) case *Object: for _, nat := range *dt { diff --git a/expr/http_body_types_test.go b/expr/http_body_types_test.go index f2be49ff5e..db72618b1f 100644 --- a/expr/http_body_types_test.go +++ b/expr/http_body_types_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP body graph rewrites visit independent declarations +// and terminate when recursive copies return to their authored origin. package expr import ( @@ -97,34 +99,3 @@ func TestRemovePkgPathDistinguishesEqualUIDOrigins(t *testing.T) { require.NotContains(t, first.Attribute().Meta, "struct:pkg:path") require.NotContains(t, second.Attribute().Meta, "struct:pkg:path") } - -func TestAppendSuffixDistinguishesEqualUIDOriginsAndTerminatesRecursion(t *testing.T) { - first := &UserTypeExpr{TypeName: "First", UID: "shared"} - firstObject := &Object{} - first.AttributeExpr = &AttributeExpr{Type: firstObject} - firstObject.Set("self", &AttributeExpr{Type: first}) - second := &UserTypeExpr{ - AttributeExpr: &AttributeExpr{Type: &Object{}}, - TypeName: "Second", - UID: "shared", - } - root := &Object{ - {Name: "first", Attribute: &AttributeExpr{Type: first}}, - {Name: "second", Attribute: &AttributeExpr{Type: second}}, - } - - appendSuffix(root, "Body") - require.Equal(t, "FirstBody", first.Name()) - require.Equal(t, "SecondBody", second.Name()) -} - -func TestAppendSuffixTerminatesAfterRecursiveCopyDetachesOrigin(t *testing.T) { - original := &UserTypeExpr{TypeName: "Recursive"} - object := &Object{} - original.AttributeExpr = &AttributeExpr{Type: object} - object.Set("self", &AttributeExpr{Type: original}) - copy := Dup(original).(UserType) - - appendSuffix(copy, "Body") - require.Equal(t, "RecursiveBody", copy.Name()) -} diff --git a/expr/testdata/endpoint_dsls.go b/expr/testdata/endpoint_dsls.go index ae54cab42c..0e16481496 100644 --- a/expr/testdata/endpoint_dsls.go +++ b/expr/testdata/endpoint_dsls.go @@ -1,3 +1,5 @@ +// This file defines reusable endpoint designs that exercise HTTP and gRPC +// preparation, validation, inheritance, streaming, and metadata behavior. package testdata import ( @@ -714,6 +716,46 @@ var GRPCEndpointWithExtendedTypes = func() { }) } +var GRPCEndpointWithCompositeMetadata = func() { + objectValue := Type("MetadataObject", func() { + Attribute("name", String) + }) + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + Result(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + GRPC(func() { + Metadata(func() { + Attribute("object") + Attribute("mapping") + Attribute("choice") + }) + Response(func() { + Headers(func() { Attribute("object") }) + Trailers(func() { + Attribute("mapping") + Attribute("choice") + }) + }) + }) + }) + }) +} + var GRPCEndpointWithInheritErrorDSL = func() { API("API", func() { Error("not_found") diff --git a/expr/transport_error_contract_test.go b/expr/transport_error_contract_test.go index 9f40cf0dc1..cadac768d9 100644 --- a/expr/transport_error_contract_test.go +++ b/expr/transport_error_contract_test.go @@ -61,6 +61,36 @@ func TestHTTPInheritedErrorMappingAcceptsEquivalentValidationOrder(t *testing.T) require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) } +func TestHTTPInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPInheritedErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestGRPCInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCInheritedErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestInheritedErrorMappingRejectsDifferentEffectiveBases(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"HTTP", incompatibleHTTPInheritedErrorMappingDSL}, + {"gRPC", incompatibleGRPCInheritedErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `error mapping "bad_request"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + func TestGRPCInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { err := expr.RunInvalidDSL(t, incompatibleGRPCErrorMappingDSL) require.ErrorContains(t, err, `gRPC error mapping "bad_request"`) @@ -187,3 +217,83 @@ var equivalentServiceErrorMappingDSL = func() { }) }) } + +var equivalentHTTPInheritedErrorMappingDSL = func() { + base := Type("HTTPErrorBase", func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentGRPCInheritedErrorMappingDSL = func() { + base := Type("GRPCErrorBase", func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + GRPC(func() {}) + }) + }) +} + +var incompatibleHTTPInheritedErrorMappingDSL = func() { + stringBase := Type("HTTPStringErrorBase", func() { Attribute("value", String) }) + integerBase := Type("HTTPIntegerErrorBase", func() { Attribute("value", Int) }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleGRPCInheritedErrorMappingDSL = func() { + stringBase := Type("GRPCStringErrorBase", func() { + Attribute("value", String, func() { Meta("rpc:tag", "1") }) + }) + integerBase := Type("GRPCIntegerErrorBase", func() { + Attribute("value", Int, func() { Meta("rpc:tag", "1") }) + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index f8f5fb6408..a70361503c 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -138,6 +138,7 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code } sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} fm := transTmplFuncs(svc, services) + fm["hasInitArg"] = hasInitArg fm["metadataEncodeDecodeData"] = metadataEncodeDecodeData fm["typeConversionData"] = typeConversionData fm["isBearer"] = isBearer @@ -168,6 +169,18 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code return &codegen.File{Path: fpath, SectionTemplates: sections} } +// hasInitArg reports whether a generated constructor consumes the named +// source variable. Templates use it to avoid binding an empty protobuf +// message that only carries response metadata. +func hasInitArg(args []*InitArgData, name string) bool { + for _, arg := range args { + if arg.Name == name { + return true + } + } + return false +} + // isBearer returns true if the security scheme uses a Bearer scheme. func isBearer(schemes []*service.SchemeData) bool { for _, s := range schemes { diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 3a20a6a56e..5e60b40673 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -143,10 +143,12 @@ func makeFlags(e *EndpointData, args []*InitArgData) ([]*cli.FlagData, *cli.Buil pInitArgs := make([]*codegen.InitArgData, len(args)) for i, arg := range args { pInitArgs[i] = &codegen.InitArgData{ - Name: arg.Name, - FieldName: arg.FieldName, - FieldType: arg.FieldType, - Type: arg.Type, + Name: arg.Name, + FieldName: arg.FieldName, + FieldType: arg.FieldType, + Type: arg.Type, + Pointer: arg.Pointer, + FieldPointer: arg.Pointer, } fargs[i] = &cli.FlagArgData{ Name: arg.Name, diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go index 691bde84b2..1d181f81ae 100644 --- a/grpc/codegen/proto_test.go +++ b/grpc/codegen/proto_test.go @@ -1,3 +1,5 @@ +// This file verifies generated protobuf services and message declarations, +// including package-owned naming collisions, by compiling each schema. package codegen import ( @@ -35,6 +37,7 @@ func TestProtoFiles(t *testing.T) { {"protofiles-struct-meta-type", testdata.StructMetaTypeDSL}, {"protofiles-default-fields", testdata.DefaultFieldsDSL}, {"protofiles-custom-message-name", testdata.CustomMessageNameDSL}, + {"protofiles-distinct-custom-message-names", testdata.DistinctCustomMessageNamesDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go index 0242ab52ac..6ebf69b5df 100644 --- a/grpc/codegen/protobuf_catalog.go +++ b/grpc/codegen/protobuf_catalog.go @@ -23,7 +23,7 @@ type ( messageUses map[*expr.AttributeExpr]*protobufMessageRecord unions []*protobufUnionRecord unionUses map[*expr.AttributeExpr]*protobufUnionRecord - syntheticSources map[expr.UserType]protobufMessageSource + rootSources map[expr.UserType]protobufMessageSource reservedNames []string validators []*protobufValidationRecord frozen bool @@ -132,10 +132,10 @@ const ( // generated protobuf package. func newProtobufPackageCatalog(packageName string) *protobufPackageCatalog { return &protobufPackageCatalog{ - packageName: packageName, - messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), - unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), - syntheticSources: make(map[expr.UserType]protobufMessageSource), + packageName: packageName, + messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), + unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), + rootSources: make(map[expr.UserType]protobufMessageSource), } } @@ -148,19 +148,23 @@ func (c *protobufPackageCatalog) reserveName(name string) { c.reservedNames = append(c.reservedNames, name) } -// bindSyntheticSource associates a shaped synthetic declaration and all of -// its later copies with the endpoint role that created it. -func (c *protobufPackageCatalog) bindSyntheticSource(attribute *expr.AttributeExpr, source protobufMessageSource) { +// bindRootSource associates a shaped root declaration and all of its later +// copies with the authored service declaration or synthetic endpoint role +// whose value it carries. +func (c *protobufPackageCatalog) bindRootSource(attribute *expr.AttributeExpr, source protobufMessageSource) { + if attribute.Type == expr.Empty { + return + } userType, ok := attribute.Type.(expr.UserType) - if !ok || source.synthetic.role == 0 { + if !ok || (source.origin == nil && source.synthetic.role == 0) { return } - c.syntheticSources[userType.Origin()] = source + c.rootSources[userType.Origin()] = source } // collectMessage records every protobuf declaration reachable from attribute. -// source identifies a synthetic root; nested authored declarations retain -// their own origins. +// source identifies the service declaration or synthetic role carried by the +// root; nested authored declarations retain their own origins. func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource, sd *ServiceData) []string { if c.frozen { panic("cannot collect a protobuf message after the package catalog is frozen") @@ -300,7 +304,7 @@ func (c *protobufPackageCatalog) message(attribute *expr.AttributeExpr) *protobu } userType := attribute.Type.(expr.UserType) source := protobufMessageSource{origin: userType.Origin()} - if synthetic, ok := c.syntheticSources[userType.Origin()]; ok { + if synthetic, ok := c.rootSources[userType.Origin()]; ok { source = synthetic } identity := protobufMessageIdentityFor(attribute, source) @@ -387,17 +391,17 @@ func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.Attribu case expr.UserType: origin := actual.Origin() identitySource := protobufMessageSource{origin: origin} - if synthetic, ok := c.syntheticSources[origin]; ok { - identitySource = synthetic + if rootSource, ok := c.rootSources[origin]; ok { + identitySource = rootSource } if !root && len(actual.Attribute().Meta[wrappedAttrMeta]) > 0 { identitySource = protobufMessageSource{synthetic: protobufSyntheticMessage{ role: protobufWrapperMessage, }} } - if root && source.synthetic.role != 0 { + if root && (source.origin != nil || source.synthetic.role != 0) { identitySource = source - c.syntheticSources[origin] = source + c.rootSources[origin] = source } identity := protobufMessageIdentityFor(attribute, identitySource) record := c.findMessage(identity) @@ -628,16 +632,9 @@ func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMe // sameProtobufMessageIdentity compares the typed source and every protobuf // schema fact rather than expression hashes or generated names. func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { - if left.preferredName != right.preferredName || left.explicitName != right.explicitName { + if left.source != right.source || left.preferredName != right.preferredName || left.explicitName != right.explicitName { return false } - if left.source != right.source { - // An explicit protobuf name declares intentional reuse across endpoint - // roles or authored origins when the complete wire schemas also match. - if !left.explicitName { - return false - } - } return sameProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) } diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index d9269f3825..1065eb55c8 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -1,3 +1,5 @@ +// This file verifies protobuf wire shaping, naming, JSON options, wrappers, +// and recursion follow generated-package declaration ownership. package codegen import ( diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index c68240aa0f..a1fd95795e 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -149,6 +149,9 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } + if responseMetadataNeedsFormat(data) { + imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) + } sections = []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} for _, e := range data.Endpoints { @@ -178,6 +181,21 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code return &codegen.File{Path: fpath, SectionTemplates: sections} } +// responseMetadataNeedsFormat reports whether a response header or trailer +// serializes a non-string scalar through fmt.Sprintf. +func responseMetadataNeedsFormat(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + for _, group := range [][]*MetadataData{endpoint.Response.Headers, endpoint.Response.Trailers} { + for _, metadata := range group { + if !metadata.Slice && metadata.TypeName != "string" && metadata.Type.Name() != "bytes" { + return true + } + } + } + } + return false +} + func transTmplFuncs(s *expr.GRPCServiceExpr, services *ServicesData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 5625566c95..d536934def 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -5,6 +5,7 @@ package codegen import ( "fmt" "path" + "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" @@ -142,12 +143,20 @@ type ( FieldName string // FieldType is the type of the struct field. FieldType expr.DataType - // FieldTypeRef is the frozen service reference used to cast an aliased - // metadata value before assigning it to the service field. - FieldTypeRef string + // ServiceAttribute is the service field populated from this metadata. + ServiceAttribute *expr.AttributeExpr + // WireAttribute is the detached native gRPC metadata value. + WireAttribute *expr.AttributeExpr // VarName is the name of the Go variable used to read or // convert the metadata value. VarName string + // WireVarName is the local variable produced before metadata encoding. + WireVarName string + // EncodeCode converts the service field to WireVarName. + EncodeCode string + // DecodeCode converts VarName to the service constructor target. The + // constructor replaces metadataTargetPlaceholder with its result variable. + DecodeCode string // TypeName is the name of the type. TypeName string // TypeRef is the reference to the type. @@ -160,11 +169,6 @@ type ( StringSlice bool // Slice is true if the metadata value type is an array. Slice bool - // MapStringSlice is true if the metadata value type is a map of string - // slice. - MapStringSlice bool - // Map is true if the metadata value type is a map. - Map bool // Type describes the datatype of the variable value. Mainly // used for conversion. Type expr.DataType @@ -358,9 +362,8 @@ type ( // FieldType is the type of the data structure field that should be // initialized with the argument if any. FieldType expr.DataType - // FieldTypeRef is the frozen service reference used to cast an aliased - // argument before assigning it to the service field. - FieldTypeRef string + // InitCode converts and assigns this argument to the constructor result. + InitCode string // TypeName is the argument type name. TypeName string // TypeRef is the argument type reference. @@ -433,21 +436,13 @@ type ( validateKind int ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData) *ServicesData { - return &ServicesData{ - ServicesData: services, - GRPCServices: make(map[string]*ServiceData), - } -} - const ( // pbPkgName is the directory name where the .proto file is generated and // compiled. pbPkgName = "pb" -) - -const ( + // metadataTargetPlaceholder marks the constructor result until the same + // metadata record is attached to its concrete conversion function. + metadataTargetPlaceholder = "__goa_metadata_target__" // validateServer generates the validation code for request messages in the // server package. validateServer validateKind = iota + 1 @@ -456,6 +451,14 @@ const ( validateClient ) +// NewServicesData creates a new ServicesData instance for the given service data. +func NewServicesData(services *service.ServicesData) *ServicesData { + return &ServicesData{ + ServicesData: services, + GRPCServices: make(map[string]*ServiceData), + } +} + // Get retrieves the transport data for the service with the given name // computing it if needed. It returns nil if there is no service with the given // name. @@ -589,7 +592,7 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { }) } // pass the metadata as arguments to client CLI args - request.CLIArgs = append(request.CLIArgs, initArgsFromMetadata(reqMD)...) + request.CLIArgs = append(request.CLIArgs, initArgsFromMetadata(reqMD, "")...) switch { case requestEnvelope != nil: request.Message = collect(requestEnvelope) @@ -729,11 +732,11 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) requestSource := protobufRootMessageSource(endpoint.Request, endpoint, nil, protobufRequestMessage) streamingSource := protobufRootMessageSource(endpoint.StreamingRequest, endpoint, nil, protobufStreamingRequestMessage) responseSource := protobufRootMessageSource(endpoint.Response.Message, endpoint, nil, protobufResponseMessage) - sd.protobuf.bindSyntheticSource(messages.request, requestSource) + sd.protobuf.bindRootSource(messages.request, requestSource) if messages.streamingRequest.Type != expr.Empty { - sd.protobuf.bindSyntheticSource(messages.streamingRequest, streamingSource) + sd.protobuf.bindRootSource(messages.streamingRequest, streamingSource) } - sd.protobuf.bindSyntheticSource(messages.response, responseSource) + sd.protobuf.bindRootSource(messages.response, responseSource) for _, grpcError := range endpoint.GRPCErrors { message := messages.errors[grpcError.Name] if message == nil { @@ -745,7 +748,7 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) grpcError, protobufErrorMessage, ) - sd.protobuf.bindSyntheticSource(message, errorSource) + sd.protobuf.bindRootSource(message, errorSource) collect(message, errorSource) } requestNeeded := !isEmpty(endpoint.Request.Type) || @@ -758,7 +761,7 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) endpoint: endpoint, role: protobufStreamEnvelopeMessage, }} - sd.protobuf.bindSyntheticSource(messages.requestEnvelope, envelopeSource) + sd.protobuf.bindRootSource(messages.requestEnvelope, envelopeSource) collect(messages.requestEnvelope, envelopeSource) } if messages.streamingRequest.Type != expr.Empty { @@ -778,8 +781,10 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) if sd.protobuf.message(messages.response) != nil { sd.protobuf.collectValidation(messages.response, validateClient, "message", "message") } - for _, message := range messages.errors { - sd.protobuf.collectValidation(message, validateClient, "errmsg", "errmsg") + for _, grpcError := range endpoint.GRPCErrors { + if message := messages.errors[grpcError.Name]; message != nil { + sd.protobuf.collectValidation(message, validateClient, "errmsg", "errmsg") + } } if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { sd.protobuf.collectValidation(messages.streamingRequest, validateServer, "stream", "stream") @@ -789,9 +794,29 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) return prepared } -// protobufRootMessageSource retains authored declaration provenance and uses a -// typed endpoint role only when message shaping created the root declaration. +// protobufRootMessageSource identifies a root message by the authored service +// declaration whose value it carries. Endpoint roles identify only messages +// whose service value is inline or compiler-created. The shaped wire attribute +// is a fallback because explicit Message DSL may itself name a declaration. func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRPCEndpointExpr, grpcError *expr.GRPCErrorExpr, role protobufSyntheticRole) protobufMessageSource { + var serviceAttribute *expr.AttributeExpr + switch role { + case protobufRequestMessage: + serviceAttribute = endpoint.MethodExpr.Payload + case protobufStreamingRequestMessage: + serviceAttribute = endpoint.MethodExpr.StreamingPayload + case protobufResponseMessage: + serviceAttribute = endpoint.MethodExpr.Result + case protobufErrorMessage: + if methodError := endpoint.MethodExpr.Error(grpcError.Name); methodError != nil { + serviceAttribute = methodError.AttributeExpr + } + } + if serviceAttribute != nil { + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} + } + } if userType, ok := attribute.Type.(expr.UserType); ok { return protobufMessageSource{origin: userType.Origin()} } @@ -866,7 +891,7 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) // pass the metadata as arguments to payload constructor in server - data.Args = append(data.Args, initArgsFromMetadata(md)...) + data.Args = append(data.Args, initArgsFromMetadata(md, data.ReturnVarName)...) return &ConvertData{ SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd), SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd), @@ -923,7 +948,7 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, sd.Service.Method(e.Name()).Payload, false, false, sd) init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) - init.Args = append(init.Args, initArgsFromMetadata(md)...) + init.Args = append(init.Args, initArgsFromMetadata(md, init.ReturnVarName)...) data.ServerConvert = &ConvertData{ TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), @@ -969,9 +994,9 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) // pass the headers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) + data.Args = append(data.Args, initArgsFromMetadata(hdrs, data.ReturnVarName)...) // pass the trailers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) + data.Args = append(data.Args, initArgsFromMetadata(trlrs, data.ReturnVarName)...) return &ConvertData{ SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd), SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd), @@ -1266,15 +1291,13 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques // metadata attribute and service type (payload/result). func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side string) []*MetadataData { var metadata []*MetadataData - scope := sd.Service.Scope - ctx := d.serviceTypeContext(sd, side).Enter(service) codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck - arr := expr.AsArray(c.Type) - mp := expr.AsMap(c.Type) - typeRef := scope.GoTypeRef(unalias(c)) + wire := nativeMetadataAttribute(c) + arr := expr.AsArray(wire.Type) + wireCtx := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()).Enter(wire) serviceField := service ft := service.Type - varn := scope.Name(codegen.Goify(name, false)) + varn := codegen.Goify(name, false) fieldName := codegen.Goify(name, true) var pointer bool if !expr.IsObject(service.Type) { @@ -1284,50 +1307,107 @@ func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *exp serviceField = service.Find(name) ft = serviceField.Type } + typeRef := wireCtx.Scope.Ref(wire, wireCtx.Pkg(wire)) if pointer { typeRef = "*" + typeRef } - fieldContext := ctx.Enter(serviceField) + serviceVar := "payload" + encodeSide := "client" + if side == "client" { + serviceVar = "result" + encodeSide = "server" + } + fieldRef := serviceVar + targetRef := metadataTargetPlaceholder + if fieldName != "" { + fieldRef += "." + fieldName + targetRef += "." + fieldName + } + wireVar := varn + "Wire" + encodeCode := d.metadataTransform(wire, serviceField, fieldRef, wireVar, sd, encodeSide, pointer, true) + decodeCode := d.metadataTransform(wire, serviceField, varn, targetRef, sd, side, pointer, false) metadata = append(metadata, &MetadataData{ - Name: elem, - AttributeName: name, - Description: c.Description, - FieldName: fieldName, - FieldType: ft, - FieldTypeRef: fieldContext.Scope.Ref(serviceField, fieldContext.Pkg(serviceField)), - VarName: varn, - Required: required, - Type: c.Type, - TypeName: scope.GoTypeName(unalias(c)), - TypeRef: typeRef, - Pointer: pointer, - Slice: arr != nil, - StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, - Map: mp != nil, - MapStringSlice: mp != nil && - mp.KeyType.Type.Kind() == expr.StringKind && - mp.ElemType.Type.Kind() == expr.ArrayKind && - expr.AsArray(mp.ElemType.Type).ElemType.Type.Kind() == expr.StringKind, - Validate: codegen.AttributeValidationCode(c, nil, ctx, required, false, varn, name), - DefaultValue: c.DefaultValue, - Example: c.Example(d.Root.API.ExampleGenerator.Field(service, name)), + Name: elem, + AttributeName: name, + Description: wire.Description, + FieldName: fieldName, + FieldType: ft, + ServiceAttribute: serviceField, + WireAttribute: wire, + VarName: varn, + WireVarName: wireVar, + EncodeCode: encodeCode, + DecodeCode: decodeCode, + Required: required, + Type: wire.Type, + TypeName: wireCtx.Scope.Name(wire, wireCtx.Pkg(wire), false, true), + TypeRef: typeRef, + Pointer: pointer, + Slice: arr != nil, + StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, + Validate: codegen.AttributeValidationCode(wire, nil, wireCtx, required, false, varn, name), + DefaultValue: wire.DefaultValue, + Example: wire.Example(d.Root.API.ExampleGenerator.Field(service, name)), }) return nil }) return metadata } +// metadataTransform generates the canonical conversion between a detached +// metadata value and its service field in the package that renders the code. +func (d *ServicesData) metadataTransform(wire, serviceField *expr.AttributeExpr, sourceVar, targetVar string, sd *ServiceData, side string, pointer, encode bool) string { + wireCtx := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()).Enter(wire) + serviceCtx := d.serviceTypeContext(sd, side).Enter(serviceField) + source, target := wire, serviceField + sourceCtx, targetCtx := wireCtx, serviceCtx + if encode { + source, target = serviceField, wire + sourceCtx, targetCtx = serviceCtx, wireCtx + } + if pointer { + sourceVar = "*" + sourceVar + } + if encode { + code, helpers, err := codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "", true) + if err != nil { + panic(err) + } + sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + return code + } + if !pointer { + code, helpers, err := codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "", false) + if err != nil { + panic(err) + } + sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + return code + } + converted := codegen.Goify(strings.TrimPrefix(sourceVar, "*"), false) + "Service" + code, helpers, err := codegen.GoTransform(source, target, sourceVar, converted, sourceCtx, targetCtx, "", true) + if err != nil { + panic(err) + } + sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + return "if " + strings.TrimPrefix(sourceVar, "*") + " != nil {\n" + code + "\n" + targetVar + " = &" + converted + "\n}\n" +} + // initArgsFromMetadata converts the given metadata into constructor arguments // so the metadata values can be passed to the generated init functions. -func initArgsFromMetadata(md []*MetadataData) []*InitArgData { +func initArgsFromMetadata(md []*MetadataData, targetVar string) []*InitArgData { args := make([]*InitArgData, len(md)) for i, m := range md { + initCode := "" + if targetVar != "" { + initCode = strings.ReplaceAll(m.DecodeCode, metadataTargetPlaceholder, targetVar) + } args[i] = &InitArgData{ Name: m.VarName, Ref: m.VarName, FieldName: m.FieldName, FieldType: m.FieldType, - FieldTypeRef: m.FieldTypeRef, + InitCode: initCode, TypeName: m.TypeName, TypeRef: m.TypeRef, Type: m.Type, @@ -1395,19 +1475,76 @@ func buildStreamEnvelopeData(envelope *expr.AttributeExpr, message *service.User } } -// unalias returns the underlying attribute of the given attribute when its -// type is a user type, recursing until a non user type is found. Unlike -// unAlias it also resolves user types with non-primitive bases (e.g. named -// arrays) which extractMetadata needs to compute the native metadata type -// references. -func unalias(att *expr.AttributeExpr) *expr.AttributeExpr { - if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := ut.Attribute().Type.(expr.Primitive); ok { - return ut.Attribute() +// nativeMetadataAttribute returns a detached primitive or primitive-array +// value for gRPC metadata. Named service declarations are recursively removed +// while their validation and default contracts remain on the wire copy. +func nativeMetadataAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + if userType, ok := source.Type.(expr.UserType); ok { + result := nativeMetadataAttribute(userType.Attribute()) + mergeNativeMetadataContract(result, source) + return result + } + result := &expr.AttributeExpr{ + Description: source.Description, + DefaultValue: source.DefaultValue, + UserExamples: source.UserExamples, + } + if source.Validation != nil { + result.Validation = source.Validation.Dup() + } + if source.Meta != nil { + result.Meta = source.Meta.Dup() + } + switch actual := source.Type.(type) { + case expr.Primitive: + result.Type = actual + case *expr.Array: + result.Type = &expr.Array{ + ElemType: nativeMetadataAttribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, + } + default: + panic(fmt.Sprintf("invalid gRPC metadata type %s", source.Type.Name())) + } + stripMetadataServiceNames(result) + return result +} + +// mergeNativeMetadataContract applies constraints authored on an alias use to +// the detached contract inherited from the alias declaration. +func mergeNativeMetadataContract(target, source *expr.AttributeExpr) { + if source.Description != "" { + target.Description = source.Description + } + if source.DefaultValue != nil { + target.DefaultValue = source.DefaultValue + } + if source.Validation != nil { + if target.Validation == nil { + target.Validation = source.Validation.Dup() + } else { + target.Validation.Merge(source.Validation) + } + } + if source.Meta != nil { + if target.Meta == nil { + target.Meta = make(expr.MetaExpr) + } + for name, values := range source.Meta { + target.Meta[name] = append([]string(nil), values...) + } + } + stripMetadataServiceNames(target) +} + +// stripMetadataServiceNames removes Go service declaration overrides from a +// value rendered entirely in the generated gRPC client or server package. +func stripMetadataServiceNames(attribute *expr.AttributeExpr) { + for name := range attribute.Meta { + if strings.HasPrefix(name, "struct:") || name == "name:original" { + delete(attribute.Meta, name) } - return unalias(ut.Attribute()) } - return att } // serviceTypeContext returns a context that resolves service declarations from diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go index 18d6b93917..e5c8f4cfdd 100644 --- a/grpc/codegen/service_data_traversal_test.go +++ b/grpc/codegen/service_data_traversal_test.go @@ -103,6 +103,57 @@ func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentSchemas(t *t require.Contains(t, messages[1].Def, "sint32 value = 1") } +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentOrigins(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + +func TestCollectMessagesUsesUnaryResultSourceForMixedResults(t *testing.T) { + firstResult := grpcMessageTraversalType("FirstResult", "first-result", expr.String, "1") + secondResult := grpcMessageTraversalType("SecondResult", "second-result", expr.String, "1") + streamingResult := grpcMessageTraversalType("StreamingResult", "streaming-result", expr.String, "1") + firstEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: firstResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + secondEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: secondResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + firstWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("FirstWire", "first-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + secondWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("SecondWire", "second-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + sd := grpcTraversalServiceData() + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + sd.protobuf.collectMessage(firstWire, protobufRootMessageSource(firstWire, firstEndpoint, nil, protobufResponseMessage), sd) + sd.protobuf.collectMessage(secondWire, protobufRootMessageSource(secondWire, secondEndpoint, nil, protobufResponseMessage), sd) + + messages := sd.protobuf.freezeMessages(sd) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + func TestCollectMessagesStopsAtRecursiveCopy(t *testing.T) { message := grpcMessageTraversalType("Recursive", "recursive", expr.String, "1") object := expr.AsObject(message) diff --git a/grpc/codegen/service_metadata_reference_test.go b/grpc/codegen/service_metadata_reference_test.go index 9111dd5d32..4fb8c2fa08 100644 --- a/grpc/codegen/service_metadata_reference_test.go +++ b/grpc/codegen/service_metadata_reference_test.go @@ -1,5 +1,5 @@ -// This file verifies that gRPC metadata casts use frozen service declaration -// references instead of rebuilding type names from DSL locations. +// This file verifies that gRPC metadata uses detached native wire values and +// canonical conversions to frozen service declarations. package codegen import ( @@ -11,7 +11,7 @@ import ( "goa.design/goa/v3/expr" ) -func TestMetadataFieldTypeRefUsesFrozenServiceDeclaration(t *testing.T) { +func TestMetadataConversionUsesDetachedWireAndFrozenServiceDeclaration(t *testing.T) { root := expr.RunDSL(t, func() { value := dsl.Type("Value", dsl.String, func() { dsl.Meta("struct:pkg:path", "domain/shared") @@ -32,6 +32,46 @@ func TestMetadataFieldTypeRefUsesFrozenServiceDeclaration(t *testing.T) { metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata require.Len(t, metadata, 1) - require.Equal(t, "shared.Value", metadata[0].FieldTypeRef) - require.Equal(t, metadata[0].FieldTypeRef, initArgsFromMetadata(metadata)[0].FieldTypeRef) + require.Equal(t, "string", metadata[0].TypeRef) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "string(payload.Value)") + require.Contains(t, initArgsFromMetadata(metadata, "v")[0].InitCode, "shared.Value(value)") +} + +func TestMetadataConversionRecursivelyDetachesNamedArrayElements(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.Int, func() { + dsl.Enum(1, 2) + dsl.Meta("struct:pkg:path", "domain/shared") + }) + values := dsl.Type("Values", dsl.ArrayOf(value), func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "values", values) + dsl.Required("values") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("values") }) + }) + }) + }) + }) + + serviceField := root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.Payload.Find("values") + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + wireArray := expr.AsArray(metadata[0].WireAttribute.Type) + require.NotNil(t, wireArray) + require.Equal(t, expr.Int, wireArray.ElemType.Type) + require.Equal(t, []any{1, 2}, wireArray.ElemType.Validation.Values) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.NotContains(t, wireArray.ElemType.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "int(val)") + require.Contains(t, initArgsFromMetadata(metadata, "v")[0].InitCode, "shared.Value(val)") + require.Contains(t, serviceField.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") + require.Contains(t, expr.AsArray(serviceField.Type).ElemType.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") } diff --git a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl +++ b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl b/grpc/codegen/templates/partial/convert_type_to_string.go.tpl index 8cefc6771e..bb731296da 100644 --- a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl +++ b/grpc/codegen/templates/partial/convert_type_to_string.go.tpl @@ -22,6 +22,4 @@ {{ .VarName }} := string({{ .Target }}) {{- else if eq .Type.Name "any" -}} {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} {{- end }} diff --git a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl index 1e07c691c4..77b186d578 100644 --- a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -58,6 +58,4 @@ {{ .VarName }}[i] = v {{- else if eq .Type.ElemType.Type.Name "any" }} {{ .VarName }}[i] = rv -{{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/string_conversion.go.tpl b/grpc/codegen/templates/partial/string_conversion.go.tpl index 8cefc6771e..bb731296da 100644 --- a/grpc/codegen/templates/partial/string_conversion.go.tpl +++ b/grpc/codegen/templates/partial/string_conversion.go.tpl @@ -22,6 +22,4 @@ {{ .VarName }} := string({{ .Target }}) {{- else if eq .Type.Name "any" -}} {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} {{- end }} diff --git a/grpc/codegen/templates/partial/type_conversion.go.tpl b/grpc/codegen/templates/partial/type_conversion.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/type_conversion.go.tpl +++ b/grpc/codegen/templates/partial/type_conversion.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/request_encoder.go.tpl b/grpc/codegen/templates/request_encoder.go.tpl index 35ea5fa7d0..3031f8c2a4 100644 --- a/grpc/codegen/templates/request_encoder.go.tpl +++ b/grpc/codegen/templates/request_encoder.go.tpl @@ -5,37 +5,38 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .PayloadRef }}", v) } {{- range .Request.Metadata }} + {{- if .Pointer }} + if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { + {{- end }} + {{ .EncodeCode }} {{- if .StringSlice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { + for _, value := range {{ .WireVarName }} { (*md).Append({{ printf "%q" .Name }}, value) } {{- else if .Slice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { + for _, value := range {{ .WireVarName }} { {{ template "partial_convert_type_to_string" (typeConversionData .Type.ElemType.Type "valueStr" "value") }} (*md).Append({{ printf "%q" .Name }}, valueStr) } {{- else }} - {{- if .Pointer }} - if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { - {{- end }} {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} - if !strings.Contains({{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}, " ") { - (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}) + if !strings.Contains({{ .WireVarName }}, " ") { + (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ .WireVarName }}) } else { {{- end }} (*md).Append({{ printf "%q" .Name }}, {{- if eq .Type.Name "bytes" }} string( {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", {{- end }} - {{- if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} + {{ .WireVarName }} {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) }}) {{- end }}) {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} } {{- end }} - {{- if .Pointer }} - } - {{- end }} + {{- end }} + {{- if .Pointer }} + } {{- end }} {{- end }} {{- if .Request.StreamEnvelope }} diff --git a/grpc/codegen/templates/response_decoder.go.tpl b/grpc/codegen/templates/response_decoder.go.tpl index 9c01e62cb5..ed02cc497a 100644 --- a/grpc/codegen/templates/response_decoder.go.tpl +++ b/grpc/codegen/templates/response_decoder.go.tpl @@ -44,7 +44,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m {{- end }} }, nil {{- else }} - message, ok := v.({{ .Response.ClientConvert.SrcRef }}) + {{ if hasInitArg .Response.ClientConvert.Init.Args "message" }}message{{ else }}_{{ end }}, ok := v.({{ .Response.ClientConvert.SrcRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Response.ClientConvert.SrcRef }}", v) } @@ -76,7 +76,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }} = vals[0] + {{ .Metadata.VarName }} = {{ if .Metadata.Pointer }}&{{ end }}vals[0] } {{- end }} {{- else if .Metadata.StringSlice }} @@ -106,12 +106,12 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Metadata.Name }}, "metadata")) } else { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- end }} diff --git a/grpc/codegen/templates/response_encoder.go.tpl b/grpc/codegen/templates/response_encoder.go.tpl index 32099cc1ef..f0e7f0baec 100644 --- a/grpc/codegen/templates/response_encoder.go.tpl +++ b/grpc/codegen/templates/response_encoder.go.tpl @@ -24,26 +24,27 @@ func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr * } {{- define "metadata_encoder" }} + {{- if .Metadata.Pointer }} + if result.{{ .Metadata.FieldName }} != nil { + {{- end }} + {{ .Metadata.EncodeCode }} {{- if .Metadata.StringSlice }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, res.{{ .Metadata.FieldName }}...) + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ .Metadata.WireVarName }}...) {{- else if .Metadata.Slice }} - for _, value := range res.{{ .Metadata.FieldName }} { + for _, value := range {{ .Metadata.WireVarName }} { {{ template "partial_convert_type_to_string" (typeConversionData .Metadata.Type.ElemType.Type "valueStr" "value") }} {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, valueStr) } {{- else }} - {{- if .Metadata.Pointer }} - if res.{{ .Metadata.FieldName }} != nil { - {{- end }} {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{- if eq .Metadata.Type.Name "bytes" }} string( {{- else if not (eq .Metadata.TypeName "string") }} fmt.Sprintf("%v", {{- end }} - {{- if .Metadata.Pointer }}*{{ end }}p.{{ .Metadata.FieldName }} + {{ .Metadata.WireVarName }} {{- if or (eq .Metadata.Type.Name "bytes") (not (eq .Metadata.TypeName "string")) }}) {{- end }}) - {{- if .Metadata.Pointer }} - } - {{- end }} + {{- end }} + {{- if .Metadata.Pointer }} + } {{- end }} {{- end }} diff --git a/grpc/codegen/templates/type_init.go.tpl b/grpc/codegen/templates/type_init.go.tpl index 7d8ecc6d30..1c29aaf422 100644 --- a/grpc/codegen/templates/type_init.go.tpl +++ b/grpc/codegen/templates/type_init.go.tpl @@ -3,8 +3,10 @@ func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .Ret {{ .Code }} {{- if .ReturnIsStruct }} {{- range .Args }} - {{- if .FieldName }} - {{ $.ReturnVarName }}.{{ .FieldName }} = {{ if isAlias .FieldType }}{{ .FieldTypeRef }}({{ end }}{{ .Name }}{{ if isAlias .FieldType }}){{ end }} + {{- if .InitCode }} + {{ .InitCode }} + {{- else if .FieldName }} + {{ $.ReturnVarName }}.{{ .FieldName }} = {{ .Name }} {{- end }} {{- end }} {{- end }} diff --git a/grpc/codegen/testdata/dsls.go b/grpc/codegen/testdata/dsls.go index a38858a387..b23b7020c0 100644 --- a/grpc/codegen/testdata/dsls.go +++ b/grpc/codegen/testdata/dsls.go @@ -1,3 +1,5 @@ +// This file defines gRPC DSL fixtures used to exercise message, metadata, +// streaming, validation, and generated package ownership behavior. package testdata import ( @@ -1137,6 +1139,29 @@ var CustomMessageNameDSL = func() { }) } +var DistinctCustomMessageNamesDSL = func() { + var First = Type("First", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + var Second = Type("Second", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + Service("DistinctCustomMessageNames", func() { + Method("UseFirst", func() { + Payload(First) + Result(First) + GRPC(func() {}) + }) + Method("UseSecond", func() { + Payload(Second) + Result(Second) + GRPC(func() {}) + }) + }) +} + var InterceptorsDSL = func() { var LogInterceptor = Interceptor("Log", func() { Description("Logs request and response details") diff --git a/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden new file mode 100644 index 0000000000..214a75cdb0 --- /dev/null +++ b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden @@ -0,0 +1,22 @@ + +syntax = "proto3"; + +package distinct_custom_message_names; + +option go_package = "/distinct_custom_message_namespb"; + +// Service is the DistinctCustomMessageNames service interface. +service DistinctCustomMessageNames { + // UseFirst implements UseFirst. + rpc UseFirst (Shared) returns (Shared); + // UseSecond implements UseSecond. + rpc UseSecond (Shared2) returns (Shared2); +} + +message Shared { + optional string value = 1; +} + +message Shared2 { + optional string value = 1; +} diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden index 3111d6d787..1c050b2b15 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden @@ -6,7 +6,9 @@ func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", fmt.Sprintf("%v", + inMetadataWire)) } return NewProtoMethodMessageWithMetadataRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden index be0c374124..c5549ab734 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden @@ -6,16 +6,24 @@ func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) } if payload.Token != nil { - (*md).Append("authorization", *payload.Token) + tokenWire := *payload.Token + (*md).Append("authorization", + tokenWire) } if payload.Key != nil { - (*md).Append("authorization", *payload.Key) + keyWire := *payload.Key + (*md).Append("authorization", + keyWire) } if payload.Username != nil { - (*md).Append("username", *payload.Username) + usernameWire := *payload.Username + (*md).Append("username", + usernameWire) } if payload.Password != nil { - (*md).Append("password", *payload.Password) + passwordWire := *payload.Password + (*md).Append("password", + passwordWire) } return NewProtoMethodMessageWithSecurityRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden index 006be77c31..9fe4eab899 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden @@ -6,7 +6,9 @@ func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", fmt.Sprintf("%v", + inMetadataWire)) } return NewProtoMethodMessageWithValidateRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden index ec7d24797c..8b99c522ef 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -20,7 +20,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden index f00c4be27a..e2d491dbe2 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -25,7 +25,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden index 2b501ff366..0eede6a829 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden @@ -7,12 +7,16 @@ func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithMetadataResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", fmt.Sprintf("%v", + inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", fmt.Sprintf("%v", + inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden index 3257742570..df095300f5 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden @@ -7,12 +7,16 @@ func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithValidateResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", fmt.Sprintf("%v", + inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", fmt.Sprintf("%v", + inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index 14b7863d4e..e2b565c448 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -113,9 +113,6 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod Name: side + "-type-init", Source: grpcTemplates.Read(grpcTypeInitT), Data: init, - FuncMap: map[string]any{ - "isAlias": expr.IsAlias, - }, }) } for _, data := range sd.validations { diff --git a/http/codegen/idempotency_test.go b/http/codegen/idempotency_test.go index dfb2a0fcfd..f046c7b0bc 100644 --- a/http/codegen/idempotency_test.go +++ b/http/codegen/idempotency_test.go @@ -1,3 +1,5 @@ +// This file verifies repeated HTTP analysis produces the same package-owned +// declarations and does not retain mutable state between runs. package codegen import ( @@ -50,8 +52,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { // TestFileGenerationIdempotent builds the HTTP services data once and renders // the complete generated file set twice, asserting that both renders produce // byte-identical outputs. This guards against file generators mutating shared -// analysis state (e.g. the ServerTypeNames/ClientTypeNames dedup sets or the -// PathInit argument data) in ways that change subsequent renders. +// analysis state (for example package declaration catalogs or PathInit +// argument data) in ways that change subsequent renders. func TestFileGenerationIdempotent(t *testing.T) { cases := []struct { Name string diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 6a68d06ab0..2e3a28ede0 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -20,7 +20,7 @@ func TestClientCLIInlinesOneOfRequestValidation(t *testing.T) { require.Contains(t, code, "BuildMethodBodyUnionUserValidatePayload") require.Contains(t, code, "if body.A == nil") - require.Contains(t, code, "marshalUnionUserValidateRequestBodyTo") + require.Contains(t, code, "marshalUnionUserValidateTo") require.NotContains(t, code, "ValidateMethodBodyUnionUserValidateRequestBody") } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden index ac58094d55..ca719a043f 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden @@ -19,7 +19,7 @@ "type": "object" }, "GoaFoobar": { - "description": "Foo BarResponseBody result type (default view)", + "description": "Foo Bar result type (default view)", "example": { "bar": [ { diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden index d60d92056b..71a2b516fd 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden @@ -55,7 +55,7 @@ definitions: foo: type: string example: "" - description: Foo BarResponseBody result type (default view) + description: Foo Bar result type (default view) example: bar: - string: "" diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden index 4b648409e2..39d9ee6a08 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden @@ -22,15 +22,20 @@ }, "Foobar": { "example": { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "" + } + ], "items": { "$ref": "#/definitions/Bar" }, @@ -39,12 +44,9 @@ "type": "array" }, "foo": { - "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ], + "example": [], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "Eaque consequatur asperiores est.", "type": "string" }, "maxItems": 42, diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden index b522e657a7..47920c6325 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden @@ -55,21 +55,19 @@ definitions: type: array items: $ref: '#/definitions/Bar' - example: [] + example: + - string: "" minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. - example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + example: Eaque consequatur asperiores est. + example: [] minItems: 0 maxItems: 42 example: - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + bar: + - string: "" + foo: [] diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden index a3535c11c0..233c811e6f 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden @@ -5,20 +5,20 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], - "current": "who" + "current": "where" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], "items": { "description": "Setup stage.", @@ -28,7 +28,7 @@ "where", "what" ], - "example": "who", + "example": "where", "type": "string" }, "type": "array" @@ -41,7 +41,7 @@ "where", "what" ], - "example": "who", + "example": "where", "type": "string" } }, @@ -63,12 +63,12 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], - "current": "who" + "current": "where" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden index 63ba41157c..54fd84d4e6 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden @@ -21,11 +21,11 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who - current: who + - where + - where + - where + - where + current: where responses: "200": description: OK response. @@ -48,21 +48,21 @@ components: items: type: string description: Setup stage. - example: who + example: where enum: - who - when - where - what example: - - who - - who - - who - - who + - where + - where + - where + - where current: type: string description: Setup stage. - example: who + example: where enum: - who - when @@ -71,10 +71,10 @@ components: description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who - current: who + - where + - where + - where + - where + current: where tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/array_file0.golden b/http/codegen/openapi/v3/testdata/golden/array_file0.golden index 73d27f8c36..84fa558be7 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file0.golden @@ -17,15 +17,20 @@ }, "Foobar": { "example": { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "" + } + ], "items": { "$ref": "#/components/schemas/Bar" }, @@ -34,12 +39,9 @@ "type": "array" }, "foo": { - "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ], + "example": [], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "Eaque consequatur asperiores est.", "type": "string" }, "maxItems": 42, @@ -65,49 +67,55 @@ "application/json": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] } ], "schema": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] }, { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] + "bar": [ + { + "string": "" + } + ], + "foo": [] } ], "items": { diff --git a/http/codegen/openapi/v3/testdata/golden/array_file1.golden b/http/codegen/openapi/v3/testdata/golden/array_file1.golden index aaaf460f0a..4dd6cbdc17 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file1.golden @@ -21,31 +21,25 @@ paths: items: $ref: '#/components/schemas/Foobar' example: - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - bar: + - string: "" + foo: [] + - bar: + - string: "" + foo: [] + - bar: + - string: "" + foo: [] example: - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - bar: + - string: "" + foo: [] + - bar: + - string: "" + foo: [] + - bar: + - string: "" + foo: [] responses: "200": description: OK response. @@ -76,23 +70,21 @@ components: type: array items: $ref: '#/components/schemas/Bar' - example: [] + example: + - string: "" minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. - example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + example: Eaque consequatur asperiores est. + example: [] minItems: 0 maxItems: 42 example: - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + bar: + - string: "" + foo: [] tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden index f5160a5df7..6c582092dd 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden @@ -5,20 +5,20 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], - "current": "who" + "current": "where" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], "items": { "$ref": "#/components/schemas/Stage" @@ -39,7 +39,7 @@ "where", "what" ], - "example": "who", + "example": "where", "type": "string" } } @@ -58,12 +58,12 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "where", + "where", + "where", + "where" ], - "current": "who" + "current": "where" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden index 36fe6cd2b8..4db3120510 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden @@ -22,11 +22,11 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who - current: who + - where + - where + - where + - where + current: where responses: "200": description: OK response. @@ -49,24 +49,24 @@ components: items: $ref: '#/components/schemas/Stage' example: - - who - - who - - who - - who + - where + - where + - where + - where current: $ref: '#/components/schemas/Stage' description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who - current: who + - where + - where + - where + - where + current: where Stage: type: string description: Setup stage. - example: who + example: where enum: - who - when diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 6c9eabc0ae..4ff9df7888 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -81,27 +81,20 @@ type ( // define the request, response and error response type // attributes in the client code. ClientBodyAttributeTypes []*TypeData - // ServerTypeNames records the user type names used to define - // the endpoint request and response bodies for server code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ServerTypeNames map[string]struct{} - // ClientTypeNames records the user type names used to define - // the endpoint request and response bodies for client code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ClientTypeNames map[string]struct{} // ServerTransformHelpers is the list of transform functions // required by the various server side constructors. ServerTransformHelpers []*codegen.TransformFunctionData // ClientTransformHelpers is the list of transform functions // required by the various client side constructors. ClientTransformHelpers []*codegen.TransformFunctionData - // UnionTypes lists the sum-type unions referenced by the HTTP request and - // response body types. - UnionTypes []*service.UnionTypeData // Scope initialized with all the server and client types. Scope *codegen.NameScope + // serverWireTypes owns declarations emitted in the actual server + // package. + serverWireTypes *wireTypeCatalog + // clientWireTypes owns declarations emitted in the actual client + // package. + clientWireTypes *wireTypeCatalog // bodies caches the shaped body attributes derived from the // design expressions during analysis. Shaped bodies are detached // copies: the analyze pass must never write them back onto the @@ -582,6 +575,9 @@ type ( Example any // View is the view used to render the (result) type if any. View string + // Declaration identifies the canonical declaration and validator owned + // by the generated output package. + declaration *wireTypeRecord } // MultipartData contains the data needed to render multipart @@ -720,10 +716,11 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { MountServer: "Mount", ServerService: "Service", ClientStruct: "Client", - ServerTypeNames: make(map[string]struct{}), - ClientTypeNames: make(map[string]struct{}), Scope: scope, + serverWireTypes: newWireTypeCatalog("c", "v", "websocket", svc.PkgName), + clientWireTypes: newWireTypeCatalog("c", "v", "websocket", svc.PkgName), } + sds.collectWireTypes(httpSvc, sd) for _, s := range httpSvc.FileServers { paths := make([]string, len(s.RequestPaths)) @@ -1020,80 +1017,148 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } for _, a := range httpSvc.HTTPEndpoints { - collectUserTypes(sd.bodies.request(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) - } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) - } - }) + sds.buildRequestAttributeTypes(sd.bodies.request(a), sd) if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectUserTypes(sd.bodies.streaming(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) - } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) - } - }) + sds.buildRequestAttributeTypes(sd.bodies.streaming(a), sd) } - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - body := effectiveClientResponseBody(sd.bodies.response(v), a, md) - collectUserTypes(body.Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) - } - }) - } - - for _, v := range a.HTTPErrors { - collectUserTypes(sd.bodies.errorResponse(v).Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for error response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) - } - }) - } } - unionTypes := make(map[codegen.UnionTypeID]*service.UnionTypeData) - seenUnionTypes := make(map[expr.UserType]struct{}) - for _, a := range httpSvc.HTTPEndpoints { - collectHTTPUnionTypes(sd.bodies.request(a), sd.Scope, unionTypes, seenUnionTypes) + return sd +} - if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectHTTPUnionTypes(sd.bodies.streaming(a), sd.Scope, unionTypes, seenUnionTypes) - } +// buildRequestAttributeTypes builds nested request declarations from separate +// tagged copies because server and client packages apply different pointer and +// default policies to the same authored body graph. +func (sds *ServicesData) buildRequestAttributeTypes(body *expr.AttributeExpr, data *ServiceData) { + for _, side := range []struct { + server bool + pointer bool + }{ + {server: true, pointer: true}, + {server: false, pointer: false}, + } { + body := expr.DupAtt(body) + addMarshalTags(body) + top, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(userType expr.UserType) { + if top != nil && userType.Origin() == top.Origin() { + return + } + declaration := sds.attributeTypeData(userType, true, side.pointer, side.server, data) + if declaration == nil { + return + } + if side.server { + data.ServerBodyAttributeTypes = append(data.ServerBodyAttributeTypes, declaration) + } else { + data.ClientBodyAttributeTypes = append(data.ClientBodyAttributeTypes, declaration) + } + }) + } +} - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - collectHTTPUnionTypes(effectiveClientResponseBody(sd.bodies.response(v), a, md), sd.Scope, unionTypes, seenUnionTypes) +// collectWireTypes records every declaration that the service's actual client +// and server packages may emit, then freezes both catalogs before endpoint +// analysis builds TypeData or asks for a reference. +func (sds *ServicesData) collectWireTypes(httpService *expr.HTTPServiceExpr, data *ServiceData) { + for _, endpoint := range httpService.HTTPEndpoints { + request := expr.DupAtt(data.bodies.request(endpoint)) + addMarshalTags(request) + data.serverWireTypes.collect(request, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}, "") + data.clientWireTypes.collect(request, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + data.serverWireTypes.collect(request, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}, "") + data.clientWireTypes.collect(request, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + streaming := expr.DupAtt(data.bodies.streaming(endpoint)) + addMarshalTags(streaming) + data.serverWireTypes.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, pointer: true, validate: true}, "") + data.clientWireTypes.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + data.serverWireTypes.collect(streaming, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}, "") + data.clientWireTypes.collect(streaming, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + } + + method := data.Service.Method(endpoint.Name()) + viewed := method.ViewedResult != nil + for _, response := range endpoint.Responses { + body := data.bodies.response(response) + if !viewed { + sds.collectResponseWireType(body, endpoint, data, true, nil) + sds.collectResponseWireType(body, endpoint, data, false, nil) + continue + } + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + emptyView := "" + switch { + case origin != "": + sds.collectResponseWireType(body, endpoint, data, true, &emptyView) + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + sds.collectResponseWireType(body, endpoint, data, true, &view) + } else { + for _, view := range method.ViewedResult.Views { + sds.collectResponseWireType(body, endpoint, data, true, &view.Name) + } + } + default: + for _, view := range method.ViewedResult.Views { + sds.collectResponseWireType(body, endpoint, data, true, &view.Name) + } + } + clientView := clientResponseViewName(endpoint, method) + clientBody := body + if clientView != "" { + clientBody = effectiveClientResponseBody(body, endpoint, method) + } + sds.collectResponseWireType(clientBody, endpoint, data, false, &clientView) } - - for _, v := range a.HTTPErrors { - collectHTTPUnionTypes(sd.bodies.errorResponse(v), sd.Scope, unionTypes, seenUnionTypes) + for _, transportError := range endpoint.HTTPErrors { + body := data.bodies.errorResponse(transportError) + sds.collectResponseWireType(body, endpoint, data, true, nil) + sds.collectResponseWireType(body, endpoint, data, false, nil) } } + data.serverWireTypes.Freeze() + data.clientWireTypes.Freeze() +} - unions := make([]*service.UnionTypeData, 0, len(unionTypes)) - for _, u := range unionTypes { - unions = append(unions, u) +// collectResponseWireType applies the selected view and records response body +// declarations using the same policy later consumed by buildResponseBodyType. +func (sds *ServicesData) collectResponseWireType(body *expr.AttributeExpr, endpoint *expr.HTTPEndpointExpr, data *ServiceData, server bool, view *string) { + body, viewName := prepareResponseWireBody(body, view) + policy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server && view == nil, view: viewName} + preferred := "" + if server && !expr.IsPrimitive(body.Type) && needInit(body.Type) { + if _, userType := body.Type.(expr.UserType); !userType { + preferred = codegen.Goify(endpoint.Name(), true) + "ResponseBody" + } } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - sd.UnionTypes = unions + data.wireTypes(server).collect(body, wireResponseBody, policy, preferred) + attributePolicy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server} + data.wireTypes(server).collect(body, wireAttribute, attributePolicy, "") +} - return sd +// prepareResponseWireBody returns the detached, projected, and tagged shape +// consumed by collection, declarations, and client response transforms. +func prepareResponseWireBody(body *expr.AttributeExpr, view *string) (*expr.AttributeExpr, string) { + body = expr.DupAtt(body) + viewName := "" + if view != nil && *view != "" { + viewName = *view + if resultType, ok := body.Type.(*expr.ResultTypeExpr); ok { + projected, err := expr.Project(resultType, *view) + if err != nil { + panic(err) + } + body.Type = projected + } + } + addMarshalTags(body) + return body, viewName } // makeHTTPType traverses the attribute recursively and performs these actions: @@ -1220,13 +1285,23 @@ func (b *shapedBodies) errorResponse(v *expr.HTTPErrorExpr) *expr.AttributeExpr // used by the request body type recursively if any. func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceData) *PayloadData { httpBody := sd.bodies.request(e) + serverHTTPBody := expr.DupAtt(httpBody) + clientHTTPBody := expr.DupAtt(httpBody) + if httpBody.Type != expr.Empty { + addMarshalTags(serverHTTPBody) + addMarshalTags(clientHTTPBody) + serverPolicy := wireTypePolicy{request: true, pointer: true, validate: true} + clientPolicy := wireTypePolicy{request: true, useDefault: true, validate: true} + sd.serverWireTypes.applyNames(serverHTTPBody, wireRequestBody, serverPolicy) + sd.clientWireTypes.applyNames(clientHTTPBody, wireRequestBody, clientPolicy) + } var ( payload = e.MethodExpr.Payload svc = sd.Service body = httpBody.Type ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = httpContext(sd.Scope, true, true) - httpclictx = httpContext(sd.Scope, true, false) + httpsvrctx = httpContext(sd.serverWireTypes.scope, true, true) + httpclictx = httpContext(sd.clientWireTypes.scope, true, false) svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) @@ -1282,10 +1357,6 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD } queryData = append(queryData, mapQueryParam) } - if serverBodyData != nil { - sd.ServerTypeNames[serverBodyData.Name] = struct{}{} - sd.ClientTypeNames[serverBodyData.Name] = struct{}{} - } for _, p := range cookiesData { if p.Required || p.Validate != "" || needConversion(p.Type) { mustValidate = true @@ -1320,7 +1391,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body // transformation. - if o, ok := httpBody.Meta["origin:attribute"]; ok { + if o, ok := serverHTTPBody.Meta["origin:attribute"]; ok { origin = o[0] if !payload.IsRequired(o[0]) { mustHaveBody = false @@ -1378,33 +1449,37 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD svcode string cvcode string ) - if ut, ok := body.(expr.UserType); ok { + if ut, ok := serverHTTPBody.Type.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { svcode = codegen.ValidationCode(ut.Attribute(), ut, httpsvrctx, true, expr.IsAlias(ut), false, "body") + } + } + if ut, ok := clientHTTPBody.Type.(expr.UserType); ok { + if val := ut.Attribute().Validation; val != nil { cvcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") } } serverArgs = append(serverArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: sd.serverWireTypes.scope.GoVar("body", serverHTTPBody.Type), AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.Scope.GoTypeName(httpBody), - TypeRef: sd.Scope.GoTypeRef(httpBody), - Type: body, + TypeName: sd.serverWireTypes.scope.GoTypeName(serverHTTPBody), + TypeRef: sd.serverWireTypes.scope.GoTypeRef(serverHTTPBody), + Type: serverHTTPBody.Type, Required: true, Example: httpBody.Example(sds.Root.API.ExampleGenerator), Validate: svcode, }, }) clientArgs = append(clientArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: sd.clientWireTypes.scope.GoVar("body", clientHTTPBody.Type), AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.Scope.GoTypeNameWithDefaults(httpBody), - TypeRef: sd.Scope.GoTypeRefWithDefaults(httpBody), - Type: body, + TypeName: sd.clientWireTypes.scope.GoTypeNameWithDefaults(clientHTTPBody), + TypeRef: sd.clientWireTypes.scope.GoTypeRefWithDefaults(clientHTTPBody), + Type: clientHTTPBody.Type, Required: true, Example: httpBody.Example(sds.Root.API.ExampleGenerator), Validate: cvcode, @@ -1525,7 +1600,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD var ( helpers []*codegen.TransformFunctionData ) - serverCode, helpers, err = unmarshal(httpBody, pAtt, "body", httpsvrctx, svcsvrctx) + transformctx := httpContext(sd.serverWireTypes.scope.Fork(), true, true) + serverCode, helpers, err = unmarshal(serverHTTPBody, pAtt, "body", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -1533,18 +1609,21 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // body is used by the CLI tool to build the payload given to the // client endpoint. It differs because the body type there does not // use pointers for all fields (no need to validate). - clientCode, helpers, err = marshal(httpBody, pAtt, "body", "v", httpclictx, svcclictx) + transformctx = httpContext(sd.clientWireTypes.scope.Fork(), true, false) + clientCode, helpers, err = marshal(clientHTTPBody, pAtt, "body", "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), httpsvrctx, svcsvrctx) + transformctx := httpContext(sd.serverWireTypes.scope.Fork(), true, true) + serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } - clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", httpclictx, svcclictx) + transformctx = httpContext(sd.clientWireTypes.scope.Fork(), true, false) + clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -1688,7 +1767,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A svc = sd.Service md = svc.Method(e.Name()) - httpclictx = httpContext(sd.Scope, false, false) + httpclictx = httpContext(sd.clientWireTypes.scope, false, false) scope = svc.Scope svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) @@ -1715,6 +1794,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A origin string mustValidate bool clientRespBody = respBody + clientBodyView *string resAttr = result ) @@ -1764,8 +1844,10 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A if clientView != "" { clientRespBody = effectiveClientResponseBody(respBody, e, md) clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd) + clientBodyView = &clientView } else { clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd) + clientBodyView = &vname } } else { if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd); sbd != nil { @@ -1773,8 +1855,11 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A } clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd) } - if clientBodyData != nil && clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} + if clientRespBody.Type != expr.Empty { + var viewName string + clientRespBody, viewName = prepareResponseWireBody(clientRespBody, clientBodyView) + policy := wireTypePolicy{pointer: true, validate: clientBodyView == nil, view: viewName} + sd.clientWireTypes.applyNames(clientRespBody, wireResponseBody, policy) } for _, h := range headersData { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -1841,7 +1926,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeRef: sd.Scope.GoTypeRef(clientRespBody), + TypeRef: sd.clientWireTypes.scope.GoTypeRef(clientRespBody), Validate: vcode, }, }} @@ -1855,7 +1940,8 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // rely on the fact that the required attributes are // set in the response body (otherwise validation // would fail). - code, helpers, err = unmarshal(clientRespBody, resAttr, "body", httpclictx, svcctx) + transformctx := httpContext(sd.clientWireTypes.scope.Fork(), false, false) + code, helpers, err = unmarshal(clientRespBody, resAttr, "body", transformctx, svcctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -1935,12 +2021,13 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa var ( svc = sd.Service ep = svc.Method(e.MethodExpr.Name) - httpclictx = httpContext(sd.Scope, false, false) + httpclictx = httpContext(sd.clientWireTypes.scope, false, false) ) data := make(map[string][]*ErrorData) for _, v := range e.HTTPErrors { - respBody := sd.bodies.errorResponse(v) + respBody := expr.DupAtt(sd.bodies.errorResponse(v)) + addMarshalTags(respBody) errorAttribute := e.MethodExpr.Error(v.Name).AttributeExpr var ( init *InitData @@ -1972,9 +2059,18 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa if isObject { ref = "&body" } + policy := wireTypePolicy{pointer: true, validate: true} + bodyRecord := sd.clientWireTypes.lookupUser(respBody, wireResponseBody, policy) + sd.clientWireTypes.applyNames(respBody, wireResponseBody, policy) + var bodyTypeRef string + if bodyRecord != nil { + bodyTypeRef = bodyRecord.ref + } else { + bodyTypeRef = sd.clientWireTypes.scope.GoTypeRef(respBody) + } args = append(args, &InitArgData{ Ref: ref, - AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: sd.Scope.GoTypeRef(respBody)}, + AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: bodyTypeRef}, }) } for _, h := range headers { @@ -1999,7 +2095,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal(respBody, eAtt, "body", httpclictx, errctx) + transformctx := httpContext(sd.clientWireTypes.scope.Fork(), false, false) + code, helpers, err = unmarshal(respBody, eAtt, "body", transformctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2043,9 +2140,6 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd) if clientBodyData != nil { - if clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} - } clientBodyData.Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", clientBodyData.VarName, svc.Name, e.Name(), v.Name) serverBodyData[0].Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", @@ -2141,6 +2235,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * if body.Type == expr.Empty { return nil } + body = expr.DupAtt(body) var ( name string varname string @@ -2151,21 +2246,28 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * validateRef string svc = sd.Service - httpctx = httpContext(sd.Scope, true, svr) + catalog = sd.wireTypes(svr) + policy = wireTypePolicy{request: true, pointer: svr, useDefault: !svr, validate: true} + httpctx = httpContext(catalog.scope, true, svr) side = "client" ) if svr { side = "server" } svcctx := sds.serviceTypeContext(sd, side).Enter(att) - name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) - addMarshalTags(body) + record := catalog.lookupUser(body, wireRequestBody, policy) + catalog.applyNames(body, wireRequestBody, policy) + name = body.Type.Name() + if record != nil { + ref = record.ref + } else { + ref = catalog.scope.GoTypeRef(body) + } if ut, ok := body.Type.(expr.UserType); ok { - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), svr, !svr) + varname = record.name + def = goTypeDef(catalog.scope.Fork(), ut.Attribute(), svr, !svr) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP request body.", varname, svc.Name, e.Name()) if svr { @@ -2177,7 +2279,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * } } else { // Generate validation code first because inline struct validation is removed. - ctx := codegen.NewAttributeContext(!expr.IsPrimitive(body.Type), false, !svr, "", sd.Scope) + ctx := codegen.NewAttributeContext(!expr.IsPrimitive(body.Type), false, !svr, "", catalog.scope) validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") if svr && expr.IsObject(body.Type) { // Body is an explicit object described in the design and in @@ -2186,7 +2288,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // generating the server body type pre-validation. body.Validation = nil } - varname = sd.Scope.GoTypeRef(body) + varname = catalog.scope.GoTypeRef(body) desc = body.Description } var init *InitData @@ -2203,7 +2305,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * svc = sd.Service ) { - name = fmt.Sprintf("New%s", codegen.Goify(sd.Scope.GoTypeName(body), true)) + name = fmt.Sprintf("New%s", codegen.Goify(catalog.scope.GoTypeName(body), true)) desc = fmt.Sprintf("%s builds the HTTP request body from the payload of the %q endpoint of the %q service.", name, e.Name(), svc.Name) src := sourceVar @@ -2216,7 +2318,8 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := httpContext(catalog.scope.Fork(), true, svr) + code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, transformctx) if err != nil { panic(err) // bug } @@ -2236,13 +2339,13 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * init = &InitData{ Name: name, Description: desc, - ReturnTypeRef: sd.Scope.GoTypeRef(body), + ReturnTypeRef: ref, ReturnTypeAttribute: codegen.Goify(origin, true), ClientCode: code, ClientArgs: []*InitArgData{&arg}, } } - return &TypeData{ + data := &TypeData{ Name: name, VarName: varname, Description: desc, @@ -2253,6 +2356,10 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * ValidateRef: validateRef, Example: body.Example(sds.Root.API.ExampleGenerator), } + if record == nil || data.Def == "" && data.ValidateDef == "" { + return data + } + return catalog.bind(record, data) } // buildResponseBodyType builds the TypeData for a response body. The data @@ -2270,6 +2377,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e if body.Type == expr.Empty { return nil } + body, viewName := prepareResponseWireBody(body, view) var ( name string varname string @@ -2278,47 +2386,48 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e ref string validateDef string validateRef string - viewName string mustInit bool - svc = sd.Service - httpctx = httpContext(sd.Scope, false, svr) - side = "client" + svc = sd.Service + side = "client" ) if svr { side = "server" } svcctx := sds.serviceTypeContext(sd, side).Enter(att) - // Project the response body when the design fixes the response to a single - // view so the generated transport code uses the effective wire shape. - if view != nil && *view != "" { - viewName = *view - body = expr.DupAtt(body) - if rt, ok := body.Type.(*expr.ResultTypeExpr); ok { - var err error - rt, err = expr.Project(rt, *view) - if err != nil { - panic(err) - } - body.Type = rt + catalog := sd.wireTypes(svr) + policy := wireTypePolicy{pointer: !svr, useDefault: svr, validate: !svr && view == nil, view: viewName} + // Build nested declarations before package names are applied to body. Each + // nested lookup consumes the collected authored shape, then applies the + // frozen names to its own detached occurrence. + topLevel, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(ut expr.UserType) { + if topLevel != nil && ut == topLevel { + return + } + if d := sds.attributeTypeData(ut, false, !svr, svr, sd); d != nil { if svr { - sd.ServerTypeNames[rt.Name()] = struct{}{} + sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) } else { - sd.ClientTypeNames[rt.Name()] = struct{}{} + sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) } } - } - + }) + record := catalog.lookupUser(body, wireResponseBody, policy) + catalog.applyNames(body, wireResponseBody, policy) + httpctx := httpContext(catalog.scope, false, svr) name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) + if record != nil { + ref = record.ref + } else { + ref = catalog.scope.GoTypeRef(body) + } mustInit = att.Type != expr.Empty && needInit(body.Type) - addMarshalTags(body) - if ut, ok := body.Type.(expr.UserType); ok { // response body is a user type. - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), !svr, svr) + varname = record.name + def = goTypeDef(catalog.scope.Fork(), ut.Attribute(), !svr, svr) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) if !svr && view == nil { @@ -2346,12 +2455,14 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e // may be deduplicated away in client/types.go. if svr { name = codegen.Goify(e.Name(), true) + "ResponseBody" - varname = name + record = catalog.lookup(body, wireResponseBody, policy, name) + varname = record.name + name = record.name desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) - def = goTypeDef(sd.Scope, body, !svr, svr) + def = goTypeDef(catalog.scope.Fork(), body, !svr, svr) } else { - varname = sd.Scope.GoTypeRef(body) + varname = catalog.scope.GoTypeRef(body) desc = body.Description def = "" } @@ -2359,25 +2470,11 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e } else { // response body is a primitive type. They are used as non-pointers when // encoding/decoding responses. - httpctx = httpContext(sd.Scope, false, true) + httpctx = httpContext(catalog.scope, false, true) validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") - varname = sd.Scope.GoTypeRef(body) + varname = catalog.scope.GoTypeRef(body) desc = body.Description } - if svr { - sd.ServerTypeNames[name] = struct{}{} - // We collect the server body types need to generate a response body type - // here because the response body type would be different from the actual - // type in the HTTPResponseExpr since we projected the body type above. - // For client side, response body types are collected in "analyze" using - // the effective client response body. - collectUserTypes(body.Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, false, false, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) - } - }) - } - var init *InitData if svr && mustInit { var ( @@ -2398,8 +2495,8 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e rtname = codegen.Goify(e.Name(), true) + "ResponseBody" rtref = rtname } else { - rtname = codegen.Goify(sd.Scope.GoTypeName(body), true) - rtref = sd.Scope.GoTypeRef(body) + rtname = codegen.Goify(catalog.scope.GoTypeName(body), true) + rtref = ref } name = fmt.Sprintf("New%s", rtname) desc = fmt.Sprintf("%s builds the HTTP response body from the result of the %q endpoint of the %q service.", @@ -2417,7 +2514,8 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := httpContext(catalog.scope.Fork(), false, svr) + code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, transformctx) if err != nil { panic(err) // bug } @@ -2460,7 +2558,10 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e Example: body.Example(sds.Root.API.ExampleGenerator), View: viewName, } - return td + if record == nil || td.Def == "" && td.ValidateDef == "" { + return td + } + return catalog.bind(record, td) } func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData) []*ParamData { @@ -2725,38 +2826,6 @@ func collectUserTypesRecursive(dt expr.DataType, cb func(expr.UserType), seen ma } } -func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, unions map[codegen.UnionTypeID]*service.UnionTypeData, seen map[expr.UserType]struct{}) { - if att == nil || att.Type == expr.Empty { - return - } - switch dt := att.Type.(type) { - case expr.UserType: - origin := dt.Origin() - if _, ok := seen[origin]; ok { - return - } - seen[origin] = struct{}{} - collectHTTPUnionTypes(dt.Attribute(), scope, unions, seen) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } - case *expr.Array: - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Map: - collectHTTPUnionTypes(dt.KeyType, scope, unions, seen) - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Union: - identity := codegen.NewUnionTypeID(dt) - if _, ok := unions[identity]; !ok { - unions[identity] = buildHTTPUnionTypeData(dt, scope) - } - for _, nat := range dt.Values { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } - } -} - // effectiveClientResponseBody returns the response body shape used by client // code generation. When the design fixes the response to a single view, the // returned attribute uses that projected ResultType so type collection, union @@ -2796,62 +2865,41 @@ func clientResponseViewName(e *expr.HTTPEndpointExpr, md *service.MethodData) st return "" } -func buildHTTPUnionTypeData(u *expr.Union, scope *codegen.NameScope) *service.UnionTypeData { - att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") - +func buildHTTPUnionTypeData(u *expr.Union, scope *codegen.NameScope, record *wireUnionRecord) *service.UnionTypeData { fields := make([]*service.UnionFieldData, len(u.Values)) for i, nat := range u.Values { fieldName := codegen.Goify(nat.Name, true) fieldType := scope.GoTypeRef(nat.Attribute) - kindConst := kindName + fieldName fields[i] = &service.UnionFieldData{ - Name: nat.Name, - KindConst: kindConst, - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - TypeTag: nat.Name, + Name: nat.Name, + KindConst: record.kindConsts[i], + Constructor: record.constructors[i], + FieldName: fieldName, + FieldType: fieldType, + Nilable: codegen.IsNilable(nat.Attribute.Type), + TypeTag: nat.Name, } } return &service.UnionTypeData{ - Name: name, - KindName: kindName, + Name: record.name, + KindName: record.kindName, Fields: fields, TypeKey: u.GetTypeKey(), ValueKey: u.GetValueKey(), } } -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs - } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted +func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, rd *ServiceData) *TypeData { + return sds.attributeTypeDataView(ut, req, ptr, server, "", rd) } -func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, rd *ServiceData) *TypeData { +// attributeTypeDataView builds a nested declaration using the view policy +// that selected its enclosing response shape. +func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, server bool, view string, rd *ServiceData) *TypeData { if ut == expr.Empty { return nil } - seen := rd.ServerTypeNames - if !server { - seen = rd.ClientTypeNames - } - if _, ok := seen[ut.Name()]; ok { - return nil - } - seen[ut.Name()] = struct{}{} var ( name string @@ -2859,10 +2907,15 @@ func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bo validate string validateRef string - att = &expr.AttributeExpr{Type: ut} - hctx = httpContext(rd.Scope, req, server) + att = expr.DupAtt(&expr.AttributeExpr{Type: ut}) + catalog = rd.wireTypes(server) + policy = wireTypePolicy{request: req, pointer: ptr, useDefault: hctxUseDefault(req, server), validate: req || !server, view: view} ) - name = rd.Scope.GoTypeName(att) + ut = att.Type.(expr.UserType) + record := catalog.lookupUser(att, wireAttribute, policy) + catalog.applyNames(att, wireAttribute, policy) + hctx := httpContext(catalog.scope, req, server) + name = record.name ctx := "request" if !req { ctx = "response" @@ -2877,16 +2930,30 @@ func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bo if validate != "" { validateRef = fmt.Sprintf("err = Validate%s(v)", name) } - return &TypeData{ + return catalog.bind(record, &TypeData{ Name: ut.Name(), VarName: name, Description: desc, - Def: goTypeDef(rd.Scope, ut.Attribute(), ptr, hctx.UseDefault), - Ref: rd.Scope.GoTypeRef(att), + Def: goTypeDef(catalog.scope.Fork(), ut.Attribute(), ptr, hctx.UseDefault), + Ref: record.ref, ValidateDef: validate, ValidateRef: validateRef, Example: att.Example(sds.Root.API.ExampleGenerator), + }) +} + +// wireTypes returns the catalog for the generated server or client package. +func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { + if server { + return sd.serverWireTypes } + return sd.clientWireTypes +} + +// hctxUseDefault mirrors httpContext's wire default policy without creating a +// second scope during declaration collection. +func hctxUseDefault(request, server bool) bool { + return !request && server || request && !server } // httpContext returns a context for attributes of types used to marshal and diff --git a/http/codegen/service_data_union_nilability_test.go b/http/codegen/service_data_union_nilability_test.go index 3b49baf22f..56f7b20eb8 100644 --- a/http/codegen/service_data_union_nilability_test.go +++ b/http/codegen/service_data_union_nilability_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP union records preserve the nilability of every +// branch when rendering their package-owned sum type. package codegen import ( @@ -11,7 +13,13 @@ import ( func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildHTTPUnionTypeData(union, codegen.NewNameScope()) + record := &wireUnionRecord{ + name: "Value", + kindName: "ValueKind", + kindConsts: []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"}, + constructors: []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"}, + } + data := buildHTTPUnionTypeData(union, codegen.NewNameScope(), record) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index 7efb84c494..b1a504a389 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/require" - cg "goa.design/goa/v3/codegen" - svc "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -79,17 +77,18 @@ func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testi }, } - scope := cg.NewNameScope() - unions := make(map[cg.UnionTypeID]*svc.UnionTypeData) - collectHTTPUnionTypes(bodies, scope, unions, make(map[expr.UserType]struct{})) + catalog := newWireTypeCatalog() + catalog.collect(bodies, wireAttribute, wireTypePolicy{}, "") + catalog.Freeze() + catalog.applyNames(bodies, wireAttribute, wireTypePolicy{}) - emitted := make([]string, 0, len(unions)) - for _, union := range unions { + emitted := make([]string, 0, len(catalog.unions)) + for _, union := range catalog.unionTypes() { emitted = append(emitted, union.Name) } references := []string{ - scope.GoTypeName(&expr.AttributeExpr{Type: first}), - scope.GoTypeName(&expr.AttributeExpr{Type: second}), + catalog.scope.GoTypeName(&expr.AttributeExpr{Type: first}), + catalog.scope.GoTypeName(&expr.AttributeExpr{Type: second}), } require.Equal(t, []string{"Value"}, emitted) require.Equal(t, []string{"Value", "Value"}, references) @@ -119,13 +118,16 @@ func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { data := CreateHTTPServices(root).Get("values") require.NotNil(t, data) - emitted := make([]string, len(data.UnionTypes)) - for i, union := range data.UnionTypes { - emitted[i] = union.Name + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + unions := catalog.unionTypes() + emitted := make([]string, len(unions)) + for i, union := range unions { + emitted[i] = union.Name + } + require.Equal(t, []string{"Value", "Value2"}, emitted) } - require.Equal(t, []string{"Value"}, emitted) require.Contains(t, data.Endpoint("first").Payload.Request.ServerBody.Def, "Value *Value ") - require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value ") + require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value2 ") } func TestMakeHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { @@ -196,14 +198,13 @@ func sameShapedValueUnionDSL() { } func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { - scope := cg.NewNameScope() - seen := make(map[expr.UserType]struct{}) - unionTypes := make(map[cg.UnionTypeID]*svc.UnionTypeData) - collectHTTPUnionTypes(att, scope, unionTypes, seen) - - names := make(map[string]string, len(unionTypes)) - for identity, data := range unionTypes { - names[identity.Hash()] = data.Name + catalog := newWireTypeCatalog() + catalog.collect(att, wireAttribute, wireTypePolicy{}, "") + catalog.Freeze() + + names := make(map[string]string, len(catalog.unions)) + for _, record := range catalog.unions { + names[record.identity.definition.Hash()] = record.data.Name } return names } diff --git a/http/codegen/templates/union_type.go.tpl b/http/codegen/templates/union_type.go.tpl index ed49a4d5d0..0cf73a8343 100644 --- a/http/codegen/templates/union_type.go.tpl +++ b/http/codegen/templates/union_type.go.tpl @@ -23,8 +23,8 @@ func (u {{ .Name }}) Kind() {{ .KindName }} { } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { +// {{ .Constructor }} constructs {{ $.Name }} with the {{ .Name }} branch set. +func {{ .Constructor }}(v {{ .FieldType }}) {{ $.Name }} { return {{ $.Name }}{ kind: {{ .KindConst }}, {{ .FieldName }}: v, diff --git a/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden index 8890c273fc..c63113e2cd 100644 --- a/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden @@ -1,5 +1,5 @@ // MethodBodyUserInnerRequestBody is the type of the "ServiceBodyUserInner" // service "MethodBodyUserInner" endpoint HTTP request body. type MethodBodyUserInnerRequestBody struct { - Inner *InnerTypeRequestBody `form:"inner,omitempty" json:"inner,omitempty" xml:"inner,omitempty"` + Inner *InnerType `form:"inner,omitempty" json:"inner,omitempty" xml:"inner,omitempty"` } diff --git a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden index e2082919b0..e447a641a3 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden @@ -1,14 +1,14 @@ -// NewPayloadTypeRequestBody builds the HTTP request body from the payload of -// the "MethodBodyPrimitiveArrayUserValidate" endpoint of the +// NewPayloadType builds the HTTP request body from the payload of the +// "MethodBodyPrimitiveArrayUserValidate" endpoint of the // "ServiceBodyPrimitiveArrayUserValidate" service. -func NewPayloadTypeRequestBody(p []*servicebodyprimitivearrayuservalidate.PayloadType) []*PayloadTypeRequestBody { - body := make([]*PayloadTypeRequestBody, len(p)) +func NewPayloadType(p []*servicebodyprimitivearrayuservalidate.PayloadType) []*PayloadType { + body := make([]*PayloadType, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalServicebodyprimitivearrayuservalidatePayloadTypeToPayloadTypeRequestBody(val) + body[i] = marshalServicebodyprimitivearrayuservalidatePayloadTypeToPayloadType(val) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden index 0e36b41475..a56ea8ecb6 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden @@ -3,9 +3,9 @@ func NewStreamStreamingBody(p *streamingaliasedarray.PayloadType) *StreamStreamingBody { body := &StreamStreamingBody{} if p.Values != nil { - body.Values = make([]CustomIntStreamingBody, len(p.Values)) + body.Values = make([]CustomInt, len(p.Values)) for i, val := range p.Values { - body.Values[i] = CustomIntStreamingBody(val) + body.Values[i] = CustomInt(val) } } return body diff --git a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden index 4dbe3fd908..5da3d0848f 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerRequestBody(p *servicebodyuserinner.PayloadType) *MethodBodyUserInnerRequestBody { body := &MethodBodyUserInnerRequestBody{} if p.Inner != nil { - body.Inner = marshalServicebodyuserinnerInnerTypeToInnerTypeRequestBody(p.Inner) + body.Inner = marshalServicebodyuserinnerInnerTypeToInnerType(p.Inner) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden index f612beb92d..e3ad137928 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden @@ -5,7 +5,7 @@ func NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView{} if body.A != nil { - v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeView(body.A) + v.A = unmarshalUserTypeToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeView(body.A) } v.C = c diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden index 136d4adc83..f70eba9e43 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden @@ -5,7 +5,7 @@ func NewMethodExplicitBodyUserResultObjectResulttypeOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { v := &serviceexplicitbodyuserresultobjectviews.ResulttypeView{} if body.A != nil { - v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeView(body.A) + v.A = unmarshalUserTypeToServiceexplicitbodyuserresultobjectviewsUserTypeView(body.A) } v.C = c v.B = b diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index 9675487ba0..a97b24bbce 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s { err = json.Unmarshal([]byte(serviceMultiMethodMultiPayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": true,\n \"att10\": \"Aut et.\",\n \"att11\": \"SGljIHZpdGFlIHZlbGl0IG1hZ25hbSBhdC4=\",\n \"att12\": \"Voluptatibus harum autem totam quaerat quis ut.\",\n \"att13\": [\n \"Sunt inventore est voluptatum ipsam omnis.\",\n \"Quidem quo a non sequi quo.\",\n \"Voluptate id corrupti.\",\n \"Sit cum quia magnam est nihil illo.\"\n ],\n \"att14\": {\n \"Fuga consequatur magnam vel sint doloribus.\": \"Corporis deserunt.\",\n \"Omnis eveniet.\": \"Nobis quia incidunt nemo illum.\"\n },\n \"att15\": {\n \"inline\": \"Suscipit voluptate magnam facilis.\"\n },\n \"att2\": 1586082787030249061,\n \"att3\": 1689516036,\n \"att4\": 2650159337126663108,\n \"att5\": 15366113216962746133,\n \"att6\": 2615133795,\n \"att7\": 13370278622483873840,\n \"att8\": 0.7927184,\n \"att9\": 0.12075756206016808\n }\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": false,\n \"att10\": \"Ea impedit omnis.\",\n \"att11\": \"RXQgb21uaXMgcXVhcyBuaWhpbCBiZWF0YWUgZXNzZSBkZWxlbml0aS4=\",\n \"att12\": \"Dolorum non.\",\n \"att13\": [\n \"Sint nisi.\",\n \"Accusantium rerum nihil quae ducimus consequatur fugiat.\",\n \"Autem eum et et et nulla quasi.\",\n \"Cumque ut optio.\"\n ],\n \"att14\": {\n \"Vel sunt architecto.\": \"Animi distinctio sequi atque et explicabo ullam.\",\n \"Vitae beatae ea porro magni et.\": \"Et eaque iusto fugit qui.\"\n },\n \"att15\": {\n \"inline\": \"Quia sint.\"\n },\n \"att2\": 143235663851589327,\n \"att3\": 1890639226,\n \"att4\": 8858961141680733979,\n \"att5\": 15592217388400497022,\n \"att6\": 1918120858,\n \"att7\": 17362445853891470951,\n \"att8\": 0.35741758,\n \"att9\": 0.44400284372015414\n }\n }'") } } var b *string @@ -28,7 +28,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s } v := &servicemulti.MethodMultiPayloadPayload{} if body.C != nil { - v.C = marshalUserTypeRequestBodyToServicemultiUserType(body.C) + v.C = marshalUserTypeToServicemultiUserType(body.C) } v.B = b v.A = a diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index 461f40f436..86f4b8877a 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -2,7 +2,7 @@ // ServiceBodyInlineArrayUser MethodBodyInlineArrayUser endpoint from CLI flags. func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody string) ([]*servicebodyinlinearrayuser.ElemType, error) { var err error - var body []*ElemTypeRequestBody + var body []*ElemType { err = json.Unmarshal([]byte(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) if err != nil { @@ -15,7 +15,7 @@ func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyI v[i] = nil continue } - v[i] = marshalElemTypeRequestBodyToServicebodyinlinearrayuserElemType(val) + v[i] = marshalElemTypeToServicebodyinlinearrayuserElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index 4d25a84bfd..e6d3ed3a18 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -2,7 +2,7 @@ // ServiceBodyInlineMapUser MethodBodyInlineMapUser endpoint from CLI flags. func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlineMapUserBody string) (map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, error) { var err error - var body map[*KeyTypeRequestBody]*ElemTypeRequestBody + var body map[*KeyType]*ElemType { err = json.Unmarshal([]byte(serviceBodyInlineMapUserMethodBodyInlineMapUserBody), &body) if err != nil { @@ -11,12 +11,12 @@ func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlin } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := marshalKeyTypeToServicebodyinlinemapuserKeyType(val) if val == nil { v[tk] = nil continue } - v[tk] = marshalElemTypeRequestBodyToServicebodyinlinemapuserElemType(val) + v[tk] = marshalElemTypeToServicebodyinlinemapuserElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden index 1f2a6479f9..0b24c0dae6 100644 --- a/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodBodyPrimitiveArrayUserValidateRequest(encoder func(*http.Reques if !ok { return goahttp.ErrInvalidType("ServiceBodyPrimitiveArrayUserValidate", "MethodBodyPrimitiveArrayUserValidate", "[]*servicebodyprimitivearrayuservalidate.PayloadType", v) } - body := NewPayloadTypeRequestBody(p) + body := NewPayloadType(p) if err := encoder(req).Encode(&body); err != nil { return goahttp.ErrEncodingError("ServiceBodyPrimitiveArrayUserValidate", "MethodBodyPrimitiveArrayUserValidate", err) } diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden index ae60cad639..688c9fe785 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden @@ -12,7 +12,7 @@ type ListResponseBody struct { } // ListSomethingWentWrongResponseBody is the type of the "ServiceA" service -// "list" endpoint HTTP response body for the "something_went_wrong" error. +// "list" endpoint HTTP response body. type ListSomethingWentWrongResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden index cd45f6401a..1c5b90165a 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden @@ -12,7 +12,7 @@ type ListResponseBody struct { } // ListSomethingWentWrongResponseBody is the type of the "ServiceB" service -// "list" endpoint HTTP response body for the "something_went_wrong" error. +// "list" endpoint HTTP response body. type ListSomethingWentWrongResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` diff --git a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden index 2293ad85d5..d1ee980ec9 100644 --- a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden @@ -1,15 +1,15 @@ // MethodARequestBody is the type of the "ServiceMixedPayloadInBody" service // "MethodA" endpoint HTTP request body. type MethodARequestBody struct { - Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` - Array []float32 `form:"array" json:"array" xml:"array"` - Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` - Object *BPayloadRequestBody `form:"object" json:"object" xml:"object"` - DupObj *BPayloadRequestBody `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` + Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` + Array []float32 `form:"array" json:"array" xml:"array"` + Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` + Object *BPayload `form:"object" json:"object" xml:"object"` + DupObj *BPayload `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` } -// BPayloadRequestBody is used to define fields on request body types. -type BPayloadRequestBody struct { +// BPayload is used to define fields on request body types. +type BPayload struct { Int int `form:"int" json:"int" xml:"int"` Bytes []byte `form:"bytes,omitempty" json:"bytes,omitempty" xml:"bytes,omitempty"` } @@ -37,10 +37,10 @@ func NewMethodARequestBody(p *servicemixedpayloadinbody.APayload) *MethodAReques } } if p.Object != nil { - body.Object = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBody(p.Object) + body.Object = marshalServicemixedpayloadinbodyBPayloadToBPayload(p.Object) } if p.DupObj != nil { - body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBody(p.DupObj) + body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayload(p.DupObj) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden index 4c4bf9af70..d228e1682b 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden @@ -1,54 +1,52 @@ -// PayloadARequestBody is used to define fields on request body types. -type PayloadARequestBody struct { +// PayloadA is used to define fields on request body types. +type PayloadA struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// PayloadBRequestBody is used to define fields on request body types. -type PayloadBRequestBody struct { +// PayloadB is used to define fields on request body types. +type PayloadB struct { A string `form:"a" json:"a" xml:"a"` B string `form:"b" json:"b" xml:"b"` } -// NewPayloadARequestBody builds the HTTP request body from the payload of the -// "MethodA" endpoint of the "ServiceMultipleMethods" service. -func NewPayloadARequestBody(p []*servicemultiplemethods.PayloadA) []*PayloadARequestBody { - body := make([]*PayloadARequestBody, len(p)) +// NewPayloadA builds the HTTP request body from the payload of the "MethodA" +// endpoint of the "ServiceMultipleMethods" service. +func NewPayloadA(p []*servicemultiplemethods.PayloadA) []*PayloadA { + body := make([]*PayloadA, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalServicemultiplemethodsPayloadAToPayloadARequestBody(val) + body[i] = marshalServicemultiplemethodsPayloadAToPayloadA(val) } return body } -// NewPayloadBRequestBody builds the HTTP request body from the payload of the -// "MethodB" endpoint of the "ServiceMultipleMethods" service. -func NewPayloadBRequestBody(p []*servicemultiplemethods.PayloadB) []*PayloadBRequestBody { - body := make([]*PayloadBRequestBody, len(p)) +// NewPayloadB builds the HTTP request body from the payload of the "MethodB" +// endpoint of the "ServiceMultipleMethods" service. +func NewPayloadB(p []*servicemultiplemethods.PayloadB) []*PayloadB { + body := make([]*PayloadB, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalServicemultiplemethodsPayloadBToPayloadBRequestBody(val) + body[i] = marshalServicemultiplemethodsPayloadBToPayloadB(val) } return body } -// ValidatePayloadARequestBody runs the validations defined on -// PayloadARequestBody -func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { +// ValidatePayloadA runs the validations defined on PayloadA +func ValidatePayloadA(body *PayloadA) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } -// ValidatePayloadBRequestBody runs the validations defined on -// PayloadBRequestBody -func ValidatePayloadBRequestBody(body *PayloadBRequestBody) (err error) { +// ValidatePayloadB runs the validations defined on PayloadB +func ValidatePayloadB(body *PayloadB) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", body.A, "patterna")) err = goa.MergeErrors(err, goa.ValidatePattern("body.b", body.B, "patternb")) return diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden index 46d896109a..b15e5cd4c8 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden @@ -7,13 +7,13 @@ type MethodARequestBody struct { // MethodBRequestBody is the type of the "ServiceMultipleMethods" service // "MethodB" endpoint HTTP request body. type MethodBRequestBody struct { - A string `form:"a" json:"a" xml:"a"` - B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` - C *APayloadRequestBody `form:"c" json:"c" xml:"c"` + A string `form:"a" json:"a" xml:"a"` + B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` + C *APayload `form:"c" json:"c" xml:"c"` } -// APayloadRequestBody is used to define fields on request body types. -type APayloadRequestBody struct { +// APayload is used to define fields on request body types. +type APayload struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } @@ -34,14 +34,13 @@ func NewMethodBRequestBody(p *servicemultiplemethods.PayloadType) *MethodBReques B: p.B, } if p.C != nil { - body.C = marshalServicemultiplemethodsAPayloadToAPayloadRequestBody(p.C) + body.C = marshalServicemultiplemethodsAPayloadToAPayload(p.C) } return body } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody -func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { +// ValidateAPayload runs the validations defined on APayload +func ValidateAPayload(body *APayload) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } diff --git a/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden index 477eab8b1f..93b37eac92 100644 --- a/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden @@ -1,15 +1,15 @@ // ClientStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "ClientStream" endpoint HTTP // request body. -type ClientStreamStreamingBody StreamingRequestStreamingBody +type ClientStreamStreamingBody StreamingRequest // BidirectionalStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "BidirectionalStream" // endpoint HTTP request body. -type BidirectionalStreamStreamingBody StreamingRequestStreamingBody +type BidirectionalStreamStreamingBody StreamingRequest -// StreamingRequestStreamingBody is used to define fields on request body types. -type StreamingRequestStreamingBody struct { +// StreamingRequest is used to define fields on request body types. +type StreamingRequest struct { Required string `form:"required" json:"required" xml:"required"` Optional *string `form:"optional,omitempty" json:"optional,omitempty" xml:"optional,omitempty"` BaseRequired string `form:"baseRequired" json:"baseRequired" xml:"baseRequired"` diff --git a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden index 9d47f2ddc7..6fabebb342 100644 --- a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden @@ -1,6 +1,6 @@ // MethodWithErrorCustomPkgErrorNameResponseBody is the type of the // "ServiceWithErrorCustomPkg" service "MethodWithErrorCustomPkg" endpoint HTTP -// response body for the "error_name" error. +// response body. type MethodWithErrorCustomPkgErrorNameResponseBody struct { Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` } @@ -16,7 +16,7 @@ func NewMethodWithErrorCustomPkgErrorName(body *MethodWithErrorCustomPkgErrorNam } // ValidateMethodWithErrorCustomPkgErrorNameResponseBody runs the validations -// defined on MethodWithErrorCustomPkg_error_name_Response_Body +// defined on MethodWithErrorCustomPkgErrorNameResponseBody func ValidateMethodWithErrorCustomPkgErrorNameResponseBody(body *MethodWithErrorCustomPkgErrorNameResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden index 69eb2409af..17fae0fe8b 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden @@ -2,19 +2,19 @@ // "ServiceResultWithResultCollection" service // "MethodResultWithResultCollection" endpoint HTTP response body. type MethodResultWithResultCollectionResponseBody struct { - A *ResulttypeResponseBody `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + A *Resulttype `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// ResulttypeResponseBody is used to define fields on response body types. -type ResulttypeResponseBody struct { - X RtCollectionResponseBody `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` +// Resulttype is used to define fields on response body types. +type Resulttype struct { + X RtCollection `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// RtCollectionResponseBody is used to define fields on response body types. -type RtCollectionResponseBody []*RtResponseBody +// RtCollection is used to define fields on response body types. +type RtCollection []*Rt -// RtResponseBody is used to define fields on response body types. -type RtResponseBody struct { +// Rt is used to define fields on response body types. +type Rt struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -24,7 +24,7 @@ type RtResponseBody struct { func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCollectionResponseBody) *serviceresultwithresultcollection.MethodResultWithResultCollectionResult { v := &serviceresultwithresultcollection.MethodResultWithResultCollectionResult{} if body.A != nil { - v.A = unmarshalResulttypeResponseBodyToServiceresultwithresultcollectionResulttype(body.A) + v.A = unmarshalResulttypeToServiceresultwithresultcollectionResulttype(body.A) } return v @@ -34,30 +34,28 @@ func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCol // defined on MethodResultWithResultCollectionResponseBody func ValidateMethodResultWithResultCollectionResponseBody(body *MethodResultWithResultCollectionResponseBody) (err error) { if body.A != nil { - if err2 := ValidateResulttypeResponseBody(body.A); err2 != nil { + if err2 := ValidateResulttype(body.A); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateResulttypeResponseBody runs the validations defined on -// ResulttypeResponseBody -func ValidateResulttypeResponseBody(body *ResulttypeResponseBody) (err error) { +// ValidateResulttype runs the validations defined on Resulttype +func ValidateResulttype(body *Resulttype) (err error) { if body.X != nil { - if err2 := ValidateRtCollectionResponseBody(body.X); err2 != nil { + if err2 := ValidateRtCollection(body.X); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateRtCollectionResponseBody runs the validations defined on -// RtCollectionResponseBody -func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) { +// ValidateRtCollection runs the validations defined on RtCollection +func ValidateRtCollection(body RtCollection) (err error) { for _, e := range body { if e != nil { - if err2 := ValidateRtResponseBody(e); err2 != nil { + if err2 := ValidateRt(e); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -65,8 +63,8 @@ func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) return } -// ValidateRtResponseBody runs the validations defined on RtResponseBody -func ValidateRtResponseBody(body *RtResponseBody) (err error) { +// ValidateRt runs the validations defined on Rt +func ValidateRt(body *Rt) (err error) { if body.X != nil { if utf8.RuneCountInString(*body.X) < 5 { err = goa.MergeErrors(err, goa.InvalidLengthError("body.x", *body.X, utf8.RuneCountInString(*body.X), 5, true)) diff --git a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden index 766af2025c..c89d793d0c 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden @@ -2,12 +2,12 @@ // "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint // HTTP response body. type MethodResultWithResultViewResponseBodyFull struct { - Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` - Rt *RtResponseBody `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Rt *Rt `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` } -// RtResponseBody is used to define fields on response body types. -type RtResponseBody struct { +// Rt is used to define fields on response body types. +type Rt struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResulttypeOK(body *MethodResultWithResultViewR Name: body.Name, } if body.Rt != nil { - v.Rt = unmarshalRtResponseBodyToServiceresultwithresultviewviewsRtView(body.Rt) + v.Rt = unmarshalRtToServiceresultwithresultviewviewsRtView(body.Rt) } return v diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden index 2c63d024f3..c3f3ccf82c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden @@ -5,7 +5,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode return func(r *http.Request) ([]*servicebodyprimitivearrayuserrequired.PayloadType, error) { var payload []*servicebodyprimitivearrayuserrequired.PayloadType var ( - body []*PayloadTypeRequestBody + body []*PayloadType err error ) err = decoder(r).Decode(&body) @@ -21,7 +21,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := ValidatePayloadType(e); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden index dfa38dfb4f..095de5149e 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden @@ -5,7 +5,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode return func(r *http.Request) ([]*servicebodyprimitivearrayuservalidate.PayloadType, error) { var payload []*servicebodyprimitivearrayuservalidate.PayloadType var ( - body []*PayloadTypeRequestBody + body []*PayloadType err error ) err = decoder(r).Decode(&body) @@ -24,7 +24,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := ValidatePayloadType(e); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden index 649c70dede..6a1141e2c1 100644 --- a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden @@ -1,13 +1,13 @@ -// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBody -// builds a value of type *ImmediatechildextenderResponseBody from a value of -// type *servicedeepuserviews.ImmediatechildextenderView. -func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBody(v *servicedeepuserviews.ImmediatechildextenderView) *ImmediatechildextenderResponseBody { +// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextender +// builds a value of type *Immediatechildextender from a value of type +// *servicedeepuserviews.ImmediatechildextenderView. +func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextender(v *servicedeepuserviews.ImmediatechildextenderView) *Immediatechildextender { if v == nil { return nil } - res := &ImmediatechildextenderResponseBody{} + res := &Immediatechildextender{} if v.DeepChild != nil { - res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchildResponseBody(v.DeepChild) + res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchild(v.DeepChild) } return res diff --git a/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden b/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden index 8ace8679e3..ea11341cac 100644 --- a/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden +++ b/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden @@ -5,7 +5,7 @@ func EncodeMethodBodyCollectionExplicitViewResponse(encoder func(context.Context return func(ctx context.Context, w http.ResponseWriter, v any) error { res := v.(servicebodycollectionexplicitviewviews.ResulttypecollectionCollection) enc := encoder(ctx, w) - body := NewResulttypecollectionResponseTinyCollection(res.Projected) + body := NewResulttypecollectionTinyCollection(res.Projected) w.WriteHeader(http.StatusOK) return enc.Encode(body) } diff --git a/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden b/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden index 8e743d8c79..b9a0f329cb 100644 --- a/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden +++ b/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden @@ -8,9 +8,9 @@ func EncodeMethodBodyCollectionResponse(encoder func(context.Context, http.Respo var body any switch res.View { case "default", "": - body = NewResulttypecollectionResponseCollection(res.Projected) + body = NewResulttypecollectionCollection(res.Projected) case "tiny": - body = NewResulttypecollectionResponseTinyCollection(res.Projected) + body = NewResulttypecollectionTinyCollection(res.Projected) } w.WriteHeader(http.StatusOK) return enc.Encode(body) diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden index 1c8f253112..89902850d2 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalResultTypeRequestBodyToFooserviceResultType builds a value of type -// *fooservice.ResultType from a value of type *ResultTypeRequestBody. -func unmarshalResultTypeRequestBodyToFooserviceResultType(v *ResultTypeRequestBody) *fooservice.ResultType { +// unmarshalResultTypeToFooserviceResultType builds a value of type +// *fooservice.ResultType from a value of type *ResultType. +func unmarshalResultTypeToFooserviceResultType(v *ResultType) *fooservice.ResultType { res := &fooservice.ResultType{} if v.Foo != nil { foo := fooservice.Foo(*v.Foo) diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden index bbbcf31907..b5eab461bc 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden @@ -1,7 +1,7 @@ -// marshalFooserviceResultTypeToResultTypeResponse builds a value of type -// *ResultTypeResponse from a value of type *fooservice.ResultType. -func marshalFooserviceResultTypeToResultTypeResponse(v *fooservice.ResultType) *ResultTypeResponse { - res := &ResultTypeResponse{} +// marshalFooserviceResultTypeToResultType2 builds a value of type *ResultType2 +// from a value of type *fooservice.ResultType. +func marshalFooserviceResultTypeToResultType2(v *fooservice.ResultType) *ResultType2 { + res := &ResultType2{} if v.Foo != nil { foo := string(*v.Foo) res.Foo = &foo diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden index c79024ee92..29b33bdc91 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalFooRequestBodyToFooFoo builds a value of type *foo.Foo from a value -// of type *FooRequestBody. -func unmarshalFooRequestBodyToFooFoo(v *FooRequestBody) *foo.Foo { +// unmarshalFooToFooFoo builds a value of type *foo.Foo from a value of type +// *Foo. +func unmarshalFooToFooFoo(v *Foo) *foo.Foo { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden index 4f3e2bf6df..91cb0b7493 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden @@ -1,10 +1,10 @@ -// marshalFooFooToFooResponseBody builds a value of type *FooResponseBody from -// a value of type *foo.Foo. -func marshalFooFooToFooResponseBody(v *foo.Foo) *FooResponseBody { +// marshalFooFooToFoo2 builds a value of type *Foo2 from a value of type +// *foo.Foo. +func marshalFooFooToFoo2(v *foo.Foo) *Foo2 { if v == nil { return nil } - res := &FooResponseBody{ + res := &Foo2{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden index ca22a60a55..30cce7eb2e 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden @@ -1,12 +1,12 @@ -// unmarshalExtensionRequestBodyToFooserviceExtension builds a value of type -// *fooservice.Extension from a value of type *ExtensionRequestBody. -func unmarshalExtensionRequestBodyToFooserviceExtension(v *ExtensionRequestBody) *fooservice.Extension { +// unmarshalExtensionToFooserviceExtension builds a value of type +// *fooservice.Extension from a value of type *Extension. +func unmarshalExtensionToFooserviceExtension(v *Extension) *fooservice.Extension { if v == nil { return nil } res := &fooservice.Extension{} if v.Bar != nil { - res.Bar = unmarshalBarRequestBodyToFooserviceBar(v.Bar) + res.Bar = unmarshalBarToFooserviceBar(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden index 32ec2f62e4..56f57181d3 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden @@ -1,6 +1,6 @@ -// unmarshalBarRequestBodyToFooserviceBar builds a value of type -// *fooservice.Bar from a value of type *BarRequestBody. -func unmarshalBarRequestBodyToFooserviceBar(v *BarRequestBody) *fooservice.Bar { +// unmarshalBarToFooserviceBar builds a value of type *fooservice.Bar from a +// value of type *Bar. +func unmarshalBarToFooserviceBar(v *Bar) *fooservice.Bar { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden index 0317d61a1e..7c7d09668e 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden @@ -1,9 +1,9 @@ -// marshalFooserviceResultTypeToResultTypeResponse builds a value of type -// *ResultTypeResponse from a value of type *fooservice.ResultType. -func marshalFooserviceResultTypeToResultTypeResponse(v *fooservice.ResultType) *ResultTypeResponse { - res := &ResultTypeResponse{} +// marshalFooserviceResultTypeToResultType2 builds a value of type *ResultType2 +// from a value of type *fooservice.ResultType. +func marshalFooserviceResultTypeToResultType2(v *fooservice.ResultType) *ResultType2 { + res := &ResultType2{} if v.Extension != nil { - res.Extension = marshalFooserviceExtensionToExtensionResponse(v.Extension) + res.Extension = marshalFooserviceExtensionToExtension2(v.Extension) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden index 730ae35211..99d42dfe62 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden @@ -1,12 +1,12 @@ -// marshalFooserviceExtensionToExtensionResponse builds a value of type -// *ExtensionResponse from a value of type *fooservice.Extension. -func marshalFooserviceExtensionToExtensionResponse(v *fooservice.Extension) *ExtensionResponse { +// marshalFooserviceExtensionToExtension2 builds a value of type *Extension2 +// from a value of type *fooservice.Extension. +func marshalFooserviceExtensionToExtension2(v *fooservice.Extension) *Extension2 { if v == nil { return nil } - res := &ExtensionResponse{} + res := &Extension2{} if v.Bar != nil { - res.Bar = marshalFooserviceBarToBarResponse(v.Bar) + res.Bar = marshalFooserviceBarToBar2(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden index 97c3aeb895..a14f3cbd47 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden @@ -1,10 +1,10 @@ -// marshalFooserviceBarToBarResponse builds a value of type *BarResponse from a -// value of type *fooservice.Bar. -func marshalFooserviceBarToBarResponse(v *fooservice.Bar) *BarResponse { +// marshalFooserviceBarToBar2 builds a value of type *Bar2 from a value of type +// *fooservice.Bar. +func marshalFooserviceBarToBar2(v *fooservice.Bar) *Bar2 { if v == nil { return nil } - res := &BarResponse{ + res := &Bar2{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden index 7148b9f7a6..e6de120b47 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden @@ -1,13 +1,13 @@ // NewMethodBodyInlineArrayUserElemType builds a ServiceBodyInlineArrayUser // service MethodBodyInlineArrayUser endpoint payload. -func NewMethodBodyInlineArrayUserElemType(body []*ElemTypeRequestBody) []*servicebodyinlinearrayuser.ElemType { +func NewMethodBodyInlineArrayUserElemType(body []*ElemType) []*servicebodyinlinearrayuser.ElemType { v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) for i, val := range body { if val == nil { v[i] = nil continue } - v[i] = unmarshalElemTypeRequestBodyToServicebodyinlinearrayuserElemType(val) + v[i] = unmarshalElemTypeToServicebodyinlinearrayuserElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index a92096c3cf..0c6fdc4900 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -1,14 +1,14 @@ // NewMethodBodyInlineMapUserMapKeyTypeElemType builds a // ServiceBodyInlineMapUser service MethodBodyInlineMapUser endpoint payload. -func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyTypeRequestBody]*ElemTypeRequestBody) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { +func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyType]*ElemType) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := unmarshalKeyTypeToServicebodyinlinemapuserKeyType(val) if val == nil { v[tk] = nil continue } - v[tk] = unmarshalElemTypeRequestBodyToServicebodyinlinemapuserElemType(val) + v[tk] = unmarshalElemTypeToServicebodyinlinemapuserElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden index ad6f4a9f37..350b94cfd0 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden @@ -3,7 +3,7 @@ // endpoint payload. func NewMethodBodyInlineRecursiveUserPayloadType(body *MethodBodyInlineRecursiveUserRequestBody, a string, b *string) *servicebodyinlinerecursiveuser.PayloadType { v := &servicebodyinlinerecursiveuser.PayloadType{} - v.C = unmarshalPayloadTypeRequestBodyToServicebodyinlinerecursiveuserPayloadType(body.C) + v.C = unmarshalPayloadTypeToServicebodyinlinerecursiveuserPayloadType(body.C) v.A = a v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden index cbf59b00b7..eeedde3a5d 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden @@ -3,7 +3,7 @@ // endpoint payload. func NewMethodBodyQueryUserUnionValidatePayloadType(body *MethodBodyQueryUserUnionValidateRequestBody, b string) *servicebodyqueryuserunionvalidate.PayloadType { v := &servicebodyqueryuserunionvalidate.PayloadType{} - v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionvalidateUnion(body.A) + v.A = unmarshalUnionToServicebodyqueryuserunionvalidateUnion(body.A) v.B = b return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden index 0217998a99..add18b987b 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyQueryUserUnionPayloadType(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { v := &servicebodyqueryuserunion.PayloadType{} if body.A != nil { - v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionUnion(body.A) + v.A = unmarshalUnionToServicebodyqueryuserunionUnion(body.A) } v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden index 1eef27f0b2..88199e1d06 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerDefaultPayloadType(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { v := &servicebodyuserinnerdefault.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType(body.Inner) + v.Inner = unmarshalInnerTypeToServicebodyuserinnerdefaultInnerType(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden index 519355b7a7..d86f57143d 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyUserInnerPayloadType(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { v := &servicebodyuserinner.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerInnerType(body.Inner) + v.Inner = unmarshalInnerTypeToServicebodyuserinnerInnerType(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden index 4f49ae0eca..e55c690121 100644 --- a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden @@ -1,15 +1,15 @@ // MethodARequestBody is the type of the "ServiceMixedPayloadInBody" service // "MethodA" endpoint HTTP request body. type MethodARequestBody struct { - Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` - Array []float32 `form:"array,omitempty" json:"array,omitempty" xml:"array,omitempty"` - Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` - Object *BPayloadRequestBody `form:"object,omitempty" json:"object,omitempty" xml:"object,omitempty"` - DupObj *BPayloadRequestBody `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` + Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` + Array []float32 `form:"array,omitempty" json:"array,omitempty" xml:"array,omitempty"` + Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` + Object *BPayload `form:"object,omitempty" json:"object,omitempty" xml:"object,omitempty"` + DupObj *BPayload `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` } -// BPayloadRequestBody is used to define fields on request body types. -type BPayloadRequestBody struct { +// BPayload is used to define fields on request body types. +type BPayload struct { Int *int `form:"int,omitempty" json:"int,omitempty" xml:"int,omitempty"` Bytes []byte `form:"bytes,omitempty" json:"bytes,omitempty" xml:"bytes,omitempty"` } @@ -32,9 +32,9 @@ func NewMethodAAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APa v.Map[tk] = tv } } - v.Object = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayload(body.Object) + v.Object = unmarshalBPayloadToServicemixedpayloadinbodyBPayload(body.Object) if body.DupObj != nil { - v.DupObj = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayload(body.DupObj) + v.DupObj = unmarshalBPayloadToServicemixedpayloadinbodyBPayload(body.DupObj) } return v @@ -49,21 +49,20 @@ func ValidateMethodARequestBody(body *MethodARequestBody) (err error) { err = goa.MergeErrors(err, goa.MissingFieldError("object", "body")) } if body.Object != nil { - if err2 := ValidateBPayloadRequestBody(body.Object); err2 != nil { + if err2 := ValidateBPayload(body.Object); err2 != nil { err = goa.MergeErrors(err, err2) } } if body.DupObj != nil { - if err2 := ValidateBPayloadRequestBody(body.DupObj); err2 != nil { + if err2 := ValidateBPayload(body.DupObj); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateBPayloadRequestBody runs the validations defined on -// BPayloadRequestBody -func ValidateBPayloadRequestBody(body *BPayloadRequestBody) (err error) { +// ValidateBPayload runs the validations defined on BPayload +func ValidateBPayload(body *BPayload) (err error) { if body.Int == nil { err = goa.MergeErrors(err, goa.MissingFieldError("int", "body")) } diff --git a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden index b6a9ca3eaa..f4fb982974 100644 --- a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden @@ -7,13 +7,13 @@ type MethodARequestBody struct { // MethodBRequestBody is the type of the "ServiceMultipleMethods" service // "MethodB" endpoint HTTP request body. type MethodBRequestBody struct { - A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` - B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` - C *APayloadRequestBody `form:"c,omitempty" json:"c,omitempty" xml:"c,omitempty"` + A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` + C *APayload `form:"c,omitempty" json:"c,omitempty" xml:"c,omitempty"` } -// APayloadRequestBody is used to define fields on request body types. -type APayloadRequestBody struct { +// APayload is used to define fields on request body types. +type APayload struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } @@ -34,7 +34,7 @@ func NewMethodBPayloadType(body *MethodBRequestBody) *servicemultiplemethods.Pay A: *body.A, B: body.B, } - v.C = unmarshalAPayloadRequestBodyToServicemultiplemethodsAPayload(body.C) + v.C = unmarshalAPayloadToServicemultiplemethodsAPayload(body.C) return v } @@ -62,16 +62,15 @@ func ValidateMethodBRequestBody(body *MethodBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.b", *body.B, "patternb")) } if body.C != nil { - if err2 := ValidateAPayloadRequestBody(body.C); err2 != nil { + if err2 := ValidateAPayload(body.C); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody -func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { +// ValidateAPayload runs the validations defined on APayload +func ValidateAPayload(body *APayload) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } diff --git a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden index cdb65103b2..09ca0a227b 100644 --- a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden +++ b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden @@ -1,11 +1,11 @@ // MethodStreamingBody is the type of the "ServicePayloadValidatedAlias" // service "Method" endpoint HTTP request body. type MethodStreamingBody struct { - Name *ValidatedStringStreamingBody `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Name *ValidatedString `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` } -// ValidatedStringStreamingBody is used to define fields on request body types. -type ValidatedStringStreamingBody string +// ValidatedString is used to define fields on request body types. +type ValidatedString string // NewMethodStreamingBody builds a ServicePayloadValidatedAlias service Method // endpoint payload. diff --git a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden index 411849e600..9bdde0ed41 100644 --- a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden @@ -1,15 +1,15 @@ // ClientStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "ClientStream" endpoint HTTP // request body. -type ClientStreamStreamingBody StreamingRequestStreamingBody +type ClientStreamStreamingBody StreamingRequest // BidirectionalStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "BidirectionalStream" // endpoint HTTP request body. -type BidirectionalStreamStreamingBody StreamingRequestStreamingBody +type BidirectionalStreamStreamingBody StreamingRequest -// StreamingRequestStreamingBody is used to define fields on request body types. -type StreamingRequestStreamingBody struct { +// StreamingRequest is used to define fields on request body types. +type StreamingRequest struct { Required *string `form:"required,omitempty" json:"required,omitempty" xml:"required,omitempty"` Optional *string `form:"optional,omitempty" json:"optional,omitempty" xml:"optional,omitempty"` BaseRequired *string `form:"baseRequired,omitempty" json:"baseRequired,omitempty" xml:"baseRequired,omitempty"` @@ -64,9 +64,8 @@ func ValidateBidirectionalStreamStreamingBody(body *BidirectionalStreamStreaming return } -// ValidateStreamingRequestStreamingBody runs the validations defined on -// StreamingRequestStreamingBody -func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) (err error) { +// ValidateStreamingRequest runs the validations defined on StreamingRequest +func ValidateStreamingRequest(body *StreamingRequest) (err error) { if body.Required == nil { err = goa.MergeErrors(err, goa.MissingFieldError("required", "body")) } diff --git a/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden index bd7ddff0e3..b7fa46630b 100644 --- a/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden @@ -1,6 +1,6 @@ // MethodWithErrorCustomPkgErrorNameResponseBody is the type of the // "ServiceWithErrorCustomPkg" service "MethodWithErrorCustomPkg" endpoint HTTP -// response body for the "error_name" error. +// response body. type MethodWithErrorCustomPkgErrorNameResponseBody struct { Name string `form:"name" json:"name" xml:"name"` } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden index 288dad2fad..a06e7d4d5d 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden @@ -1,33 +1,32 @@ -// ResulttypesiblingcollectionResponseCollection is the type of the +// ResulttypesiblingcollectionCollection is the type of the // "ServiceResultCollectionUserTypeSibling" service // "MethodResultCollectionUserTypeSibling" endpoint HTTP response body. -type ResulttypesiblingcollectionResponseCollection []*ResulttypesiblingcollectionResponse +type ResulttypesiblingcollectionCollection []*Resulttypesiblingcollection -// ResulttypesiblingcollectionResponse is used to define fields on response -// body types. -type ResulttypesiblingcollectionResponse struct { +// Resulttypesiblingcollection is used to define fields on response body types. +type Resulttypesiblingcollection struct { // Attribute A - A *UserTypeResponse `json:"a"` + A *UserType `json:"a"` // Attribute B - B *UserTypeResponse `json:"b"` + B *UserType `json:"b"` } -// UserTypeResponse is used to define fields on response body types. -type UserTypeResponse struct { +// UserType is used to define fields on response body types. +type UserType struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } -// NewResulttypesiblingcollectionResponseCollection builds the HTTP response -// body from the result of the "MethodResultCollectionUserTypeSibling" endpoint -// of the "ServiceResultCollectionUserTypeSibling" service. -func NewResulttypesiblingcollectionResponseCollection(res serviceresultcollectionusertypesiblingviews.ResulttypesiblingcollectionCollectionView) ResulttypesiblingcollectionResponseCollection { - body := make([]*ResulttypesiblingcollectionResponse, len(res)) +// NewResulttypesiblingcollectionCollection builds the HTTP response body from +// the result of the "MethodResultCollectionUserTypeSibling" endpoint of the +// "ServiceResultCollectionUserTypeSibling" service. +func NewResulttypesiblingcollectionCollection(res serviceresultcollectionusertypesiblingviews.ResulttypesiblingcollectionCollectionView) ResulttypesiblingcollectionCollection { + body := make([]*Resulttypesiblingcollection, len(res)) for i, val := range res { if val == nil { body[i] = nil continue } - body[i] = marshalServiceresultcollectionusertypesiblingviewsResulttypesiblingcollectionViewToResulttypesiblingcollectionResponse(val) + body[i] = marshalServiceresultcollectionusertypesiblingviewsResulttypesiblingcollectionViewToResulttypesiblingcollection(val) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden index dd7aa4ebe0..f7a67cbfdd 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden @@ -2,19 +2,19 @@ // "ServiceResultWithResultCollection" service // "MethodResultWithResultCollection" endpoint HTTP response body. type MethodResultWithResultCollectionResponseBody struct { - A *ResulttypeResponseBody `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + A *Resulttype `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// ResulttypeResponseBody is used to define fields on response body types. -type ResulttypeResponseBody struct { - X RtCollectionResponseBody `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` +// Resulttype is used to define fields on response body types. +type Resulttype struct { + X RtCollection `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// RtCollectionResponseBody is used to define fields on response body types. -type RtCollectionResponseBody []*RtResponseBody +// RtCollection is used to define fields on response body types. +type RtCollection []*Rt -// RtResponseBody is used to define fields on response body types. -type RtResponseBody struct { +// Rt is used to define fields on response body types. +type Rt struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -24,7 +24,7 @@ type RtResponseBody struct { func NewMethodResultWithResultCollectionResponseBody(res *serviceresultwithresultcollection.MethodResultWithResultCollectionResult) *MethodResultWithResultCollectionResponseBody { body := &MethodResultWithResultCollectionResponseBody{} if res.A != nil { - body.A = marshalServiceresultwithresultcollectionResulttypeToResulttypeResponseBody(res.A) + body.A = marshalServiceresultwithresultcollectionResulttypeToResulttype(res.A) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden index c0cf2ae38e..31a6657ba6 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden @@ -3,19 +3,19 @@ // HTTP response body. type MethodResultUserTypeNestedResponseBody struct { // Outer A - A *UserTypeResponseBody `json:"outer_a"` - Nested *WrapperResponseBody `form:"nested,omitempty" json:"nested,omitempty" xml:"nested,omitempty"` + A *UserType `json:"outer_a"` + Nested *Wrapper `form:"nested,omitempty" json:"nested,omitempty" xml:"nested,omitempty"` } -// UserTypeResponseBody is used to define fields on response body types. -type UserTypeResponseBody struct { +// UserType is used to define fields on response body types. +type UserType struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } -// WrapperResponseBody is used to define fields on response body types. -type WrapperResponseBody struct { +// Wrapper is used to define fields on response body types. +type Wrapper struct { // Inner A - A *UserTypeResponseBody `json:"inner_a"` + A *UserType `json:"inner_a"` } // NewMethodResultUserTypeNestedResponseBody builds the HTTP response body from @@ -24,10 +24,10 @@ type WrapperResponseBody struct { func NewMethodResultUserTypeNestedResponseBody(res *serviceresultusertypenestedviews.ResulttypenestedView) *MethodResultUserTypeNestedResponseBody { body := &MethodResultUserTypeNestedResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserTypeResponseBody(res.A) + body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserType(res.A) } if res.Nested != nil { - body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapperResponseBody(res.Nested) + body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapper(res.Nested) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden index fd9a4529a2..9796f5c1f1 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden @@ -3,13 +3,13 @@ // endpoint HTTP response body. type MethodResultUserTypeSiblingResponseBody struct { // Attribute A - A *UserTypeResponseBody `json:"a"` + A *UserType `json:"a"` // Attribute B - B *UserTypeResponseBody `json:"b"` + B *UserType `json:"b"` } -// UserTypeResponseBody is used to define fields on response body types. -type UserTypeResponseBody struct { +// UserType is used to define fields on response body types. +type UserType struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } @@ -19,10 +19,10 @@ type UserTypeResponseBody struct { func NewMethodResultUserTypeSiblingResponseBody(res *serviceresultusertypesiblingviews.ResulttypesiblingView) *MethodResultUserTypeSiblingResponseBody { body := &MethodResultUserTypeSiblingResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody(res.A) + body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserType(res.A) } if res.B != nil { - body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody(res.B) + body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserType(res.B) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden index 5f33466e4e..4230d02562 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden @@ -2,12 +2,12 @@ // "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint // HTTP response body. type MethodResultWithResultViewResponseBodyFull struct { - Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` - Rt *RtResponseBody `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Rt *Rt `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` } -// RtResponseBody is used to define fields on response body types. -type RtResponseBody struct { +// Rt is used to define fields on response body types. +type Rt struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResponseBodyFull(res *serviceresultwithresultv Name: res.Name, } if res.Rt != nil { - body.Rt = marshalServiceresultwithresultviewviewsRtViewToRtResponseBody(res.Rt) + body.Rt = marshalServiceresultwithresultviewviewsRtViewToRt(res.Rt) } return body } diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index 4b9dc28626..df5472d33c 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -1,3 +1,5 @@ +// This file contains expected HTTP streaming sections used to verify that +// websocket client and server code names the exact catalog-owned wire types. package testdata var MixedEndpointsConnConfigurerStructCode = `// ConnConfigurer holds the websocket connection configurer functions for the @@ -639,11 +641,11 @@ func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingr var body any switch s.view { case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) + body = NewUsertypeTinyCollection(res.Projected) case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) + body = NewUsertypeExtendedCollection(res.Projected) case "default", "": - body = NewUsertypeResponseCollection(res.Projected) + body = NewUsertypeCollection(res.Projected) } return s.conn.WriteJSON(body) } @@ -732,7 +734,7 @@ func (s *StreamingResultCollectionWithExplicitViewMethodServerStream) Send(v str return s.upgradeErr } res := streamingresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeResponseTinyCollection(res.Projected) + body := NewUsertypeTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -790,7 +792,7 @@ var StreamingResultCollectionWithExplicitViewClientStreamRecvCode = `// Recv rea func (s *StreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (streamingresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv streamingresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeResponseTinyCollection + body UsertypeTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -1040,7 +1042,7 @@ var StreamingResultUserTypeArrayClientStreamRecvCode = `// Recv reads instances func (s *StreamingResultUserTypeArrayMethodClientStream) Recv() ([]*streamingresultusertypearrayservice.UserType, error) { var ( rv []*streamingresultusertypearrayservice.UserType - body []*UserTypeResponse + body []*UserType err error ) err = s.conn.ReadJSON(&body) @@ -1107,7 +1109,7 @@ var StreamingResultUserTypeMapClientStreamRecvCode = `// Recv reads instances of func (s *StreamingResultUserTypeMapMethodClientStream) Recv() (map[string]*streamingresultusertypemapservice.UserType, error) { var ( rv map[string]*streamingresultusertypemapservice.UserType - body map[string]*UserTypeResponse + body map[string]*UserType err error ) err = s.conn.ReadJSON(&body) @@ -1861,11 +1863,11 @@ func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SendAndClo var body any switch s.view { case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) + body = NewUsertypeTinyCollection(res.Projected) case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) + body = NewUsertypeExtendedCollection(res.Projected) case "default", "": - body = NewUsertypeResponseCollection(res.Projected) + body = NewUsertypeCollection(res.Projected) } return s.conn.WriteJSON(body) } @@ -2004,7 +2006,7 @@ var StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode = `// S func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { defer s.conn.Close() res := streamingpayloadresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeResponseTinyCollection(res.Projected) + body := NewUsertypeTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -2084,7 +2086,7 @@ var StreamingPayloadResultCollectionWithExplicitViewClientStreamRecvCode = `// C func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) CloseAndRecv() (streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeResponseTinyCollection + body UsertypeTinyCollection err error ) defer s.conn.Close() @@ -3412,11 +3414,11 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send var body any switch s.view { case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) + body = NewUsertypeTinyCollection(res.Projected) case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) + body = NewUsertypeExtendedCollection(res.Projected) case "default", "": - body = NewUsertypeResponseCollection(res.Projected) + body = NewUsertypeCollection(res.Projected) } return s.conn.WriteJSON(body) } @@ -3566,7 +3568,7 @@ func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodServerStrea return s.upgradeErr } res := bidirectionalstreamingresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeResponseTinyCollection(res.Projected) + body := NewUsertypeTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -3645,7 +3647,7 @@ var BidirectionalStreamingResultCollectionWithExplicitViewClientStreamRecvCode = func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (bidirectionalstreamingresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv bidirectionalstreamingresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeResponseTinyCollection + body UsertypeTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -4128,7 +4130,7 @@ var BidirectionalStreamingUserTypeArrayClientStreamRecvCode = `// Recv reads ins func (s *BidirectionalStreamingUserTypeArrayMethodClientStream) Recv() ([]*bidirectionalstreamingusertypearrayservice.ResultType, error) { var ( rv []*bidirectionalstreamingusertypearrayservice.ResultType - body []*ResultTypeResponse + body []*ResultType err error ) err = s.conn.ReadJSON(&body) @@ -4256,7 +4258,7 @@ var BidirectionalStreamingUserTypeMapClientStreamRecvCode = `// Recv reads insta func (s *BidirectionalStreamingUserTypeMapMethodClientStream) Recv() (map[string]*bidirectionalstreamingusertypemapservice.ResultType, error) { var ( rv map[string]*bidirectionalstreamingusertypemapservice.ResultType - body map[string]*ResultTypeResponse + body map[string]*ResultType err error ) err = s.conn.ReadJSON(&body) diff --git a/http/codegen/types.go b/http/codegen/types.go index 76f798726d..8ab2a57291 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -80,6 +80,7 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod validateSection = "client-validate" bodyInitT = clientBodyInitT } + unionTypes := data.wireTypes(svr).unionTypes() path := filepath.Join(codegen.Gendir, services.dir(), svcName, side, "types.go") imports := []*codegen.ImportSpec{ {Path: "encoding/json"}, @@ -87,7 +88,7 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod {Path: "unicode/utf8"}, services.ServiceImport(svc.Name()), } - if len(data.UnionTypes) > 0 { + if len(unionTypes) > 0 { imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) } views := services.ViewImport(svc.Name()) @@ -104,46 +105,42 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod sections = []*codegen.SectionTemplate{header} - // seen tracks the body types already emitted in this file. Server - // types are deduplicated by type name because distinct - // endpoint-scoped composite wrappers may share a structural - // reference, client types by reference because structurally - // identical types are decoded interchangeably. - seen = make(map[string]struct{}) + // seen tracks the canonical package records already emitted. Type names + // and references are outputs of these records, never declaration + // identity. + seen = make(map[*wireTypeRecord]struct{}) seenInits = make(map[string]struct{}) - seenValidated = make(map[string]struct{}) + seenValidated = make(map[*wireTypeRecord]struct{}) ) - key := func(td *TypeData) string { - if svr { - return td.Name - } - return td.Ref - } // addDecl emits the type declaration section if the type has a // definition. addDecl := func(name string, td *TypeData) { - if td.Def != "" { + if td.declaration == nil || td.Def == "" { + return + } + if _, ok := seen[td.declaration]; ok { + return + } + seen[td.declaration] = struct{}{} + declaration := td.declaration.data + if declaration != nil { sections = append(sections, &codegen.SectionTemplate{ Name: name, Source: httpTemplates.Read(typeDeclT), - Data: td, + Data: declaration, }) } } - // addValidated records the type for validation method generation. Client - // types are deduplicated by name; server types rely on the body type - // dedup performed by the callers. + // addValidated records each package-owned validation helper once. addValidated := func(td *TypeData) { - if td.ValidateDef == "" { + if td.declaration == nil || td.ValidateDef == "" { return } - if !svr { - if _, ok := seenValidated[td.Name]; ok { - return - } - seenValidated[td.Name] = struct{}{} + if _, ok := seenValidated[td.declaration]; ok { + return } - validatedTypes = append(validatedTypes, td) + seenValidated[td.declaration] = struct{}{} + validatedTypes = append(validatedTypes, td.declaration.data) } // request body types @@ -165,12 +162,6 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod if td == nil { continue } - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } name := requestBodySection if i == 1 { name = wsPayloadSection @@ -201,13 +192,12 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { - continue - } - seen[key(td)] = struct{}{} addDecl(responseBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } @@ -227,18 +217,12 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { - continue - } - // Server error body types without a definition are not - // marked as emitted: their endpoint-scoped constructors - // and validations are collected for every occurrence. - if !svr || td.Def != "" { - seen[key(td)] = struct{}{} - } addDecl(errorBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } @@ -252,18 +236,12 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod atts = data.ClientBodyAttributeTypes } for _, td := range atts { - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } addDecl(attributeSection, td) addValidated(td) } // union sum types - for _, u := range data.UnionTypes { + for _, u := range unionTypes { sections = append(sections, &codegen.SectionTemplate{ Name: unionSection, Source: httpTemplates.Read(unionTypeT), diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index f337727017..f89aca53eb 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -133,7 +133,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin var svcode string if ut, ok := body.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { - httpctx := httpContext(sd.Scope, true, true) + httpctx := httpContext(sd.serverWireTypes.scope, true, true) svcode = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") } } @@ -142,8 +142,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin AttributeData: &AttributeData{ Name: "payload", VarName: "body", - TypeName: sd.Scope.GoTypeName(streamBody), - TypeRef: sd.Scope.GoTypeRef(streamBody), + TypeName: sd.serverWireTypes.scope.GoTypeName(streamBody), + TypeRef: sd.serverWireTypes.scope.GoTypeRef(streamBody), Type: streamBody.Type, Required: true, // The example has always been computed from the @@ -155,7 +155,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin } if body != expr.Empty { var helpers []*codegen.TransformFunctionData - httpctx := httpContext(sd.Scope, true, true) + httpctx := httpContext(sd.serverWireTypes.scope, true, true) serverCode, helpers, err = marshal(streamBody, e.MethodExpr.StreamingPayload, "body", "v", httpctx, svcctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) @@ -176,10 +176,6 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin } } cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd) - if cliPayload != nil { - sd.ClientTypeNames[cliPayload.Name] = struct{}{} - sd.ServerTypeNames[cliPayload.Name] = struct{}{} - } if e.MethodExpr.Stream == expr.ClientStreamKind { svrSendDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection and closes the connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) svrSendWithContextDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection with context and closes the connection.", md.ServerStream.SendWithContextName, svrSendTypeName, md.Name) diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go new file mode 100644 index 0000000000..1526eeedd9 --- /dev/null +++ b/http/codegen/wire_catalog.go @@ -0,0 +1,675 @@ +// This file owns declaration identity and names for the wire types emitted by +// one generated HTTP or JSON-RPC client or server package. The catalog first +// collects detached shapes, then freezes names, and only then lets analysis +// build declarations, references, and validators from those records. +package codegen + +import ( + "fmt" + "reflect" + "slices" + "strconv" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // wireTypeCatalog owns the names and declarations emitted by one transport output package. + wireTypeCatalog struct { + scope *codegen.NameScope + records []*wireTypeRecord + unionOccurrences []wireUnionOccurrence + unions []*wireUnionRecord + names map[string]int + frozen bool + } + + // wireUnionRecord owns one emitted positional union in this output package. + wireUnionRecord struct { + identity wireUnionIdentity + union *expr.Union + name string + kindName string + kindConsts []string + constructors []string + data *service.UnionTypeData + } + + // wireUnionOccurrence records one union use until branch declarations have names. + wireUnionOccurrence struct { + union *expr.Union + role wireTypeRole + policy wireTypePolicy + } + + // wireUnionIdentity combines the authored wire shape with the exact frozen + // declarations referenced by its branches. + wireUnionIdentity struct { + definition codegen.UnionTypeID + declarations []*wireTypeRecord + } + + // wireTypeRecord is the canonical package-local declaration selected for a wire identity. + wireTypeRecord struct { + identity wireTypeIdentity + name string + ref string + data *TypeData + } + + // wireTypeIdentity contains typed declaration provenance and every policy + // fact that changes the emitted Go type. + wireTypeIdentity struct { + source expr.UserType + resultID string + role wireTypeRole + preferred string + attribute *expr.AttributeExpr + policy wireTypePolicy + } + + // wireTypePolicy describes the pointer, default, validation, and view rules applied to a wire shape. + wireTypePolicy struct { + request bool + pointer bool + useDefault bool + validate bool + view string + } + + // wireTypeRole identifies synthetic declarations that have no authored Origin. + wireTypeRole uint8 + + // wireAttributePair identifies two recursive attributes already compared. + wireAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } +) + +const ( + wireRequestBody wireTypeRole = iota + 1 + wireResponseBody + wireAttribute + wireStreamPayload +) + +// newWireTypeCatalog constructs an empty output-package catalog. +func newWireTypeCatalog(reserved ...string) *wireTypeCatalog { + scope := codegen.NewNameScope() + names := make(map[string]int, len(reserved)) + for _, name := range reserved { + scope.Unique(name) + names[name] = 1 + } + return &wireTypeCatalog{scope: scope, names: names} +} + +// collect records attribute and every named type it contains. +func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { + if c.frozen { + panic("cannot collect HTTP wire type after catalog freeze") + } + return c.collectRecursive(attribute, role, policy, preferred, make(map[expr.UserType]struct{})) +} + +// Freeze assigns final names and binds copied user types to the catalog scope. +func (c *wireTypeCatalog) Freeze() { + if c.frozen { + return + } + for _, record := range c.records { + record.name = c.uniqueName(record.identity.preferred) + record.ref = wireTypeRef(record.name, record.identity.attribute.Type) + setWireTypeName(record.identity.attribute, record.name) + if userType, ok := record.identity.attribute.Type.(expr.UserType); ok { + c.scope.HashedUnique(userType, record.name) + } else { + c.scope.Unique(record.name) + } + } + for _, occurrence := range c.unionOccurrences { + identity := c.unionIdentity(occurrence.union, occurrence.role, occurrence.policy) + if c.findUnion(identity) == nil { + c.unions = append(c.unions, &wireUnionRecord{identity: identity, union: occurrence.union}) + } + } + for _, union := range c.unions { + union.name = c.uniqueName(codegen.Goify(union.union.Name(), true)) + union.kindName = c.uniqueName(union.name + "Kind") + union.kindConsts = make([]string, len(union.union.Values)) + union.constructors = make([]string, len(union.union.Values)) + for index, branch := range union.union.Values { + fieldName := codegen.Goify(branch.Name, true) + union.kindConsts[index] = c.uniqueName(union.kindName + fieldName) + union.constructors[index] = c.uniqueName("New" + union.name + fieldName) + } + } + for _, union := range c.unions { + c.applyUnionRecord(union.union, union) + c.scope.HashedUnique(codegen.NewUnionTypeID(union.union), union.name) + c.scope.Unique(union.kindName) + for index := range union.kindConsts { + c.scope.Unique(union.kindConsts[index]) + c.scope.Unique(union.constructors[index]) + } + } + for _, union := range c.unions { + union.data = buildHTTPUnionTypeData(union.union, c.scope, union) + } + c.scope.Freeze() + c.frozen = true +} + +// wireTypeRef returns the Go reference owned by a frozen declaration record. +func wireTypeRef(name string, dataType expr.DataType) string { + if _, inline := dataType.(*expr.Object); inline { + return name + } + if expr.IsObject(dataType) || expr.IsUnion(dataType) { + return "*" + name + } + return name +} + +// lookup returns the frozen record and applies its name to an equivalent occurrence. +func (c *wireTypeCatalog) lookup(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { + if !c.frozen { + panic("cannot resolve HTTP wire type before catalog freeze") + } + identity := newWireTypeIdentity(attribute, role, policy, preferred) + record := c.find(identity) + if record != nil { + setWireTypeName(attribute, record.name) + return record + } + panic(fmt.Sprintf("HTTP wire type %q was not collected before catalog freeze", preferred)) +} + +// lookupUser returns the frozen record for a named user type and nil for an +// inline or primitive occurrence that has no top-level declaration. +func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + preferred := wireTypeDeclaredName(userType.Origin()) + if policy.view != "" { + preferred = wireTypeDeclaredName(userType) + } + return c.lookup(attribute, role, policy, codegen.Goify(preferred, true)) +} + +// applyNames writes every frozen nested declaration name onto one detached +// occurrence before type definitions or transforms traverse it. +func (c *wireTypeCatalog) applyNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { + c.applyNamesRecursive(attribute, role, policy, make(map[expr.UserType]struct{})) +} + +// applyNamesRecursive follows named fields once per authored origin. +func (c *wireTypeCatalog) applyNamesRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + c.lookupUser(attribute, role, policy) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyNamesRecursive(userType.Attribute(), wireAttribute, nestedPolicy, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyNamesRecursive(named.Attribute, wireAttribute, nestedPolicy, seen) + } + case *expr.Array: + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, seen) + case *expr.Map: + c.applyNamesRecursive(actual.KeyType, wireAttribute, nestedPolicy, seen) + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, seen) + case *expr.Union: + identity := c.unionIdentity(actual, role, policy) + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP union %q was not collected before catalog freeze", actual.Name())) + } + c.applyUnionRecord(actual, record) + } +} + +// unionTypes returns the frozen union declarations in deterministic name order. +func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { + unions := make([]*service.UnionTypeData, len(c.unions)) + for index, record := range c.unions { + unions[index] = record.data + } + slices.SortFunc(unions, func(left, right *service.UnionTypeData) int { return strings.Compare(left.Name, right.Name) }) + return unions +} + +// bind attaches occurrence-specific TypeData to its canonical declaration and +// merges the validator generated by any occurrence of that declaration. +func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData { + data.declaration = record + if record.data == nil { + if data.Def == "" && data.ValidateDef == "" { + return data + } + declaration := *data + declaration.Init = nil + record.data = &declaration + return data + } + if data.Def != "" { + if record.data.Def == "" { + record.data.Def = data.Def + } else if record.data.Def != data.Def { + panic(fmt.Sprintf("HTTP wire type %q produced conflicting declarations", record.name)) + } + } + if data.ValidateDef != "" { + if record.data.ValidateDef == "" { + record.data.ValidateDef = data.ValidateDef + record.data.ValidateRef = data.ValidateRef + } else if record.data.ValidateDef != data.ValidateDef || record.data.ValidateRef != data.ValidateRef { + panic(fmt.Sprintf("HTTP wire type %q produced conflicting validators", record.name)) + } + } + return data +} + +// collectRecursive records named declarations and terminates cycles by source Origin. +func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, seen map[expr.UserType]struct{}) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + var record *wireTypeRecord + if userType, ok := attribute.Type.(expr.UserType); ok { + name := wireTypeDeclaredName(userType.Origin()) + if policy.view != "" { + name = wireTypeDeclaredName(userType) + } + preferred = codegen.Goify(name, true) + record = c.findOrAppend(newWireTypeIdentity(attribute, role, policy, preferred)) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return record + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(userType.Attribute(), wireAttribute, nestedPolicy, "", seen) + delete(seen, origin) + return record + } + if preferred != "" { + record = c.findOrAppend(newWireTypeIdentity(attribute, role, policy, preferred)) + } + switch actual := attribute.Type.(type) { + case *expr.Object: + nestedPolicy := policy + nestedPolicy.view = "" + for _, named := range sortedWireAttributes(*actual) { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", seen) + } + case *expr.Array: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", seen) + case *expr.Map: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.KeyType, wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", seen) + case *expr.Union: + nestedPolicy := policy + nestedPolicy.view = "" + union := expr.Dup(actual).(*expr.Union) + c.unionOccurrences = append(c.unionOccurrences, wireUnionOccurrence{union: union, role: role, policy: policy}) + for _, named := range actual.Values { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", seen) + } + } + return record +} + +// findOrAppend reuses a structurally equal typed record or appends a new one. +func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecord { + if record := c.find(identity); record != nil { + return record + } + record := &wireTypeRecord{identity: identity} + c.records = append(c.records, record) + return record +} + +// find returns the declaration record equal to identity. +func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { + for _, record := range c.records { + if wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// unionIdentity resolves every named declaration referenced by union without +// changing the detached occurrence. +func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, policy wireTypePolicy) wireUnionIdentity { + identity := wireUnionIdentity{definition: codegen.NewUnionTypeID(union)} + attribute := &expr.AttributeExpr{Type: union} + c.collectUnionDeclarations(attribute, role, policy, &identity.declarations, make(map[expr.UserType]struct{})) + return identity +} + +// collectUnionDeclarations records package declarations in branch traversal order. +func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, declarations *[]*wireTypeRecord, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypeDeclaredName(userType.Origin()) + if policy.view != "" { + preferred = wireTypeDeclaredName(userType) + } + record := c.find(newWireTypeIdentity(attribute, role, policy, codegen.Goify(preferred, true))) + if record == nil { + panic(fmt.Sprintf("HTTP union branch type %q was not collected before catalog freeze", preferred)) + } + *declarations = append(*declarations, record) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectUnionDeclarations(userType.Attribute(), wireAttribute, nestedPolicy, declarations, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + case *expr.Array: + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Map: + c.collectUnionDeclarations(actual.KeyType, wireAttribute, nestedPolicy, declarations, seen) + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + } +} + +// applyUnionRecord writes exactly the branch declarations captured by record +// onto one equivalent union occurrence. +func (c *wireTypeCatalog) applyUnionRecord(union *expr.Union, record *wireUnionRecord) { + union.TypeName = record.name + index := 0 + seen := make(map[expr.UserType]struct{}) + for _, branch := range union.Values { + c.applyResolvedDeclarations(branch.Attribute, record.identity.declarations, &index, seen) + } + if index != len(record.identity.declarations) { + panic(fmt.Sprintf("HTTP union %q did not consume its frozen branch declarations", record.name)) + } +} + +// applyResolvedDeclarations consumes the typed declaration sequence captured +// while the union identity was built. +func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExpr, declarations []*wireTypeRecord, index *int, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if *index >= len(declarations) { + panic(fmt.Sprintf("HTTP union branch %q has no frozen declaration", wireTypeDeclaredName(userType))) + } + record := declarations[*index] + *index = *index + 1 + setWireTypeName(attribute, record.name) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + c.applyResolvedDeclarations(userType.Attribute(), declarations, index, seen) + delete(seen, origin) + return + } + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyResolvedDeclarations(named.Attribute, declarations, index, seen) + } + case *expr.Array: + c.applyResolvedDeclarations(actual.ElemType, declarations, index, seen) + case *expr.Map: + c.applyResolvedDeclarations(actual.KeyType, declarations, index, seen) + c.applyResolvedDeclarations(actual.ElemType, declarations, index, seen) + case *expr.Union: + definition := codegen.NewUnionTypeID(actual) + start := *index + for _, branch := range actual.Values { + c.applyResolvedDeclarations(branch.Attribute, declarations, index, seen) + } + identity := wireUnionIdentity{definition: definition, declarations: declarations[start:*index]} + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP nested union %q has no frozen declaration", actual.Name())) + } + actual.TypeName = record.name + } +} + +// findUnion returns the package union record equal to identity. +func (c *wireTypeCatalog) findUnion(identity wireUnionIdentity) *wireUnionRecord { + for _, record := range c.unions { + if wireUnionIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// wireUnionIdentitiesEqual compares the typed declaration sequence referenced by a wire union. +func wireUnionIdentitiesEqual(left, right wireUnionIdentity) bool { + return left.definition == right.definition && slices.Equal(left.declarations, right.declarations) +} + +// uniqueName allocates a package declaration without creating a second scope identity. +func (c *wireTypeCatalog) uniqueName(preferred string) string { + count := c.names[preferred] + if count == 0 { + c.names[preferred] = 1 + return preferred + } + for index := count + 1; ; index++ { + name := preferred + strconv.Itoa(index) + if c.names[name] == 0 { + c.names[preferred] = index + c.names[name] = 1 + return name + } + } +} + +// newWireTypeIdentity builds a typed identity from a detached occurrence. +func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) wireTypeIdentity { + identity := wireTypeIdentity{role: role, preferred: preferred, attribute: expr.DupAtt(attribute), policy: policy} + if userType, ok := attribute.Type.(expr.UserType); ok { + if resultType, ok := userType.(*expr.ResultTypeExpr); ok { + identity.resultID = resultType.Identifier + identity.role = 0 + } else if policy.view == "" { + identity.source = userType.Origin() + identity.role = 0 + } + } + return identity +} + +// wireTypeIdentitiesEqual compares provenance, policy, and the detached attribute contract. +func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { + if left.source != right.source || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { + return false + } + if left.source != nil { + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + if leftType, ok := left.attribute.Type.(expr.UserType); ok { + rightType, ok := right.attribute.Type.(expr.UserType) + return ok && wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + return wireAttributesEqual(left.attribute, right.attribute, make(map[wireAttributePair]struct{})) +} + +// wireTypePoliciesEqual compares only facts that change a Go declaration. +// Validation helpers are separate package records and do not create a second +// type when the wire representation is otherwise identical. +func wireTypePoliciesEqual(left, right wireTypePolicy) bool { + left.validate = false + right.validate = false + return left == right +} + +// wireAttributesEqual compares facts that change a declaration or validator and terminates cycles. +func wireAttributesEqual(left, right *expr.AttributeExpr, seen map[wireAttributePair]struct{}) bool { + if left == right { + return true + } + pair := wireAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || !reflect.DeepEqual(left.Validation, right.Validation) || !wireMetadataEqual(left.Meta, right.Meta) { + return false + } + switch ltype := left.Type.(type) { + case expr.UserType: + rtype, ok := right.Type.(expr.UserType) + if !ok { + return false + } + if lresult, ok := ltype.(*expr.ResultTypeExpr); ok { + rresult, ok := rtype.(*expr.ResultTypeExpr) + return ok && lresult.Identifier == rresult.Identifier && lresult.Name() == rresult.Name() && wireAttributesEqual(lresult.Attribute(), rresult.Attribute(), seen) + } + return ltype.Origin() == rtype.Origin() && wireAttributesEqual(ltype.Attribute(), rtype.Attribute(), seen) + case *expr.Object: + rtype, ok := right.Type.(*expr.Object) + if !ok || len(*ltype) != len(*rtype) { + return false + } + for index, field := range *ltype { + other := (*rtype)[index] + if field.Name != other.Name || !wireAttributesEqual(field.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + rtype, ok := right.Type.(*expr.Array) + return ok && ltype.NonNullableElems == rtype.NonNullableElems && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Map: + rtype, ok := right.Type.(*expr.Map) + return ok && wireAttributesEqual(ltype.KeyType, rtype.KeyType, seen) && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Union: + rtype, ok := right.Type.(*expr.Union) + if !ok || ltype.Name() != rtype.Name() || ltype.GetTypeKey() != rtype.GetTypeKey() || ltype.GetValueKey() != rtype.GetValueKey() || len(ltype.Values) != len(rtype.Values) { + return false + } + for index, branch := range ltype.Values { + other := rtype.Values[index] + if branch.Name != other.Name || !wireAttributesEqual(branch.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + return left.Type.Kind() == right.Type.Kind() && left.Type.Name() == right.Type.Name() + } +} + +// wireMetadataEqual compares authored metadata while ignoring the name written during Freeze. +func wireMetadataEqual(left, right expr.MetaExpr) bool { + keys := make([]string, 0, len(left)) + for key := range left { + if key != "struct:type:name" { + keys = append(keys, key) + } + } + slices.Sort(keys) + otherKeys := make([]string, 0, len(right)) + for key := range right { + if key != "struct:type:name" { + otherKeys = append(otherKeys, key) + } + } + slices.Sort(otherKeys) + if !slices.Equal(keys, otherKeys) { + return false + } + for _, key := range keys { + if !slices.Equal(left[key], right[key]) { + return false + } + } + return true +} + +// sortedWireAttributes makes declaration allocation independent of authored object field order. +func sortedWireAttributes(attributes []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + sorted := slices.Clone(attributes) + slices.SortFunc(sorted, func(left, right *expr.NamedAttributeExpr) int { + return strings.Compare(left.Name, right.Name) + }) + return sorted +} + +// setWireTypeName records the package-owned name on a detached user type. +func setWireTypeName(attribute *expr.AttributeExpr, name string) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + userType.Attribute().AddMeta("struct:type:name", name) + } +} + +// wireTypeDeclaredName returns the stable expression declaration name rather +// than a package name previously assigned through struct:type:name metadata. +func wireTypeDeclaredName(userType expr.UserType) string { + switch actual := userType.(type) { + case *expr.UserTypeExpr: + return actual.TypeName + case *expr.ResultTypeExpr: + return actual.TypeName + default: + panic(fmt.Sprintf("unsupported HTTP wire user type %T", userType)) + } +} diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go new file mode 100644 index 0000000000..80c9f1af19 --- /dev/null +++ b/http/codegen/wire_catalog_test.go @@ -0,0 +1,152 @@ +// This file verifies HTTP output packages distinguish declarations by source +// provenance and wire policy while reusing identical emitted shapes. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestWireTypeCatalogIdentity(t *testing.T) { + request := wireTypePolicy{request: true, pointer: true} + response := wireTypePolicy{useDefault: true} + first := wireCatalogType("Shared", "same", "first", true) + second := wireCatalogType("Shared", "same", "second", false) + + catalog := newWireTypeCatalog() + firstBody := makeHTTPType(&expr.AttributeExpr{Type: first}) + catalog.collect(firstBody, wireRequestBody, request, "") + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request, "") + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request, "") + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response, "") + catalog.Freeze() + firstRecord := catalog.lookupUser(firstBody, wireRequestBody, request) + reusedRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + secondRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + responseRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) + + require.Same(t, firstRecord, reusedRecord) + require.Equal(t, "Shared", firstRecord.name) + require.Equal(t, "Shared2", secondRecord.name) + require.Equal(t, "Shared3", responseRecord.name) +} + +func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Node", UID: "node"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + object.Set("next", &expr.AttributeExpr{Type: recursive}) + + catalog := newWireTypeCatalog() + body := makeHTTPType(&expr.AttributeExpr{Type: recursive}) + policy := wireTypePolicy{request: true, pointer: true} + catalog.collect(body, wireRequestBody, policy, "") + catalog.Freeze() + record := catalog.lookupUser(body, wireRequestBody, policy) + + require.Equal(t, "Node", record.name) + require.Len(t, catalog.records, 1) +} + +func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + withoutValidator := wireTypePolicy{pointer: true} + withValidator := wireTypePolicy{pointer: true, validate: true} + catalog := newWireTypeCatalog() + + first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator, "") + second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator, "") + + require.Same(t, first, second) + catalog.Freeze() + catalog.bind(first, &TypeData{Def: "struct { Value string }"}) + catalog.bind(second, &TypeData{Def: "struct { Value string }", ValidateDef: "validate shared"}) + require.Equal(t, "Shared", first.name) + require.Equal(t, "validate shared", first.data.ValidateDef) +} + +func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + attribute := &expr.AttributeExpr{Type: union} + catalog := newWireTypeCatalog() + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") + require.Len(t, catalog.unionOccurrences, 1) + catalog.Freeze() + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + require.Len(t, catalog.unions, 1) + require.NotNil(t, catalog.unions[0].data) +} + +func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog := newWireTypeCatalog("Shared") + catalog.collect(attribute, wireAttribute, policy, "") + catalog.Freeze() + + first := catalog.lookupUser(attribute, wireAttribute, policy) + second := catalog.lookupUser(attribute, wireAttribute, policy) + + require.Same(t, first, second) + require.Equal(t, "Shared2", first.name) +} + +func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { + originalNil := expr.Empty.Attribute().Meta == nil + original := expr.Empty.Attribute().Meta.Dup() + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + }} + catalog := newWireTypeCatalog() + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") + catalog.Freeze() + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + + if originalNil { + require.Nil(t, expr.Empty.Attribute().Meta) + } else { + require.Equal(t, original, expr.Empty.Attribute().Meta) + } +} + +func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Known", "known", "value", true)} + policy := wireTypePolicy{request: true, pointer: true} + catalog := newWireTypeCatalog() + catalog.collect(typeAttribute, wireRequestBody, policy, "") + catalog.Freeze() + + require.Panics(t, func() { + catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy, "") + }) + require.Panics(t, func() { + catalog.lookupUser(&expr.AttributeExpr{Type: wireCatalogType("Unknown", "unknown", "value", true)}, wireRequestBody, policy) + }) + require.Panics(t, func() { + catalog.scope.Unique("late") + }) +} + +// wireCatalogType builds an independent authored declaration. Equal UIDs are +// intentional: wire identity follows Origin rather than the example ID. +func wireCatalogType(name, uid, field string, required bool) *expr.UserTypeExpr { + attribute := &expr.AttributeExpr{Type: expr.String} + attribute.Validation = &expr.ValidationExpr{Pattern: field} + object := &expr.Object{{Name: field, Attribute: attribute}} + if required { + objectAttribute := &expr.AttributeExpr{Type: object, Validation: &expr.ValidationExpr{Required: []string{field}}} + return &expr.UserTypeExpr{AttributeExpr: objectAttribute, TypeName: name, UID: uid} + } + return &expr.UserTypeExpr{AttributeExpr: &expr.AttributeExpr{Type: object}, TypeName: name, UID: uid} +} diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index 607b29459b..1c659125be 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -1,3 +1,5 @@ +// This file compiles a representative JSON-RPC design and compares every +// generated package with its checked-in golden contract. package codegen_test import ( diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index 3bdfe020a8..0e168fe31b 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -45,7 +45,7 @@ type PingResponseBody struct { } // AddOverflowResponseBody is the type of the "Calc" service "add" endpoint -// HTTP response body for the "overflow" error. +// HTTP response body. type AddOverflowResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden index 2373a233dc..7a32cda441 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden @@ -45,7 +45,7 @@ type PingResponseBody struct { } // AddOverflowResponseBody is the type of the "Calc" service "add" endpoint -// HTTP response body for the "overflow" error. +// HTTP response body. type AddOverflowResponseBody struct { // Name is the name of this class of errors. Name string `form:"name" json:"name" xml:"name"` From da3907d23759d4fcf713ae7f4cc7ff768b914cf3 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 08:35:40 -0700 Subject: [PATCH 26/43] docs(codegen): define retained generation plans --- codegen/ARCHITECTURE.md | 553 +++++++----- .../2026-08-20-generated-package-ownership.md | 798 ++++++++++++------ 2 files changed, 900 insertions(+), 451 deletions(-) diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 5a23d12ad7..c3a2e46d5a 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -1,217 +1,348 @@ # Code Generation Architecture This document defines how Goa turns one evaluated design into generated files. -It focuses on ownership rules that are easy to violate when a declaration is -written outside its service package. - -## Generation lifecycle - -The `goa` command compiles and runs a temporary generator for one design. The -evaluation package registers exactly one core `*expr.RootExpr`; its evaluation -name is `design`, and duplicate evaluation names are rejected. A generation run -then follows this order: - -1. Evaluate and validate the design. -2. Let preparation plugins amend the evaluated expression roots. -3. Normalize the roots. -4. Create one generation context for the normalized roots. -5. Let every selected core generator and plugin declare the generated service - types it may emit, then freeze their package names. -6. Let the core service, HTTP, gRPC, JSON-RPC, and OpenAPI generators render - files using the frozen declarations. -7. Let post-generation plugins render additional files using the same context. -8. Merge contributions with the same output path and render the files. - -The core service generator therefore reasons about one real design root. A -temporary root created by a plugin is a separate analysis unless the plugin is -explicitly given the active generation context. - -## Ownership - -| Concern | Owner | Consumers | -| --- | --- | --- | -| Design identity and structural equality | `expr` | validation and code generation | -| Go identifiers in a generated package | the generated-package record for its output path | service and transport rendering | -| Relocated user-type and union declarations | the same generated-package record | file rendering | -| Service error values | the endpoint method's effective error declaration | service and transport rendering | -| HTTP and gRPC error response policy | the transport mapping selected by error name | transport validation and wire rendering | -| HTTP, gRPC, and JSON-RPC wire types | each transport generator | transport templates | -| Output-path merging | the generator | all file-producing plugins | - -`expr.Union.Hash()` describes expression identity. It must not change merely -because generated Go source needs a different notion of equality. Code -generation uses a separate typed union identity containing every property that -changes the emitted union declaration, including discriminator keys, branch -order, branch type shape, and relocated branch packages. - -## Generated packages - -The generation context owns a catalog keyed by the actual generated import -path. Each catalog entry represents one Go package and owns: - -- one `codegen.NameScope` for every package-level identifier; -- the relocated user types rendered into that package; -- the structurally distinct unions rendered into that package; and -- the final names of each union type, discriminator type, constants, and - constructors. - -The generation copies its evaluated roots and generated module import path at -construction. Callers can read those values but cannot replace the registered -roots or redirect output packages after planning begins. Planning and rendering -therefore test membership against the same root snapshot. - -The generation also owns one import qualifier for each complete import path. -Imports referenced by static templates have required qualifiers, generated -service and views packages have preferred qualifiers, and design metadata has -lower-priority preferences. Required qualifiers are allocated first and -conflicting requirements are rejected; generated and metadata qualifiers may -receive deterministic suffixes. Each generated file still imports only the -paths used by the declarations and references it renders. - -Each transport plans both the literal imports used by its templates and every -generated client, server, protobuf, and command-line package it will reference -before the catalog freezes. Preferred qualifiers come from the authored service -path, never by appending text to an already allocated qualifier. JSON-RPC -planning includes HTTP planning because it reuses the HTTP type, codec, and -command-line renderers. Example planning also reserves the application, -interceptor, transport-server, and command-line paths before the common freeze. -The service planner does not know about transport packages. Render functions -derive their output import paths from the same generation-backed service -analysis; they do not accept another generated module path that could redirect -files away from their imports. - -Planning a declaration returns its canonical record. Once every selected -generator and plugin has planned its output, the context freezes the catalog. -Rendering may only look up those records; a late attempt to add a declaration -is an error. Code that declares a type and code that refers to it must consume -the same record or the package-aware attribute scope backed by it. A transport -must never recreate a union name from a service-local `NameScope`. - -For example, suppose two services place types in `gen/types`. Both contain a -nested union whose natural Go name is `Value`, but the unions have different -branches. The package catalog may assign `Value` and `Value2`. The user-type -definitions, service methods, HTTP transforms, and gRPC transforms must all read -those exact assignments from the `gen/types` catalog. - -Relocated declared user types keep the Go form of their declared name. If two -declared names in one output package become the same Go identifier, such as -`foo-bar` and `foo_bar` both becoming `FooBar`, generation rejects the design -before rendering. Silently assigning `FooBar2` would make a public declaration -depend on unrelated traversal order. - -Relocated user types are emitted in their metadata-selected files. Relocated -unions are emitted once in `unions.go` in the owning package, independent of -which service first referred to them. - -## Attribute naming during transforms - -`codegen.AttributeContext` asks an `Attributor` for names and references. A -service-local context uses the service package record. A context that transforms -a relocated type uses a package-aware attributor backed by the generation -catalog: - -- a declared user type selects the package named by its `struct:pkg:path`; -- a nested union selects the package of the enclosing generated declaration; -- a local type selects the service package scope. - -This is the only supported route for resolving generated service types inside -HTTP, gRPC, JSON-RPC, conversion, and validation helpers. Transport-specific -scopes still own transport-only wire declarations. Each actual HTTP, -JSON-RPC, or protobuf output package has its own wire declaration catalog. The -catalog first collects the complete detached wire shapes and validation rules, -then freezes deterministic declaration and validator names before templates -request references. Traversal provenance only stops recursion; it never decides -that two emitted declarations are interchangeable. - -HTTP body shaping renames only the endpoint's top-level wrapper. Nested copied -declarations retain their authored `Origin()` until the client or server wire -catalog assigns the name used in that output package. This keeps transport-local -correlation out of the expression graph and lets one authored declaration reuse -one request record while still receiving a distinct response record when pointer, -view, default, or validation policy changes. - -An HTTP union record combines its authored JSON shape with the exact frozen -wire declaration records used by every branch. Equal JSON shapes are therefore -reused only when their generated branch types are also the same. The catalog -plans the union type, discriminator type, branch constants, and constructors -before freezing; transforms and validators consume those records instead of -reconstructing names from authored types. - -gRPC request headers, response headers, and trailers are native wire values: -one primitive or an array of primitives. Analysis recursively removes named -service aliases from a detached copy while preserving validation, defaults, and -requiredness. Metadata parsing and serialization use that local native value; -Goa's normal transformer converts between it and the frozen service field in -the actual client or server package. Objects, maps, and unions are rejected by -DSL validation rather than reaching templates as unsupported cases. - -An explicit protobuf message name is a preferred emitted name, not declaration -identity. Root messages retain the `Origin()` of the service declaration whose -value they carry, even when gRPC shaping builds a separate wire object for an -endpoint role. Two authored declarations with different `Origin()` values -remain separate catalog records even when they request the same protobuf name -and have the same current wire shape. - -Each side of a conversion enters its own attribute independently. A copied HTTP -body remains owned by the HTTP package even if the source service declaration -was relocated, while the service-side value follows the relocated declaration's -package record. The generated file's actual import path determines whether a -reference is local or qualified, and the qualifier comes from the same frozen -full-path import binding used by that file's imports. - -Reusable API- or service-level HTTP and gRPC error mappings are response policy, -not replacement service types. When an endpoint inherits a mapping by error -name, the mapping's error attribute must equal the method's effective error -attribute after References and Bases are materialized, including its named type -shape, validations, defaults, and struct metadata. Validation finalizes a -complete detached copy of each cyclic graph; it never mutates or registers the -evaluated declarations. Union branches compare by position because transforms -pair branches by position. Validation rejects incompatible shadowing before -code generation. -Finalization then binds the mapping to the method error declaration, so service -constructors, transport encoders and decoders, and generated references all use -one concrete error value. For example, an API mapping for a string -`bad_request` may be reused by a method that independently declares the same -string error, but not by a method that declares `bad_request` as an integer or -as the built-in service error object. - -## Plugin and file assembly contracts - -A plugin that can emit generated service types plans them before the catalog is -frozen and renders them with the active generation context. A plugin that -analyzes a temporary root must either plan those types in the active context or -emit to packages isolated from every other participant. Independent analyses -may not coordinate through package names, process-global maps, decorated -strings, or render-order assumptions. - -Standalone or selective generation creates a fresh context containing exactly -the roots, generators, and plugins selected for that output. It runs the same -plan, freeze, and render phases. An API that accepts only roots and reconstructs -its own scope cannot safely contribute to a larger generation. - -`codegen.SectionTemplate.Name` labels a template for diagnostics. It is not a -declaration identity. Output merging appends same-path sections and merges -imports; it must not discard sections merely because their diagnostic labels -match. Package owners remove identical declaration contributions before they -become file sections. Conflicting declarations remain visible and fail with a -generation or Go compilation error instead of disappearing silently. +It is the contract for generator authors: one run prepares the design, builds +one retained plan, freezes every emitted name, and renders that exact plan. + +## Why this contract exists + +The original AURA failure produced a reference to one validation function name +and a declaration with another name. The function and its caller were emitted +into the same generated Go package, but separate analyses allocated their names +from separately initialized scopes. The first ownership work fixed concrete +service, HTTP, JSON-RPC, and gRPC cases by adding a generation-wide package +catalog and transport-local catalogs. It also exposed the remaining design +mistake: planning still records only selected type families, then rendering +rebuilds service and transport data and allocates other package-level names. + +For example, a render-time service scope can still choose names for endpoint +constructors, error constructors, validators, and stream helpers after the +generation has supposedly frozen. A second `NewServicesData` call can rebuild +the same logical wire model with a different traversal context. Both behaviors +let a declaration and its reference disagree. + +The terminal contract is stronger and smaller: every package-level type, +function, constant, and variable has one declaration record owned by the +package that emits it. Every subsystem retains the analysis that created those +records. Rendering reads that analysis; it never reconstructs it. + +## Run lifecycle + +The `goa` command compiles and runs a temporary generator for one evaluated +design. One run follows this order: + +1. Resolve the command and instantiate fresh core generator and plugin objects + from immutable registered factories. +2. Evaluate and validate the design roots. +3. Run preparation. Preparation plugins may add or change expressions, and the + core normalizer may wrap raw method attributes. No later phase may mutate an + expression root. +4. Create one `codegen.Generation` from an immutable snapshot of the prepared + roots and generated module path. +5. Build one typed `generator.Plan`. It creates and retains the core service + plan for each root, then the selected HTTP, gRPC, JSON-RPC, OpenAPI, and + example plans that consume those exact service plans. Plugin planning + receives the same typed plan. +6. Each subsystem completes collection, sorts declarations by stable typed + identity, and declares every package-level symbol in its actual output + package. The generation then freezes package names and import qualifiers. +7. Core generators render their retained subsystem plans. Plugins render using + the same `generator.Plan` and exact core service plans. +8. Merge contributions with the same canonical output path and render files. + +Collection must be complete before freeze. Stable ordering makes preferred-name +suffixes independent of map iteration, traversal order, plugin registration +order, and process history. Freeze turns every declaration record into a +read-only value. Render performs no expression mutation, graph analysis, +declaration discovery, name allocation, or import allocation. + +## Fresh run objects + +Registration stores immutable factories, not mutable generator or plugin +instances. A factory is called once for each generation run: + +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) +} + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) +``` + +These APIs belong to `codegen/generator`, which owns command orchestration. +The `codegen` package owns generated declarations and files; it does not own a +process-global plugin lifecycle. Core generator factories follow the same +fresh-instance rule. + +A factory may close over immutable configuration. Per-run roots, plans, files, +caches, and errors belong to the returned object. Concurrent and repeated +generation runs must not observe one another. The registry itself is immutable +while runs execute; tests install isolated registries rather than replacing a +public global `Generators` function. + +## The retained core plan + +`generator.Plan` is the typed value shared by core generators and plugins. Its +fields are private. It exposes the active `Generation` and the exact service +plan built for a registered root: + +```go +func (p *Plan) Generation() *codegen.Generation +func (p *Plan) Service(root *expr.RootExpr) *service.Plan +``` + +Selected core subsystems are stored in typed fields, not in a generic map. +There is no `PlanKey`, string key, extension registry, or `any`-typed plan bag. +A plugin that needs core service declarations consumes `Plan.Service(root)`; +it may not call service analysis again or rebuild an equivalent plan from the +root. + +Each subsystem has one retained plan constructor. The service contract is: + +```go +func service.NewPlan(root *expr.RootExpr, generation *codegen.Generation) (*service.Plan, error) +``` + +HTTP, gRPC, JSON-RPC, OpenAPI, and example generation use equivalent typed +constructors. A transport plan receives the exact `*service.Plan` for its root. +JSON-RPC may retain and reuse its HTTP plan because it emits HTTP codecs and +wire files, but it does not rebuild HTTP analysis. Render functions accept the +retained subsystem plan, not a `Generation`, generated module path, expression +root, or reconstructed `ServicesData`. + +The plan stores immutable render data and canonical declaration pointers. It +does not store callbacks that repeat analysis. `NewServicesData`, `Genfunc`, +the replaceable `Generators` variable, `renderOnly`, and the callback plugin +registry are transition mechanisms to delete. + +## Generated package ownership + +The generation owns a package catalog keyed by the actual generated Go import +path. Each package record owns: + +- one declaration namespace for every package-level type, function, constant, + and variable; +- one import qualifier for every complete import path referenced by files in + that package; and +- the canonical output directory that corresponds to its import path. + +The common declaration record is `NameDeclaration`. It keeps its preferred and +final spellings private. `Name()` panics before freeze and returns the same +final spelling for the remainder of the run after freeze. Existing type, +union, branch, HTTP wire, protobuf, validator, and helper records contain or +reference `NameDeclaration`; they do not carry another independently mutable +name. + +Package-level declarations include less obvious symbols: union discriminator +constants and constructors, endpoint constructors, error and +result constructors, validation functions, conversion functions, stream +interfaces and helpers, HTTP body constructors, protobuf oneof wrappers, +client and server constructors, and package variables emitted by templates. +Local variables, parameters, struct fields, and method names remain owned by +their lexical render scope because they cannot collide with package-level +declarations. + +### Exact and preferred symbols + +An exact symbol is part of an authored or external contract. Two distinct +exact declarations that normalize to the same Go identifier in one package are +rejected before rendering. Examples include two relocated authored types named +`foo-bar` and `foo_bar`, or two explicit external names that both require +`FooBar`. + +A preferred symbol is generated from a semantic role. It may receive a stable +numeric suffix when another declaration already owns the preferred spelling. +Examples include a generated `ValidatePayload`, `NewValueText`, or protobuf +request message. The declaration's typed identity—not discovery order—decides +which record receives each spelling. + +Exact declarations reserve first. Preferred declarations are sorted by stable +typed identity and allocated second. A subsystem must reject two distinct +identities whose ordering facts are equal; pointer addresses, expression +hashes, map order, and rendered text are not tie-breakers. + +### Imports and output paths + +Complete import path is the only import identity. Static-template requirements +have priority over generated-package preferences, which have priority over +design metadata preferences. References and `ImportSpec` values consume the +same frozen binding, while each file imports only the paths it uses. + +The output planner canonicalizes both generated import paths and filesystem +paths before collection. If two different package identities normalize to the +same import path or output directory, planning rejects them. It does not let +one package win, merge their declarations, or add a suffix to a directory. +Multiple file contributions may share a canonical path only when they declare +the same package identity; the file merger then appends all sections. + +## Expression identity and declaration identity + +Expression identity answers a design question. Declaration identity answers +whether two generated package-level symbols are the same emitted contract. +They are deliberately separate. + +`UserType.Origin()` identifies one authored declaration across exact compiler +copies. Recursion walkers use Origin only to detect a cycle in the current +graph traversal. A cycle set answers “have I entered this declaration on this +path?” It never proves that two emitted wire declarations, validators, or +helpers are interchangeable. + +An emitted declaration identity contains every fact that changes its generated +source: owning package and role, source provenance, wire shape, validation, +defaults, views, pointer policy, ordered union branches, and protocol-specific +metadata as applicable. Equal semantic `ID()` values do not merge distinct +origins. Conversely, the same authored origin may produce distinct request and +response records when their emitted contracts differ. + +`expr.Union.Hash()` remains expression identity. Typed code-generation +identities such as `UnionTypeID` describe emitted union families. Do not change +expression hashes, decorate string keys, or add general expression provenance +to coordinate code generation. + +## Service plan + +`service.Plan` owns every service and views package declaration for one root. +Its constructor collects service declarations, normalized method wrappers, +relocated authored types, projected view types, unions and their complete +families, endpoints, clients, constructors, validators, conversions, +interceptors, errors, stream types, and package variables. It also collects the +imports and exact output files those declarations require. + +The plan retains one package-backed attributor for each service and views +package. HTTP, gRPC, JSON-RPC, example, and plugins use those attributors and +canonical declaration records. No consumer recreates a service `NameScope`, +calls `NewServicesData`, or reconstructs a name from a DSL spelling and package +alias. + +When multiple prepared roots contribute to one generated package, the core +plan collects all their declarations before the package freezes and emits the +package once. A root not present in the Generation snapshot is rejected. + +## HTTP and JSON-RPC plans + +Each actual HTTP client or server output package owns a retained wire plan. It +collects complete detached request, response, WebSocket, SSE, error, union, +constructor, validator, codec, and helper declarations before freeze. The plan +keeps request and response policy in declaration identity, so one authored +origin can reuse a record only when the complete emitted wire contract agrees. + +HTTP transforms enter the service and wire owners independently. Detached HTTP +bodies do not carry service package metadata. JSON-RPC consumes the exact HTTP +plan for the files and codecs it shares, then adds its own package declarations +to typed JSON-RPC plans. It does not create a second HTTP catalog. + +Every validator and helper reference stores its canonical declaration. A call +site's traversal context may select which declaration it needs, but it never +selects or changes that declaration's name. + +Reusable API- and service-level HTTP or gRPC error mappings select response +policy by error name. The endpoint method's effective error declaration owns +the service value that encoders and decoders carry. Planning compares pure, +fully finalized copies of the mapping and method attributes, including emitted +type shape, validations, defaults, struct metadata, and ordered union branches. +It accepts equivalent declarations and binds the mapping to the method record; +it rejects incompatible shadowing before rendering without mutating the +evaluated design. + +## Protobuf and gRPC plans + +The protobuf plan owns a descriptor model for each emitted `.proto` package and +the corresponding Go package produced by the supported `protoc` and +`protoc-gen-go` toolchain. Protobuf source declarations and protoc-generated Go +declarations are different, explicit families. + +A declaration family records every package-level Go symbol Goa refers to, +including messages, nested messages, enums and enum values, oneof interfaces +and wrapper structs, service interfaces, client and server types, and version- +dependent support symbols. Preferred protobuf names do not become identity. +Field numbers, ordered fields and oneof branches, validation, defaults, source +provenance, and endpoint role are identity facts where they change output. + +The protoc Go naming algorithm is selected by an explicit supported toolchain +version. One versioned implementation derives Go names for a descriptor family; +templates and transforms do not carry scattered approximations of protoc +CamelCase or oneof naming. Changing the supported compiler or plugin version +requires a new versioned naming contract and generated-module proof against the +real toolchain. + +gRPC validators and conversions consume frozen descriptor-family records. +Validator identity is independent of the call site that first discovers it, +and conversion contexts cannot allocate a message, wrapper, or validator name. +Explicit metadata remains a detached native primitive wire contract and uses +the canonical service transform after parsing or before serialization. + +## Plugins + +Preparation is the only plugin phase allowed to mutate expression roots. A +plugin that adds a service, method, type, or transport mapping attaches it to a +registered root during preparation. Core normalization then observes it before +planning. + +Plugin planning receives `*generator.Plan`. It may declare plugin-owned output +through the same Generation, and it consumes core declarations through the +exact retained service plan. Plugin rendering receives the same plan after +freeze. It may add files and sections, but it cannot create another root, +re-run service or transport analysis, reserve a name, or change an expression. + +MCP generation therefore attaches its generated service expressions during +prepare and later consumes `Plan.Service(root)`. Agent tool specifications use +one retained typed specification plan for each output package; public specs and +transport specs are distinct packages with distinct declaration owners. No +plugin coordinates through a process-global map, a latest result, a decorated +hash, `PlanKey`, or render order. + +## File assembly + +`SectionTemplate.Name` labels a section for diagnostics. It is not declaration +identity. Package plans remove identical declaration contributions before they +become sections. The file merger combines imports and appends every non-header +section in producer order, even when diagnostic labels match. Conflicting +declarations remain visible and fail during planning or Go compilation instead +of disappearing silently. + +## Compatibility and operations + +This architecture intentionally breaks external generators and plugins that +register callback instances, replace `Generators`, call `NewServicesData`, or +render from roots and generated module paths. They must register factories, +retain typed plans, and render those plans. + +Generated Go names may change where prior suffix ownership depended on +traversal or reconstruction. Goa, goa-ai, and applications must regenerate +together. There is no runtime fallback, persisted-data migration, or staged +dual mode. A normalized output-path collision now fails during planning rather +than overwriting or combining unrelated packages. + +Fresh factories make repeated and concurrent generation independent. The main +operational risks are an uncollected template symbol, an incomplete emitted +identity, or an inaccurate protoc family name. Focused catalog tests, reversed- +order tests, concurrent-run tests, real generated-module compilation, the +supported protoc toolchain, goa-ai generation, and full AURA regeneration are +the required proof. ## Review gate -Before changing type naming, relocated declarations, generation roots, plugin -files, or file merging, trace one declaration through all of these stages: - -1. the single evaluated root; -2. service analysis; -3. planning and catalog freeze; -4. the owning generated-package record; -5. service declaration rendering; -6. HTTP and gRPC references; -7. post-generation plugin contributions; and -8. final files after output-path merging. - -A service-only render test is insufficient. The regression must compile a real -generated module with HTTP, gRPC, and JSON-RPC enabled whenever those transports -can refer to the declaration. Streaming coverage must exercise WebSocket and -SSE files when streaming payloads, results, or selected SSE data fields contain -relocated declarations. +Before changing generation lifecycle, names, roots, transports, plugins, or +file merging, trace one representative declaration through: + +1. the prepared root; +2. the retained service plan; +3. the selected transport or plugin plan; +4. the owning generated package and `NameDeclaration`; +5. stable collection and freeze; +6. every declaration and reference rendered from that record; +7. plugin contributions; and +8. the final merged file and compiled generated module. + +Also prove a valid counterexample at the next wider lifetime: two declarations +with one semantic ID but different origins, one origin with different request +and response wire contracts, two packages with the same basename, repeated +runs in one process, and concurrent runs. A service-only render test or a +source-text assertion is insufficient when the failure can appear in a +transport, protoc-generated family, plugin, or merged output. diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 2c0d2a6194..fe87e3b45a 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -2,404 +2,680 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Make every generated service-type declaration and reference use one name frozen by the generation that produces the output. +**Goal:** Make every generated package-level declaration and every reference use one name collected once, frozen once, and retained through rendering. -**Architecture:** After prepare plugins and root normalization, one `codegen.Generation` runs all selected core generators and plugins through plan, freeze, and render phases. Its generated-package catalog owns package scopes and canonical user-type and union records. Service, HTTP, gRPC, JSON-RPC, standalone callers, and goa-ai plugins resolve names through those records; package owners render each declaration once. +**Architecture:** One generation run instantiates fresh core and plugin objects, permits root mutation only during preparation, and builds one typed `generator.Plan`. Service, HTTP, JSON-RPC, gRPC, OpenAPI, example, and goa-ai plans retain their complete render models and package-owned `NameDeclaration` records; render never rebuilds analysis or allocates a name. -**Tech Stack:** Go 1.25, Goa evaluation and code generation, goa-ai plugins, `testify/require` +**Tech Stack:** Go 1.25, Goa evaluation and code generation, Protocol Buffers and `protoc-gen-go`, goa-ai plugins, `testify/require` **Spec:** `codegen/ARCHITECTURE.md` ## Global Constraints - Never edit generated output; regenerate it from the owning design. -- Keep `expr.Union.Hash()` unchanged and use a typed code-generation identity for emitted unions. -- Reject relocated declared user types whose names become the same Go identifier in one output package. -- Definitions and references must consume the same frozen package-owned declaration record. -- Rendering cannot add declarations; standalone generation runs the same plan, freeze, and render phases. -- Do not use decorated strings, synthetic map keys, process-global registries, fallbacks, or traversal-order heuristics to coordinate ownership. +- Preparation is the last phase allowed to mutate an expression root. +- One run owns one immutable root snapshot, one `codegen.Generation`, and one typed `generator.Plan`. +- Every package-level type, function, constant, and variable has a package-owned `NameDeclaration` before freeze. +- Exact symbols reject normalized collisions; preferred symbols receive deterministic suffixes from stable typed ordering. +- `NameDeclaration.Name()` panics before freeze and is stable after freeze. +- Render accepts retained typed plans. It does not accept roots, a generated module path, or callbacks that reconstruct analysis. +- Complete import path is the only import identity. Different package identities that normalize to one output import path or directory are rejected. +- Recursion uses `UserType.Origin()` only for cycle detection. Emitted declarations use complete typed declaration identities. +- Keep `expr.Union.Hash()` unchanged. +- Protoc-generated Go names come from one explicit, versioned naming contract that covers complete declaration families. +- Plugins consume the exact core service plan. Do not add `PlanKey`, plan registries, generic plan bags, reconstruction, decorated string keys, or process-global run state. - `SectionTemplate.Name` is diagnostic metadata, not declaration identity. -- Every exported construct needs GoDoc; non-trivial files need a concrete header comment. +- Never add fallbacks or compatibility modes. Migrate all in-tree callers and delete replaced APIs. +- Every exported construct needs GoDoc; every non-trivial file needs a concrete purpose and invariant header. --- -### Task 1: Executable failure contracts +## Completed foundation -**Files:** -- Modify: `codegen/generator/service_union_package_scope_test.go` -- Modify: `codegen/generator/generate_merge_test.go` -- Create: `codegen/generated_types_test.go` +The following tasks are complete against their reviewed contracts. Their tests, +typed identities, import-path ownership, and transport correctness remain +required. The retained-plan audit supersedes their transitional callback and +reconstruction APIs; “complete” here records delivered history, not approval to +preserve those APIs. -**Interfaces:** -- Consumes: current full generator, HTTP/gRPC DSL, and same-path file merging -- Produces: a positive transport preservation test plus red tests for relocated declared-name collisions and same-label section preservation +### Task 1: Executable failure contracts — complete -- [x] **Step 1: Add the real generated-module regression** +**Commits:** `0103f6ab`, `dac45fe7` -Use one evaluated design root with two services. Put distinct user types in the -same `struct:pkg:path` package and give each a different nested union whose -natural name is `Value`. Enable HTTP and gRPC, generate the module, and run `go -test ./...` inside it. +- [x] Added a real two-service generated-module test with relocated nested unions and HTTP/gRPC compilation. +- [x] Added exact relocated-name collision coverage. +- [x] Added the still-open same-label file-section preservation regression. -- [x] **Step 2: Prove the nested-union transport case remains valid** +### Task 2: Generation-owned type catalog — complete -Run: +**Commits:** `15f86ce7`, `7353f34b`, `8bda1ae4` -```bash -go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 -``` +- [x] Added one package catalog per generated import path. +- [x] Reserved exact user declarations before deterministic union allocation. +- [x] Froze package scopes and rejected planning through render-only accessors. -Expected on the current branch: PASS. This test preserves the valid two-service -HTTP/gRPC case while the generation-owned catalog removes the independent name -allocation that could make later generators diverge. +### Task 3: Typed union identity and initial lifecycle — complete -- [x] **Step 3: Add the collision and merge regressions** +**Commits:** `4957ddef`, `a120d8ea` -The collision test plans `foo-bar` and `foo_bar` into package `types` and -expects an error naming both inputs and `FooBar`. The merge test contributes two -different sections with the same `SectionTemplate.Name` and expects both -rendered bodies to remain. +- [x] Added `UnionTypeID` without changing expression hash semantics. +- [x] Established prepare, plan, freeze, and render ordering. +- [x] Proved one generation reaches core and plugin callbacks. -- [x] **Step 4: Prove both contracts fail before implementation** +The retained-plan audit replaces `Genfunc`, callback plugin instances, and +render-time reconstruction introduced during this transition. -Run: +### Task 4: Package-owned service declarations — complete -```bash -go test ./codegen ./codegen/generator -run 'TestGeneratedTypesRejectRelocatedNameCollision|TestMergeFilesPreservesSameLabelSections' -count=1 -``` +**Commits:** `81b8bc37`, `4ca1f70c`, `5469ad1e`, `14b1a3c4`, `1bf62f51`, `839791a6` + +- [x] Added typed authored origin, derived method/view identities, complete union families, full-path import aliases, and cross-root package emission. +- [x] Made declaration records immutable and deterministic. +- [x] Bound service and views references to frozen package records. +- [x] Rejected unregistered roots and immutable-generation mutation. + +The retained-plan audit expands this ownership from selected type families to +every service package-level symbol and replaces `Plan` plus +`NewServicesData` re-analysis with one retained `service.Plan`. -Expected: FAIL because the generation-owned type catalog does not exist and the -merger drops the second same-label section. +### Task 5: Transport declaration ownership — complete -### Task 2: Generation-owned type catalog +**Commits:** `595f50ad`, `8dc8ea8e`, `c6a0e1e0`, `393e9102`, `ddfc0472` + +- [x] Routed HTTP, gRPC, and JSON-RPC service references through exact frozen service declarations. +- [x] Added HTTP/JSON-RPC and protobuf wire catalogs, independent transform ownership, native gRPC metadata, generated import planning, and effective inherited-error validation. +- [x] Distinguished cycle identity from transport declaration identity and compiled the integrated generated modules. + +The retained-plan audit keeps these semantics and converts the render-time +catalog construction into retained HTTP, JSON-RPC, protobuf, and gRPC plans. + +--- + +## Remaining implementation + +### Task 6: Common declarations and fresh run lifecycle **Files:** -- Create: `codegen/generation.go` -- Create: `codegen/generated_types.go` +- Modify: `codegen/generation.go` +- Modify: `codegen/generated_types.go` - Modify: `codegen/generated_types_test.go` +- Modify: `codegen/import_aliases.go` +- Modify: `codegen/normalize.go` +- Delete: `codegen/plugin.go` +- Delete: `codegen/plugin_test.go` +- Modify: `codegen/generator/generate.go` +- Replace: `codegen/generator/generators.go` +- Create: `codegen/generator/plan.go` +- Create: `codegen/generator/plugin.go` +- Create: `codegen/generator/plugin_test.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Modify: `codegen/generator/service_union_package_scope_test.go` +- Modify: `codegen/generator/generate_http_union_shape_integration_test.go` +- Modify: `codegen/generator/generate_union_merge_integration_test.go` +- Modify: `codegen/walk.go` +- Modify: `codegen/import.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` **Interfaces:** -- Consumes: `[]eval.Root`, `codegen.NameScope`, and the existing `UnionTypeHash` -- Produces: `Generation`, generated-package records, collision errors, and immutable lookup after freeze +- Produces: package-owned `NameDeclaration` for type/function/constant/variable symbols +- Produces: `generator.Plugin`, `PluginFactory`, fresh core factories, and private-field `generator.Plan` +- Preserves: `Generation`, import-path bindings, `TypeDeclaration`, `UnionDeclaration`, and typed declaration identities -- [x] **Step 1: Extend the catalog contract tests** +- [ ] **Step 1: Add declaration and lifecycle RED tests** -Alongside the Task 1 collision test, cover user-type idempotency, union -idempotency, different same-base unions, lookup before and after freeze, -declaration after freeze rejection, and isolation between standalone -generations. +Add table-driven tests proving one package namespace catches cross-kind +collisions, exact names reject, preferred names suffix in stable typed order, +`Name()` panics before freeze, and every existing type/union record returns its +contained canonical name record. Add canonical output-path tests where two +different package identities normalize to one import path or directory. -- [x] **Step 2: Run the catalog tests and preserve the Task 1 RED evidence** +Add repeated and concurrent generator tests. Register a factory whose plugin +keeps per-run counters, run generation twice and in parallel, and prove each run +starts at zero and receives only its own roots, plan, and files. Attempt root +mutation after preparation and require rejection or a purity failure at the +owning boundary. Run: ```bash -go test ./codegen -run 'TestGeneration|TestGeneratedPackage|TestGeneratedTypes' -count=1 +go test ./codegen ./codegen/generator \ + -run 'TestNameDeclaration|TestGeneratedOutputPath|TestPluginFactory|TestConcurrentGeneration|TestPreparedRoots' \ + -count=1 ``` -- [x] **Step 3: Implement package records and freeze** +Expected: FAIL because names are still type-family-specific, plugins are +registered as callback instances in `codegen`, and `Generators` is mutable +process-global run state. -Use this public contract: +- [ ] **Step 2: Implement the common declaration owner** -```go -type Generation struct { - GenPkg string - Roots []eval.Root -} +Add private preferred/final state and a package-level symbol kind to +`NameDeclaration`. Make exact and preferred declaration APIs return the same +record on idempotent typed identity and reject one identity binding to two +records. Allocate exact records first and preferred records in stable typed +order during `Generation.Freeze`. -func NewGeneration(genpkg string, roots []eval.Root) *Generation -func (g *Generation) GeneratedPackage(path string) *GeneratedPackage -func (g *Generation) Freeze() error +Embed or reference `NameDeclaration` from existing type, union, union branch, +imported toolchain, and later subsystem records. Remove duplicate name fields +as each owner migrates. Canonicalize output paths during collection and reject +different package owners that converge after normalization. -type GeneratedPackage struct{} +- [ ] **Step 3: Move orchestration and plugin registration into generator** -func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) -func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*TypeDeclaration, error) -func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) -func (p *GeneratedPackage) Union(union *expr.Union) (*TypeDeclaration, error) -func (p *GeneratedPackage) Scope() *NameScope +Implement the approved public surface: -type TypeDeclaration struct { - Name string - PackagePath string +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) } + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) + +func (p *Plan) Generation() *codegen.Generation ``` -Declaration methods allocate only before freeze. Lookup methods never allocate. -User types reserve the exact `Goify(Name(), true)` name and report collisions; -unions temporarily use the existing emitted-definition hash until Task 3 gives -that identity a distinct type. `GeneratedPackage.Scope()` is available only -after freeze and returns the already-frozen scope; planning uses declaration -methods instead of direct name reservations. +Store immutable factory descriptors and instantiate fresh plugins and core +generators before each run. Make normalization part of preparation and close +root mutation before constructing `Generation`. Delete `Genfunc`, the public +replaceable `Generators` variable, `renderOnly`, and the callback registry in +`codegen/plugin.go`. Tests install an isolated registry or command factory +through a private test seam, not a mutable production global. + +- [ ] **Step 4: Finish mechanical identity and example cleanup** -- [x] **Step 4: Run the catalog tests green** +Audit every cycle-only walk and key it by `UserType.Origin()`. Keep semantic +`ID()` only where it intentionally seeds example generation, OpenAPI examples, +or a public semantic identifier. Remove render-time example scopes that own +package-level names; leave local argument and field scopes local. Add focused +counterexamples with equal semantic IDs and different origins. + +- [ ] **Step 5: Verify and commit Task 6** Run: ```bash -go test ./codegen -run 'TestGeneration|TestGeneratedPackage|TestGeneratedTypes' -count=1 +go fmt ./... +go test ./codegen ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS. +The same-label merge test may remain the only unfiltered generator failure. +Commit the common owner and fresh-run lifecycle together because retained plans +depend on both contracts. -### Task 3: Typed union identity and generation lifecycle +### Task 7: Retained service plan and complete core symbols **Files:** -- Modify: `codegen/union.go` -- Modify: `codegen/scope.go` -- Modify: `codegen/scope_test.go` -- Modify: `codegen/generated_types.go` -- Modify: `codegen/plugin.go` -- Modify: `codegen/plugin_test.go` -- Modify: `codegen/generator/generators.go` -- Modify: `codegen/generator/generate.go` -- Create: `codegen/generator/generation_test.go` +- Replace: `codegen/service/generated_package.go` +- Replace: `codegen/service/service_data.go` +- Modify: `codegen/service/service.go` +- Modify: `codegen/service/client.go` +- Modify: `codegen/service/endpoint.go` +- Modify: `codegen/service/views.go` +- Modify: `codegen/service/convert.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/service/example_svc.go` +- Modify: `codegen/service/declaration_resolver.go` +- Modify: service headers/import builders that emit package symbols +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/service.go` +- Test: `codegen/service/*_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` **Interfaces:** -- Consumes: Task 2 `Generation` and package records, `expr.Union`, `NameScope.HashedUnique` -- Produces: `UnionTypeID`, generic `Hasher.Hash()` behavior, and plan-aware core generator and plugin APIs +- Consumes: Task 6 `Generation`, `NameDeclaration`, and prepared root snapshot +- Produces: `service.NewPlan(root, generation) (*service.Plan, error)` +- Produces: `generator.Plan.Service(root) *service.Plan` +- Produces: service render functions that accept retained plans only -- [x] **Step 1: Add union identity and lifecycle tests** +- [ ] **Step 1: Inventory and test every service package-level symbol** -Use a custom `Hasher` to prove `HashedUnique` keys only on its exact `Hash()`. -Keep emitted-union distinctions for wire keys, branch order, branch Go shape, -and relocated package. Add generator/plugin tests that record plan, freeze, and -render order and reject a render-time declaration. +Build a table from templates and render data covering service and views types, +method wrappers, union families, endpoint constructors, clients, +errors, validators, conversions, interceptors, stream interfaces and helpers, +view constructors, and package variables. For each family, add a collision +fixture against a type and another generated function or constant. Assert the +declaration and every call site share the same `NameDeclaration` pointer, then +compile the generated service and views packages. -- [x] **Step 2: Introduce the typed emitted-union identity** +Run: -Use this public contract: +```bash +go test ./codegen/service ./codegen/generator \ + -run 'TestServicePlan|TestServicePackageDeclarations|TestRelocatedUnionPackageNamesCompile' \ + -count=1 +``` -```go -type UnionTypeID string +Expected: FAIL where `NewServicesData` and private render scopes still allocate +package-level endpoint, constructor, validator, conversion, or stream names. + +- [ ] **Step 2: Build and retain one service plan per root** -func NewUnionTypeID(union *expr.Union) UnionTypeID +Replace the declaration-only `service.Plan` function and render-time +`NewServicesData` reconstruction with: + +```go +func NewPlan(root *expr.RootExpr, generation *codegen.Generation) (*Plan, error) ``` -Move the emitted-definition algorithm behind `NewUnionTypeID`, update Task 2's -package records to key unions by it, and restore `HashedUnique` to direct -`key.Hash()` behavior. `expr.Union.Hash()` remains unchanged. +The constructor collects the complete immutable render model, all package +imports and output files, and every package-level declaration. It performs +stable ordering before registering preferred names. `generator.Plan` stores +the exact result by root and returns it through `Service(root)`; unknown roots +fail fast. -- [x] **Step 3: Change core and plugin lifecycle APIs** +- [ ] **Step 3: Render only the retained plan** -Use these contracts: +Change service, views, client, endpoint, conversion, validation, interceptor, +and starter implementation renderers to accept `*service.Plan` or typed values +owned by that plan. Remove root, generation, generated module path, and mutable +scope parameters that permit re-analysis or redirected output. Keep lexical +scopes only for locals, parameters, fields, and methods. -```go -type PlanFunc func(*Generation) error -type GenerateFunc func(*Generation, []*File) ([]*File, error) +Delete `NewServicesData`, the old `ServicesData` reconstruction constructor, +duplicate planning traversals, and any record that carries a second final name. -type Genfunc struct { - Plan codegen.PlanFunc - Generate func(*codegen.Generation) ([]*codegen.File, error) -} -``` +- [ ] **Step 4: Prove aggregation, order independence, and purity** -`RegisterPlugin`, `RegisterPluginFirst`, and `RegisterPluginLast` accept -prepare, plan, and generate functions. `Generate` runs prepare, normalization, -every core/plugin plan, `Freeze`, every core render, then every plugin render. -No render callback may declare a new type. +Generate two roots contributing to one relocated package, reverse root and +service traversal, and assert byte-identical declarations. Run planning once, +mutate no expression, render twice, and assert identical files without new +catalog entries. Compile service, views, and example implementation packages. -- [x] **Step 4: Run identity and lifecycle tests** +- [ ] **Step 5: Verify and commit Task 7** Run: ```bash -go test ./codegen ./codegen/generator -run 'TestNameScope|TestUnionType|TestRegisterPlugin|TestGeneratePhases' -count=1 +go fmt ./... +go test ./expr ./dsl ./codegen ./codegen/service ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS. +Only the separately assigned same-label merge regression may fail. -### Task 4: Package-owned service analysis and emission +### Task 8: Retained HTTP and JSON-RPC plans **Files:** -- Create: `codegen/service/generated_package.go` -- Modify: `codegen/service/service_data.go` -- Modify: `codegen/service/service.go` -- Modify: `codegen/service/convert.go` -- Modify: `codegen/service/views.go` -- Modify: `codegen/service/service_test.go` -- Modify: `codegen/service/service_data_union_order_test.go` -- Modify: `codegen/generator/service.go` -- Modify: `codegen/generator/example.go` -- Modify: `codegen/generator/openapi.go` +- Replace: `http/codegen/plan.go` +- Replace: `http/codegen/service_data.go` +- Replace: `http/codegen/wire_catalog.go` +- Modify: `http/codegen/client.go` +- Modify: `http/codegen/server.go` +- Modify: `http/codegen/websocket.go` +- Modify: `http/codegen/sse.go` +- Modify: `http/codegen/sse_client.go` +- Modify: `http/codegen/types.go` +- Modify: `http/codegen/client_cli.go` +- Modify: `http/codegen/example_cli.go` +- Modify: `http/codegen/example_server.go` +- Replace: `jsonrpc/codegen/plan.go` +- Modify: `jsonrpc/codegen/client.go` +- Modify: `jsonrpc/codegen/server.go` +- Modify: `jsonrpc/codegen/websocket_client.go` +- Modify: `jsonrpc/codegen/websocket_server.go` +- Modify: `jsonrpc/codegen/example_server.go` +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/transport.go` +- Test: `http/codegen/plan_test.go` +- Test: `http/codegen/wire_catalog_test.go` +- Test: `http/codegen/service_data_purity_test.go` +- Test: `http/codegen/streaming_test.go` +- Test: `jsonrpc/codegen/plan_test.go` +- Test: `jsonrpc/codegen/kitchen_sink_test.go` +- Test: `jsonrpc/codegen/sse_integration_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` **Interfaces:** -- Consumes: frozen or planning `*codegen.Generation`, `*codegen.TypeDeclaration` -- Produces: `NewServicesData(*expr.RootExpr, *codegen.Generation) (*ServicesData, error)` and root-level package-owned service files +- Consumes: exact retained `*service.Plan` +- Produces: typed retained HTTP and JSON-RPC plans with complete package declarations +- Preserves: independent wire/service transform ownership and detached HTTP bodies -- [x] **Step 1: Add package analysis and emission tests** +- [ ] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** -Test that all services in one root bind to the same declaration records, -identical unions emit once, different same-base unions receive distinct frozen -names, relocated user types emit once at their metadata paths, and each owning -package emits one `unions.go`. +Inventory request, response, WebSocket, SSE, error, union, constructor, +validator, codec, stream, client, server, CLI, and example package symbols. +Create collisions between wire types and validators/constructors, between +request and response policy for one origin, and between HTTP and JSON-RPC +sections sharing an output package. Require stable names under reversed +endpoint order and compile the full generated module. -- [x] **Step 2: Replace local package priming with frozen declarations** +- [ ] **Step 2: Build retained HTTP plans from exact service plans** -Delete `NewServicesDataForRoots`, `packageScopes`, `serviceNameScopes`, -`unionCompanionKey`, and every decorated union key. During planning, -`NewServicesData` declares every relocated user type before unions. During -rendering, it looks up the same records. `UserTypeData` and `UnionTypeData` -retain their `*codegen.TypeDeclaration`; `buildUnionTypeData` allocates the kind -name once from the owning package scope and stores it in the union render data. +Make HTTP `NewPlan` consume the prepared root's HTTP expressions and exact +`*service.Plan`. Collect detached client and server wire models, union families, +validators, helpers, imports, and file membership once. Move every current +`NewServicesData` and `wire_catalog` allocation into this constructor. Store +canonical service and wire declaration records in transform data. -- [x] **Step 3: Make the package owner render all service types** +- [ ] **Step 3: Make JSON-RPC retain the HTTP plan it shares** -Change the public renderer to: +Build one typed JSON-RPC plan that points at the exact HTTP plan used for HTTP +codecs and body files, then collects JSON-RPC-only declarations. Do not invoke +HTTP planning or analysis again. Make JSON-RPC render functions accept this +plan and delete their root/service reconstruction paths. -```go -func Files(genpkg string, services *ServicesData) []*codegen.File -``` +- [ ] **Step 4: Remove context-dependent helper naming** -It renders service-local files, each relocated user type at its configured -file, and one sorted `unions.go` per package. Remove `userTypePkgs`, `~union:`, -and `unionRegistryKey`. `ConvertFiles` uses the owning package scope rather than -a fresh one. +Validators, constructors, conversions, stream helpers, and codecs must read +their `NameDeclaration`; call-site traversal selects a record but cannot name +it. Keep local field and variable scopes. Prove request/response and +WebSocket/SSE transforms enter service and wire owners independently. -- [x] **Step 4: Migrate core service, example, and OpenAPI generators** - -Their plan callback analyzes each design root with the active generation. Their -render callback repeats analysis against frozen records and propagates errors. -The Service renderer calls the root-level `service.Files` once. - -- [x] **Step 5: Run service and generator tests** +- [ ] **Step 5: Verify and commit Task 8** Run: ```bash -go test ./codegen/service ./codegen/generator -count=1 +go fmt ./... +go test ./codegen/service ./http/codegen/... ./jsonrpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS. +Only the same-label merge regression may fail. -### Task 5: Frozen service names in HTTP, gRPC, and JSON-RPC +### Task 9: Versioned protobuf descriptor plan and retained gRPC plan **Files:** -- Modify: `codegen/transformer.go` -- Modify: `http/codegen/service_data.go` -- Modify: `http/codegen/websocket.go` -- Modify: `http/codegen/sse.go` -- Modify: `http/codegen/client.go` -- Modify: `http/codegen/server.go` -- Modify: `grpc/codegen/service_data.go` +- Replace: `grpc/codegen/plan.go` +- Replace: `grpc/codegen/service_data.go` +- Replace: `grpc/codegen/protobuf_catalog.go` +- Modify: `grpc/codegen/protobuf.go` +- Modify: `grpc/codegen/proto.go` +- Modify: `grpc/codegen/proto_hooks.go` - Modify: `grpc/codegen/types.go` +- Modify: `grpc/codegen/client.go` - Modify: `grpc/codegen/server.go` +- Modify: `grpc/codegen/client_cli.go` +- Modify: `grpc/codegen/example_cli.go` +- Modify: `grpc/codegen/example_server.go` +- Create: `grpc/codegen/protoc_names.go` +- Create: `grpc/codegen/protoc_names_test.go` +- Modify: `codegen/generator/plan.go` - Modify: `codegen/generator/transport.go` +- Test: `grpc/codegen/plan_test.go` +- Test: `grpc/codegen/proto_test.go` +- Test: `grpc/codegen/protobuf_test.go` +- Test: `grpc/codegen/protobuf_transform_test.go` +- Test: `grpc/codegen/service_data_traversal_test.go` +- Test: `grpc/codegen/service_metadata_reference_test.go` +- Test: `grpc/codegen/streaming_test.go` - Test: `codegen/generator/service_union_package_scope_test.go` **Interfaces:** -- Consumes: service data bound to frozen `TypeDeclaration` records and package scopes -- Produces: package-aware `Attributor` contexts for every recursive service-type transform +- Consumes: exact retained `*service.Plan` +- Produces: retained protobuf descriptor plans and retained gRPC plans +- Produces: one explicit supported protoc/protoc-gen-go naming version and complete Go declaration families + +- [ ] **Step 1: Capture the real protoc naming contract as RED tests** + +Create descriptor fixtures for acronym and digit names, reserved words, nested +messages, enums, oneofs, services, streams, and explicit preferred names. Run +the supported real `protoc` and `protoc-gen-go` toolchain in a temporary module, +then compare every Goa-predicted package-level symbol with generated Go source. +Include message, enum/value, oneof interface/wrapper, client/server, and support +families. Add a test that rejects an unknown naming-version selector. + +Run: + +```bash +go test ./grpc/codegen -run 'TestProtocNameVersion|TestProtocDeclarationFamilies' -count=1 +``` -- [x] **Step 1: Add focused transport reference assertions** +Expected: FAIL because protoc naming is currently approximated across helpers +and the catalog does not retain complete versioned families. -For the two-service nested-union design, assert that HTTP and gRPC conversion -helpers refer to the exact union names declared in the relocated package. Keep -transport wire-type scopes independent. +- [ ] **Step 2: Build one retained descriptor plan per protobuf package** -- [x] **Step 2: Make attribute contexts carry package ownership** +Represent `.proto` declarations and protoc-generated Go declarations as +separate typed records. Give each family canonical `NameDeclaration` records +for every Go symbol Goa references. Identity includes complete emitted schema, +ordered fields/oneofs, field numbers, validation, defaults, source provenance, +and role where these facts change output; explicit protobuf names remain +preferences. -Add the generated package path or frozen declaration resolver required for an -`Attributor` to select the enclosing service package while recursion enters a -relocated user type. `AttributeContext.Dup` must preserve it, and helper -generation must update it when `struct:pkg:path` changes the enclosing package. +Put the supported toolchain naming algorithm behind one explicit versioned +implementation. Delete scattered protoc CamelCase, oneof-wrapper, and service +name reconstruction after their callers consume family records. -- [x] **Step 3: Replace direct service-scope recomputation** +- [ ] **Step 3: Build and render one retained gRPC plan** -HTTP, WebSocket, SSE, client/server callbacks, gRPC conversions, gRPC -`fullTypeName`, and transport generator setup resolve service types through the -frozen service attributor or existing canonical method declaration. `sd.Scope` -continues to name only HTTP/protobuf wire declarations. +Make gRPC `NewPlan` consume the exact `*service.Plan` and retained protobuf +descriptor plans. Collect messages, validators, conversions, native metadata, +streams, clients, servers, CLI, examples, imports, and output files before +freeze. Render `.proto` and Go files from the same records. -- [x] **Step 4: Run the generated-module regression** +- [ ] **Step 4: Make validators and transforms context-independent** + +Store the exact message, wrapper, validator, and conversion declarations in +render data. A transform context may select source and target records but may +not calculate their names. Add equal semantic ID/different origin cases, +same-origin/different role cases, reversed endpoint order, and one type reused +across unary and streaming roles. Compile and round-trip native metadata. + +- [ ] **Step 5: Verify and commit Task 9** Run: ```bash -go test ./codegen/generator -run TestRelocatedUnionPackageNamesCompile -count=1 +go fmt ./... +go test ./codegen/service ./grpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS with HTTP and gRPC enabled. +Only the same-label merge regression may fail. + +### Task 10: OpenAPI, examples, and selective lifecycle integration + +**Files:** +- Modify: `codegen/generator/plan.go` +- Replace: `codegen/generator/openapi.go` +- Replace: `codegen/generator/example.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` +- Modify: `http/codegen/openapi.go` +- Modify: `http/codegen/openapi/v2/builder.go` +- Modify: `http/codegen/openapi/v2/files.go` +- Modify: `http/codegen/openapi/v2/openapi.go` +- Modify: `http/codegen/openapi/v3/builder.go` +- Modify: `http/codegen/openapi/v3/example.go` +- Modify: `http/codegen/openapi/v3/files.go` +- Modify: `http/codegen/openapi/v3/openapi.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` + +**Interfaces:** +- Consumes: retained service and selected transport plans +- Produces: retained OpenAPI and example plans +- Produces: one core command plan with no render-time root or generation reconstruction + +- [ ] **Step 1: Add selective-command and plan-identity REDs** + +For `gen`, `example`, and focused test commands, assert each selected subsystem +is planned once, each renderer receives the exact retained pointer, unselected +subsystems allocate nothing, and the prepared root remains unchanged after the +plan boundary. Cover OpenAPI-only semantic example IDs separately from Go +declaration identity. -- [x] **Step 5: Run all core codegen tests** +- [ ] **Step 2: Retain OpenAPI and example analysis** + +Build typed OpenAPI plans from prepared expressions and typed example plans +from exact service/transport plans. Preserve OpenAPI semantic example rebasing +where `ID()` intentionally selects deterministic example data. Collect every +example and CLI package-level constructor, variable, and helper through the +owning package catalog. + +- [ ] **Step 3: Make the core plan the only command execution model** + +Have command factories construct one private-field `generator.Plan` containing +the exact selected subsystem plans. Core render dispatch reads those fields; +it does not call `NewPlan`, `NewServicesData`, `Generation.Roots`, or accept a +second generated module path. Remove all remaining generator adapters and +callback-shaped lifecycle tests. + +- [ ] **Step 4: Prove purity, selection, repeated runs, and compilation** + +Run each command twice and concurrently with different roots. Assert byte- +identical output per input, no cross-run state, no late declarations, and no +unselected files. Compile full HTTP/gRPC/JSON-RPC examples and validate both +OpenAPI versions. + +- [ ] **Step 5: Verify and commit Task 10** Run: ```bash -go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/... -count=1 +go fmt ./... +go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/... \ + -skip '^TestMergeFilesPreservesSameLabelSections$' -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS. +The skipped merge regression remains Task 12 work. -### Task 6: Goa-ai plugin participation +### Task 11: Goa-ai retained plans and plugin migration **Files:** - Modify: `/Users/raphael/src/goa-ai/codegen/agent/init.go` - Modify: `/Users/raphael/src/goa-ai/codegen/agent/data.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_toolset_specs.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_agent_files.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_helpers.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_materialize.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_misc.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_type_info.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_types.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_unions.go` - Modify: `/Users/raphael/src/goa-ai/codegen/ir/build.go` - Modify: `/Users/raphael/src/goa-ai/codegen/mcp/init.go` - Modify: `/Users/raphael/src/goa-ai/codegen/mcp/generate.go` - Modify: `/Users/raphael/src/goa-ai/eval/codegen/codegen.go` -- Create: `/Users/raphael/src/goa-ai/codegen/mcp/generate_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/generate_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_internal_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/uniontest/union_names_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/tests/golden_deep_nested_validations_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/contract_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/state_test.go` +- Test: `/Users/raphael/src/goa-ai/eval/codegen/codegen_test.go` **Interfaces:** -- Consumes: Goa's plan-aware plugin API, active `*codegen.Generation`, root-level `service.Files` -- Produces: agent, MCP, and eval plugins that plan generated service types before freeze and render from the same records +- Consumes: generator plugin factories and exact `generator.Plan.Service(root)` +- Produces: retained agent specification, MCP, and eval plans +- Removes: temporary-root rendering, repeated service/spec reconstruction, shared spec scopes, and string union companion keys -- [ ] **Step 1: Add MCP planning tests** +- [ ] **Step 1: Add plugin and specification REDs** -Create an MCP temporary service whose relocated type package overlaps a core -service package. Assert that identical declarations share a record and -incompatible declared names or shapes return an error during planning, before -any core or MCP file renders. +Add a repeated/concurrent run test in one process, an MCP service whose package +overlaps a core service package, and an AURA-shaped large tool-spec package +with colliding validator/constructor preferences. Assert public tool specs and +HTTP transport specs own independent package plans and natural names. Assert +MCP render consumes the exact core service-plan pointer created after prepare. -- [ ] **Step 2: Migrate plugin registration and builders** +- [ ] **Step 2: Attach all generated expressions during prepare** -Add plan callbacks to agent, MCP, and eval registration. Builders that need -service data accept the active generation and propagate `NewServicesData` -errors. MCP plans its temporary root into the active context and calls -root-level `service.Files` during render. Delete its `userTypePkgs` map. +Register fresh agent, MCP, and eval plugin factories. MCP prepare creates and +validates its service/types/JSON-RPC expressions and attaches them to a +canonical registered root before normalization and core planning. Do not create +a render-time temporary root or a second core service plan. -- [ ] **Step 3: Run goa-ai tests against local Goa** +- [ ] **Step 3: Build one retained goa-ai plan per output package** -Run in `/Users/raphael/src/goa-ai` with its existing local Goa replacement: +Build agent IR and tool specification data once during plugin planning. Split +public-spec and transport-spec package owners. Retain typed declaration records +for every generated type, validator, constructor, tool variable, and union +family. Delete repeated `NewServicesData`/IR/spec builders, +`UnionTypeHash`-based companion keys, shared `NameScope` use across packages, +and any emitted-name reconstruction. + +- [ ] **Step 4: Render through exact core and plugin plans** + +Agent, MCP, and eval render callbacks accept `*generator.Plan` and their +factory-owned retained plugin plan. MCP consumes `Plan.Service(root)` and emits +only plugin-owned adapters or modifications; core service/JSON-RPC plans emit +the attached service declarations once. No plugin accesses a plan registry or +looks up a “latest” analysis. + +- [ ] **Step 5: Verify and commit Goa-ai** + +Use a disposable module replacement or the repository's established local Goa +development workflow without committing an unrelated replacement. Run: ```bash +go fmt ./... go test ./codegen/... ./eval/codegen/... -count=1 +go test ./... -run '^$' +git diff --check ``` -Expected: PASS. +Generate and compile the AURA-shaped goa-ai fixture. Record the Goa commit it +requires, commit the goa-ai changes separately, and open or update the goa-ai +pull request. -### Task 7: File assembly, full regeneration, and publication +### Task 12: Lossless merge, full regeneration, review, and publication **Files:** - Modify: `codegen/generator/generate.go` - Modify: `codegen/generator/generate_merge_test.go` +- Regenerate: `/Users/raphael/src/aura/gen` only through AURA generation scripts +- Update: Goa and goa-ai pull request descriptions **Interfaces:** -- Consumes: package-owned emission and same-path file contributions -- Produces: lossless same-path merging, verified Goa/goa-ai/AURA, and updated pull requests +- Consumes: complete retained plans and package-owned declaration deduplication +- Produces: lossless same-path assembly and fully verified Goa, goa-ai, and AURA branches + +- [ ] **Step 1: Make same-path file assembly lossless** + +Merge compatible headers and imports, then append every non-header section in +producer order. Never deduplicate by `SectionTemplate.Name`. Require all +same-path contributors to name the same canonical package identity; package +planning already owns declaration reuse and collision rejection. -- [ ] **Step 1: Make same-path merging lossless** +Run: + +```bash +go test ./codegen/generator -run TestMergeFilesPreservesSameLabelSections -count=1 +``` -Merge header imports and append every non-header section in generator order. -Do not deduplicate by `SectionTemplate.Name`. Package owners already remove -identical type declarations; conflicting output must remain visible as an -explicit generation or Go compilation failure. +Expected before implementation: FAIL because the second same-label body is +discarded. Expected after implementation: PASS with both bodies present. -- [ ] **Step 2: Remove obsolete mechanisms** +- [ ] **Step 2: Delete every superseded mechanism** -Confirm these searches return no production hits: +Require these production searches to return no hits: ```bash -rg -n 'NewServicesDataForRoots|~union:|unionRegistryKey|unionCompanionKey|userTypePkgs|scopedTypeHash' --glob '*.go' -rg -n 'NewNameScope\(\)' codegen/service http/codegen grpc/codegen --glob '*.go' +rg -n 'Genfunc|renderOnly|NewServicesData|NewServicesDataForRoots|PlanKey|UnionTypeHash|unionRegistryKey|unionCompanionKey|userTypePkgs' \ + --glob '*.go' +rg -n 'var Generators|codegen\.RegisterPlugin|RunPluginsPlan|RunPluginsPrepare' \ + --glob '*.go' ``` -Inspect every remaining fresh scope and retain it only for a package whose -declarations it exclusively owns. +Inspect every surviving `NewNameScope` in service, transport, example, and +goa-ai code. Retain it only when it owns lexical local names; no package-level +declaration or import may depend on it. Search every render function for root, +generated module path, and `Generation` inputs and remove remaining analysis +or output redirection. -- [ ] **Step 3: Verify Goa** +- [ ] **Step 3: Verify Goa completely** Run: @@ -407,11 +683,24 @@ Run: go fmt ./... go test ./... -count=1 make lint +git diff --check +``` + +Expected: all pass with no skipped regression. + +- [ ] **Step 4: Verify goa-ai completely** + +Run in `/Users/raphael/src/goa-ai` against the final local Goa commit: + +```bash +go fmt ./... +go test ./... -count=1 +git diff --check ``` -Expected: all commands pass. +Expected: all pass. -- [ ] **Step 4: Regenerate and verify AURA from scratch** +- [ ] **Step 5: Regenerate and verify AURA from scratch** Run in `/Users/raphael/src/aura`: @@ -421,12 +710,41 @@ Run in `/Users/raphael/src/aura`: cd gen && go test ./... -count=1 ``` -Never patch files under `gen/`; each generation command owns deletion and -recreation. - -- [ ] **Step 5: Review and publish** - -Run independent whole-branch reviews in Goa and goa-ai, address every confirmed -finding, and update the Goa PR with the concrete failure, generation ownership -rule, breaking API, generated-source change, rejected ambiguous design, plugin -behavior, and exact verification commands. Push only after all proof passes. +Do not patch anything under `gen/`. Each generation command deletes and +recreates its owned output. Review the generated diff for unexpected public +name changes, then run the relevant AURA service/eval suites identified by +`docs/TROUBLESHOOT.md` and the original production-task reproduction. + +- [ ] **Step 6: Run independent whole-branch reviews** + +Review Goa and goa-ai against `codegen/ARCHITECTURE.md`. Require explicit +checks for every package-level symbol, exact/preferred collision policy, +output-path normalization, retained plan identity, protoc family/version +accuracy, repeated/concurrent runs, plugin root ownership, dead APIs, and +lossless merging. Fix every confirmed finding and repeat full verification. + +- [ ] **Step 7: Publish clear pull requests** + +Update the Goa PR in plain language: describe the invalid AURA validation +function reference, why separate analyses disagreed, the one-plan/one-name +rule, breaking plugin API, protoc proof, generated-source effects, and exact +verification commands. Update or create the goa-ai PR with its prepare-time MCP +attachment and retained spec-plan changes. Address every applicable GitHub +Copilot review comment before merge, push only verified commits, and keep both +PRs draft until dependent verification is green. + +## Final completion proof + +The work is complete only when all of these statements are true: + +- one fresh factory instance owns each core generator and plugin in each run; +- roots never change after preparation; +- one typed core plan retains exact typed subsystem plans; +- every emitted package-level symbol has one frozen `NameDeclaration`; +- every declaration and reference consumes that same record; +- no renderer reconstructs service, wire, protobuf, OpenAPI, example, or plugin analysis; +- protobuf Go names match the explicit supported real toolchain family; +- repeated and concurrent runs are isolated; +- same-path file contributions are lossless; +- Goa, goa-ai, and freshly regenerated AURA all compile and pass their tests; and +- independent review finds no registry, fallback, compatibility path, duplicate owner, or dead transitional API. From cf6e0a548b851f8b60af23b8dde29a9cd4abe6e1 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 09:38:52 -0700 Subject: [PATCH 27/43] refactor(codegen): own names and fresh run lifecycle --- codegen/example/example_client.go | 9 +- codegen/example/example_client_test.go | 4 +- codegen/example/example_server.go | 27 +- codegen/example/example_server_test.go | 2 +- codegen/example/plan.go | 3 +- codegen/generated_types.go | 354 ++++++++++++++---- codegen/generated_types_test.go | 182 ++++++++- codegen/generation.go | 102 ++++- codegen/generator/example.go | 4 +- codegen/generator/generate.go | 109 +----- ...generate_grpc_metadata_integration_test.go | 14 +- ...erate_http_union_shape_integration_test.go | 14 +- codegen/generator/generate_merge_test.go | 270 +++++++------ .../generate_union_merge_integration_test.go | 18 +- codegen/generator/generation_test.go | 76 ++-- codegen/generator/generators.go | 98 +++-- codegen/generator/lifecycle.go | 103 +++++ codegen/generator/plan.go | 19 + codegen/generator/plugin.go | 155 ++++++++ codegen/generator/plugin_test.go | 215 +++++++++++ codegen/generator/purity_test.go | 47 ++- codegen/generator/registry_test.go | 70 ++++ .../service_union_package_scope_test.go | 184 ++++----- codegen/name_declaration.go | 154 ++++++++ codegen/plugin.go | 146 -------- codegen/plugin_test.go | 159 -------- codegen/scope.go | 16 + codegen/service/generated_package.go | 8 +- expr/root.go | 22 +- expr/root_test.go | 53 +++ grpc/codegen/example_cli.go | 2 +- grpc/codegen/example_server.go | 2 +- http/codegen/example_cli.go | 2 +- http/codegen/example_server.go | 15 +- 34 files changed, 1746 insertions(+), 912 deletions(-) create mode 100644 codegen/generator/lifecycle.go create mode 100644 codegen/generator/plan.go create mode 100644 codegen/generator/plugin.go create mode 100644 codegen/generator/plugin_test.go create mode 100644 codegen/generator/registry_test.go create mode 100644 codegen/name_declaration.go delete mode 100644 codegen/plugin.go delete mode 100644 codegen/plugin_test.go diff --git a/codegen/example/example_client.go b/codegen/example/example_client.go index 9750d255b5..43af0a1ad8 100644 --- a/codegen/example/example_client.go +++ b/codegen/example/example_client.go @@ -1,3 +1,6 @@ +// This file renders example CLI entrypoints from the retained server analysis. +// Generated service and transport imports are already frozen by planning; the +// CLI renderer receives no separate generated-module path. package example import ( @@ -11,10 +14,10 @@ import ( // CLIFiles returns example client tool main implementation for each server // expression in the design. -func CLIFiles(genpkg string, root *expr.RootExpr) []*codegen.File { +func CLIFiles(root *expr.RootExpr) []*codegen.File { var fw []*codegen.File for _, svr := range root.API.Servers { - if m := exampleCLIMain(genpkg, root, svr); m != nil { + if m := exampleCLIMain(root, svr); m != nil { fw = append(fw, m) } } @@ -23,7 +26,7 @@ func CLIFiles(genpkg string, root *expr.RootExpr) []*codegen.File { // exampleCLIMain returns an example client tool main implementation for the // given server expression. -func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { +func exampleCLIMain(root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { svrdata := Servers.Get(svr, root) // Skip CLI generation for servers with no transports (e.g., agent-only services) diff --git a/codegen/example/example_client_test.go b/codegen/example/example_client_test.go index f89cc823e0..60feeb5ec7 100644 --- a/codegen/example/example_client_test.go +++ b/codegen/example/example_client_test.go @@ -1,3 +1,5 @@ +// This file verifies that common example CLI entrypoints render without a +// second generated-module path input. package example import ( @@ -27,7 +29,7 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - fs := CLIFiles("", root) + fs := CLIFiles(root) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index 031c9bed4a..7cece88c22 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -5,6 +5,7 @@ package example import ( "os" + "path" "path/filepath" "strings" @@ -15,37 +16,19 @@ import ( // ServerFiles returns an example server main implementation for every server // expression in the service design. -func ServerFiles(genpkg string, root *expr.RootExpr, services *service.ServicesData) []*codegen.File { +func ServerFiles(root *expr.RootExpr, services *service.ServicesData) []*codegen.File { var fw []*codegen.File for _, svr := range root.API.Servers { - if m := exampleSvrMain(genpkg, root, svr, services); m != nil { + if m := exampleSvrMain(root, svr, services); m != nil { fw = append(fw, m) } } return fw } -// APIPkg returns a unique package name for the example API implementation -// package derived from the API name. The name is registered with the given -// scope so subsequent calls return distinct names. -func APIPkg(root *expr.RootExpr, scope *codegen.NameScope) string { - return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") -} - -// RootPath returns the Go import path of the project root computed from the -// generated code package import path genpkg. It returns "." if genpkg has no -// parent path. -func RootPath(genpkg string) string { - // genpkg is created by path.Join so the separator is / regardless of operating system - if idx := strings.LastIndex(genpkg, "/"); idx > 0 { - return genpkg[:idx] - } - return "." -} - // exampleSvrMain returns the default main function for the given server // expression. -func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *service.ServicesData) *codegen.File { +func exampleSvrMain(root *expr.RootExpr, svr *expr.ServerExpr, services *service.ServicesData) *codegen.File { svrdata := Servers.Get(svr, root) mainPath := filepath.Join("cmd", svrdata.Dir, "main.go") if _, err := os.Stat(mainPath); !os.IsNotExist(err) { @@ -77,7 +60,7 @@ func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, se specs = append(specs, serviceImport) hasInterceptors = hasInterceptors || len(sd.ServerInterceptors) > 0 } - rootPath := RootPath(genpkg) + rootPath := path.Dir(services.GenPkg()) apiImport := services.PackageImport(rootPath) apiPkg := apiImport.Name specs = append(specs, apiImport) diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index f20ae9bca8..83c4d55035 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -70,7 +70,7 @@ func TestExampleServerFiles(t *testing.T) { require.NoError(t, generation.Freeze()) services, err := service.NewServicesData(root, generation) require.NoError(t, err) - fs := ServerFiles(generation.GenPkg(), root, services) + fs := ServerFiles(root, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/codegen/example/plan.go b/codegen/example/plan.go index 15aa681a41..3f79bf0772 100644 --- a/codegen/example/plan.go +++ b/codegen/example/plan.go @@ -3,6 +3,7 @@ package example import ( + "path" "strings" "goa.design/goa/v3/codegen" @@ -12,7 +13,7 @@ import ( // Plan reserves the application and interceptor package aliases consumed by // example server and client files before the generation catalog freezes. func Plan(generation *codegen.Generation) error { - rootPath := RootPath(generation.GenPkg()) + rootPath := path.Dir(generation.GenPkg()) for _, root := range generation.Roots() { design, ok := root.(*expr.RootExpr) if !ok { diff --git a/codegen/generated_types.go b/codegen/generated_types.go index f716c8fd7c..2bcdff0ff2 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -16,7 +16,11 @@ type ( // one generated Go package. GeneratedPackage struct { path string + outputDir string scope *NameScope + names []*NameDeclaration + nameRecords map[*NameDeclaration]struct{} + exactNames map[string]*NameDeclaration userTypes map[expr.UserType]*TypeDeclaration typeBindings map[expr.UserType]*TypeDeclaration derivedTypes map[DerivedTypeID]*derivedTypeDeclaration @@ -45,25 +49,23 @@ type ( // TypeDeclaration records the canonical name and package path of one // generated type declaration. TypeDeclaration struct { - name string - packagePath string + declaration *NameDeclaration } // UnionDeclaration records the canonical union and discriminator names in // the package that emits them. UnionDeclaration struct { - name string - kindName string - packagePath string + declaration *NameDeclaration + kindDeclaration *NameDeclaration } // UnionBranchDeclaration records the package-level declarations emitted for // one union branch. UnionBranchDeclaration struct { - kindConst string - constructor string - branchType *TypeDeclaration - typeName string + kindDeclaration *NameDeclaration + constructorDeclaration *NameDeclaration + branchType *TypeDeclaration + typeName string } // unionDeclaration retains the expression needed to allocate the public @@ -99,6 +101,16 @@ type ( sourceName string sourceID string } + + // unionNameOrder identifies one declaration in a generated union family. + unionNameOrder struct { + union UnionTypeID + role unionNameRole + branch string + } + + // unionNameRole orders the closed package-level symbols emitted for unions. + unionNameRole uint8 ) const ( @@ -110,6 +122,14 @@ const ( methodStreamingResultTypeKind ) +const ( + unionTypeNameRole unionNameRole = iota + 1 + unionKindNameRole + unionBranchTypeNameRole + unionBranchKindNameRole + unionBranchConstructorNameRole +) + // NewProjectedTypeID returns the generated declaration identity for the // pointer-backed projection of source emitted in a service views package. func NewProjectedTypeID(source expr.UserType) DerivedTypeID { @@ -160,42 +180,67 @@ func (i MethodTypeIdentity) Matches(userType expr.UserType) bool { return userType.ID() == i.UID() } -// Name returns the unqualified Go declaration name. It is empty until the +// Name returns the unqualified Go declaration name. It panics until the // generation freezes declarations whose names depend on package collisions. func (d *TypeDeclaration) Name() string { - return d.name + return d.declaration.Name() } // PackagePath returns the import path of the package that owns the declaration. func (d *TypeDeclaration) PackagePath() string { - return d.packagePath + return d.declaration.PackagePath() } -// Name returns the unqualified Go union declaration name. It is empty until -// the generation freezes the owning package. +// Declaration returns the canonical package-owned name record. +func (d *TypeDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// Name returns the unqualified Go union declaration name. It panics until the +// generation freezes the owning package. func (d *UnionDeclaration) Name() string { - return d.name + return d.declaration.Name() } -// KindName returns the unqualified Go discriminator type name. It is empty -// until the generation freezes the owning package. +// KindName returns the unqualified Go discriminator type name. It panics until +// the generation freezes the owning package. func (d *UnionDeclaration) KindName() string { - return d.kindName + return d.kindDeclaration.Name() } // PackagePath returns the import path of the package that owns the union. func (d *UnionDeclaration) PackagePath() string { - return d.packagePath + return d.declaration.PackagePath() +} + +// Declaration returns the canonical package-owned union type name. +func (d *UnionDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// KindDeclaration returns the canonical package-owned discriminator type name. +func (d *UnionDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration } // KindConst returns the unqualified discriminator constant for the branch. func (d *UnionBranchDeclaration) KindConst() string { - return d.kindConst + return d.kindDeclaration.Name() } // Constructor returns the unqualified constructor function for the branch. func (d *UnionBranchDeclaration) Constructor() string { - return d.constructor + return d.constructorDeclaration.Name() +} + +// KindDeclaration returns the canonical discriminator constant name. +func (d *UnionBranchDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration +} + +// ConstructorDeclaration returns the canonical branch constructor name. +func (d *UnionBranchDeclaration) ConstructorDeclaration() *NameDeclaration { + return d.constructorDeclaration } // Type returns the generated branch alias declaration and whether the branch @@ -207,7 +252,56 @@ func (d *UnionBranchDeclaration) Type() (*TypeDeclaration, bool) { // Ref returns the Go reference spelling for declaration's data type, including // Goa's pointer/value semantics for named objects, unions, and aliases. func (d *TypeDeclaration) Ref(dataType expr.DataType) string { - return goTypeRef(d.name, dataType) + return goTypeRef(d.Name(), dataType) +} + +// DeclareName registers one canonical package-level declaration. Registering +// the same record again is idempotent; another owner or ambiguous order fails. +func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + if declaration.packagePath != "" { + if declaration.packagePath == p.path { + return nil + } + return fmt.Errorf( + "package name %q already belongs to generated package %q", + declaration.preferredName(), + declaration.packagePath, + ) + } + if declaration.exact { + if existing, ok := p.exactNames[declaration.preferred]; ok { + return fmt.Errorf( + "generated package %q cannot declare exact %s %q: already declared by exact %s", + p.path, + declaration.kind, + declaration.preferred, + existing.kind, + ) + } + p.exactNames[declaration.preferred] = declaration + } else { + for existing := range p.nameRecords { + if existing.exact || existing.base != nil || declaration.base != nil { + continue + } + if existing.order.PackageNameFamily() == declaration.order.PackageNameFamily() && + existing.order.ComparePackageName(declaration.order) == 0 { + return fmt.Errorf( + "generated package %q cannot deterministically order preferred %s %q", + p.path, + declaration.kind, + declaration.preferredName(), + ) + } + } + } + declaration.packagePath = p.path + p.names = append(p.names, declaration) + p.nameRecords[declaration] = struct{}{} + return nil } // DeclareUserType reserves userType's exact exported Go name and returns its @@ -231,19 +325,15 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara declaredName, ) } - if p.scope.PeekUnique(name) != name { - return nil, fmt.Errorf( - "generated package %q cannot declare user type %q as %q: name is already reserved", - p.path, - userType.Name(), - name, - ) + nameDeclaration := NewExactName(NameType, name) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, fmt.Errorf("declare user type %q: %w", userType.Name(), err) } - declaration := &TypeDeclaration{name: name, packagePath: p.path} + declaration := &TypeDeclaration{declaration: nameDeclaration} if err := p.bindType(origin, declaration); err != nil { return nil, err } - p.scope.HashedUnique(userType, name, "") + p.bindName(nameDeclaration, userType) p.userTypes[origin] = declaration p.userTypeNames[name] = userType.Name() return declaration, nil @@ -277,7 +367,11 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin identity.origin.Name(), ) } - declaration := &TypeDeclaration{packagePath: p.path} + nameDeclaration := NewPreferredName(NameType, name, order) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} if identity.kind.isMethodType() { if err := p.bindType(identity.origin, declaration); err != nil { return nil, err @@ -308,8 +402,8 @@ func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source } // DeclareUnion records union's emitted definition and returns the same -// declaration for unions with the same emitted identity. The declaration name -// remains empty until the owning generation freezes its package catalogs. +// declaration for unions with the same emitted identity. Reading its name +// panics until the owning generation freezes its package catalogs. func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) @@ -319,14 +413,55 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, e return planned.declaration, nil } - declaration := &UnionDeclaration{packagePath: p.path} + nameDeclaration := NewPreferredName(NameType, union.Name(), unionNameOrder{ + union: identity, + role: unionTypeNameRole, + }) + kindDeclaration := newDependentName(NameType, nameDeclaration, "", "Kind", unionNameOrder{ + union: identity, + role: unionKindNameRole, + }) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + if err := p.DeclareName(kindDeclaration); err != nil { + return nil, err + } + declaration := &UnionDeclaration{ + declaration: nameDeclaration, + kindDeclaration: kindDeclaration, + } + p.bindName(nameDeclaration, identity) branches := make(map[unionBranchID]*UnionBranchDeclaration, len(union.Values)) for _, branch := range union.Values { - identity := unionBranchID{name: branch.Name} - if _, ok := branches[identity]; ok { + branchIdentity := unionBranchID{name: branch.Name} + if _, ok := branches[branchIdentity]; ok { return nil, fmt.Errorf("union %q declares branch %q more than once", union.Name(), branch.Name) } - branches[identity] = &UnionBranchDeclaration{} + kindDeclaration := newDependentName( + NameConstant, + declaration.kindDeclaration, + "", + Goify(branch.Name, true), + unionNameOrder{union: identity, role: unionBranchKindNameRole, branch: branch.Name}, + ) + constructorDeclaration := newDependentName( + NameFunction, + declaration.declaration, + "New", + Goify(branch.Name, true), + unionNameOrder{union: identity, role: unionBranchConstructorNameRole, branch: branch.Name}, + ) + if err := p.DeclareName(kindDeclaration); err != nil { + return nil, err + } + if err := p.DeclareName(constructorDeclaration); err != nil { + return nil, err + } + branches[branchIdentity] = &UnionBranchDeclaration{ + kindDeclaration: kindDeclaration, + constructorDeclaration: constructorDeclaration, + } } p.unions[identity] = &unionDeclaration{ union: union, @@ -372,13 +507,22 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName } return branch.branchType, nil } - declaration := &TypeDeclaration{packagePath: p.path} + typeName := Goify(userType.Name(), true) + nameDeclaration := NewPreferredName(NameType, typeName, unionNameOrder{ + union: NewUnionTypeID(union), + role: unionBranchTypeNameRole, + branch: branchName, + }) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} origin := userType.Origin() if err := p.bindType(origin, declaration); err != nil { return nil, err } branch.branchType = declaration - branch.typeName = Goify(userType.Name(), true) + branch.typeName = typeName return declaration, nil } @@ -457,11 +601,41 @@ func (p *GeneratedPackage) Scope() *NameScope { return p.scope } +// PackageNameFamily groups derived service declarations for typed comparison. +func (o derivedTypeOrder) PackageNameFamily() string { + return "service-derived-type" +} + +// ComparePackageName orders two derived service declaration identities. +func (o derivedTypeOrder) ComparePackageName(other PackageNameOrder) int { + return compareDerivedTypeOrder(o, other.(derivedTypeOrder)) +} + +// PackageNameFamily groups complete union families for typed comparison. +func (o unionNameOrder) PackageNameFamily() string { + return "union" +} + +// ComparePackageName orders union declarations by emitted identity and role. +func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { + right := other.(unionNameOrder) + if compared := strings.Compare(string(o.union), string(right.union)); compared != 0 { + return compared + } + if o.role != right.role { + return int(o.role) - int(right.role) + } + return strings.Compare(o.branch, right.branch) +} + // newGeneratedPackage creates an empty mutable declaration catalog for path. -func newGeneratedPackage(path string) *GeneratedPackage { +func newGeneratedPackage(path, outputDir string) *GeneratedPackage { return &GeneratedPackage{ path: path, + outputDir: outputDir, scope: NewNameScope(), + nameRecords: make(map[*NameDeclaration]struct{}), + exactNames: make(map[string]*NameDeclaration), userTypes: make(map[expr.UserType]*TypeDeclaration), typeBindings: make(map[expr.UserType]*TypeDeclaration), derivedTypes: make(map[DerivedTypeID]*derivedTypeDeclaration), @@ -471,50 +645,76 @@ func newGeneratedPackage(path string) *GeneratedPackage { } } -// freeze assigns derived declarations in stable source order and union -// families in structural-identity order, then ends declaration and scope -// mutation while preserving read-only lookups. -func (p *GeneratedPackage) freeze() { - derived := make([]*derivedTypeDeclaration, 0, len(p.derivedTypes)) - for _, planned := range p.derivedTypes { - derived = append(derived, planned) +// freeze allocates exact names first, then independent preferred names in +// stable typed order, followed by names derived from an already frozen base. +func (p *GeneratedPackage) freeze() error { + exact := make([]*NameDeclaration, 0, len(p.names)) + preferred := make([]*NameDeclaration, 0, len(p.names)) + dependent := make([]*NameDeclaration, 0, len(p.names)) + for _, declaration := range p.names { + switch { + case declaration.exact: + exact = append(exact, declaration) + case declaration.base == nil: + preferred = append(preferred, declaration) + default: + dependent = append(dependent, declaration) + } } - slices.SortFunc(derived, func(a, b *derivedTypeDeclaration) int { - return compareDerivedTypeOrder(a.order, b.order) + slices.SortFunc(exact, func(left, right *NameDeclaration) int { + return strings.Compare(left.preferred, right.preferred) }) - for _, planned := range derived { - planned.declaration.name = p.scope.Unique(planned.name) - } - - identities := make([]UnionTypeID, 0, len(p.unions)) - for identity := range p.unions { - identities = append(identities, identity) - } - slices.Sort(identities) - for _, identity := range identities { - planned := p.unions[identity] - name := p.scope.HashedUnique(identity, Goify(planned.union.Name(), true), "") - planned.declaration.name = name - planned.declaration.kindName = p.scope.Unique(name + "Kind") - - branches := make([]unionBranchID, 0, len(planned.branches)) - for branch := range planned.branches { - branches = append(branches, branch) + for _, declaration := range exact { + declaration.final = p.scope.Unique(declaration.preferred) + if declaration.final != declaration.preferred { + return fmt.Errorf( + "generated package %q cannot preserve exact %s name %q", + p.path, + declaration.kind, + declaration.preferred, + ) } - slices.SortFunc(branches, func(a, b unionBranchID) int { - return strings.Compare(a.name, b.name) - }) - for _, identity := range branches { - branch := planned.branches[identity] - if branch.branchType != nil { - branch.branchType.name = p.scope.Unique(branch.typeName) + declaration.frozen = true + } + slices.SortFunc(preferred, comparePackageNames) + for _, declaration := range preferred { + declaration.final = p.scope.Unique(declaration.preferred) + declaration.frozen = true + } + for len(dependent) > 0 { + ready := dependent[:0] + waiting := make([]*NameDeclaration, 0, len(dependent)) + for _, declaration := range dependent { + if declaration.base.frozen { + ready = append(ready, declaration) + } else { + waiting = append(waiting, declaration) } - branch.kindConst = p.scope.Unique(planned.declaration.kindName + Goify(identity.name, true)) - branch.constructor = p.scope.Unique("New" + planned.declaration.name + Goify(identity.name, true)) + } + if len(ready) == 0 { + return fmt.Errorf("generated package %q contains a package-name dependency cycle", p.path) + } + slices.SortFunc(ready, comparePackageNames) + for _, declaration := range ready { + declaration.final = p.scope.Unique(declaration.preferredName()) + declaration.frozen = true + } + dependent = waiting + } + for declaration := range p.nameRecords { + for _, hash := range declaration.hashes { + p.scope.bind(hash, declaration.final) } } p.scope.Freeze() p.frozen = true + return nil +} + +// bindName associates a type identity with a canonical declaration after all +// package names have been allocated. +func (p *GeneratedPackage) bindName(declaration *NameDeclaration, hash Hasher) { + declaration.hashes = append(declaration.hashes, hash) } // bindType gives one exact expression origin one canonical package diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 7d4a7249f5..ad52c22549 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -4,6 +4,7 @@ package codegen import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/require" @@ -13,6 +14,165 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // testNameOrder supplies stable typed ordering facts to package-name tests. + testNameOrder struct { + family string + value string + } +) + +// PackageNameFamily returns the declaration family used for cross-family ordering. +func (o testNameOrder) PackageNameFamily() string { + return o.family +} + +// ComparePackageName orders declarations from the same test family. +func (o testNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(o.value, other.(testNameOrder).value) +} + +// TestNameDeclarationOwnsOnePackageNamespace verifies that exact and preferred +// package symbols of every kind share one collision domain and one frozen name. +func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + types := generation.GeneratedPackage("generated.local/gen/types") + exact := NewExactName(NameType, "Build") + preferred := NewPreferredName(NameFunction, "Build", testNameOrder{family: "helper", value: "build"}) + + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(preferred)) + require.Equal(t, "Build", exact.PreferredName()) + require.Equal(t, NameType, exact.Kind()) + require.Panics(t, func() { exact.Name() }) + require.Panics(t, func() { preferred.Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Build", exact.Name()) + require.Equal(t, "Build2", preferred.Name()) + require.Equal(t, "generated.local/gen/types", exact.PackagePath()) + require.Equal(t, "Build", exact.Name()) + + for _, kind := range []PackageNameKind{NameType, NameFunction, NameConstant, NameVariable} { + collisionGeneration := NewGeneration("generated.local/gen", nil) + pkg := collisionGeneration.GeneratedPackage("generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Shared"))) + err := pkg.DeclareName(NewExactName(kind, "Shared")) + require.ErrorContains(t, err, "Shared") + } +} + +// TestNameDeclarationPreferredOrder verifies that typed stable identity, not +// discovery order, decides suffix ownership and rejects an indistinguishable tie. +func TestNameDeclarationPreferredOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := NewGeneration("generated.local/gen", nil) + pkg := generation.GeneratedPackage("generated.local/gen/types") + first := NewPreferredName(NameFunction, "Build", testNameOrder{family: "helper", value: "a"}) + second := NewPreferredName(NameConstant, "Build", testNameOrder{family: "helper", value: "b"}) + declarations := []*NameDeclaration{first, second} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Build", first) + require.Equal(t, "Build2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) + + generation := NewGeneration("generated.local/gen", nil) + pkg := generation.GeneratedPackage("generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewPreferredName( + NameFunction, + "Build", + testNameOrder{family: "helper", value: "same"}, + ))) + err := pkg.DeclareName(NewPreferredName( + NameVariable, + "Build", + testNameOrder{family: "helper", value: "same"}, + )) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestNameDeclarationRejectsMultipleOwners verifies that one canonical name +// record cannot be rebound to another generated package. +func TestNameDeclarationRejectsMultipleOwners(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + require.NoError(t, generation.GeneratedPackage("generated.local/gen/first").DeclareName(declaration)) + err := generation.GeneratedPackage("generated.local/gen/second").DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestGeneratedOutputPathRejectsNormalizedCollisions verifies that equivalent +// import spellings cannot make two requested package identities share output. +func TestGeneratedOutputPathRejectsNormalizedCollisions(t *testing.T) { + generation := NewGeneration("generated.local/root/../gen", nil) + first := generation.GeneratedPackage("generated.local/gen/types") + second := generation.GeneratedPackage("generated.local/gen/values/../types") + require.Same(t, first, second) + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.Equal(t, "generated.local/gen/types", first.ImportPath()) + require.Equal(t, "gen/types", first.OutputDirectory()) + err := generation.Freeze() + require.ErrorContains(t, err, "normalize") + require.ErrorContains(t, err, "generated.local/gen/types") +} + +// TestGeneratedOutputPathRejectsLateNormalizedIdentity verifies that freeze +// does not let a new raw package identity reach an existing canonical package. +func TestGeneratedOutputPathRejectsLateNormalizedIdentity(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + first := generation.GeneratedPackage("generated.local/gen/types") + require.NoError(t, generation.Freeze()) + require.Same(t, first, generation.GeneratedPackage("generated.local/gen/types")) + require.Panics(t, func() { + generation.GeneratedPackage("generated.local/gen/values/../types") + }) +} + +// TestGeneratedTypeFamiliesContainCanonicalNames verifies that existing type +// and union records expose the package-owned records used for rendering. +func TestGeneratedTypeFamiliesContainCanonicalNames(t *testing.T) { + generation := NewGeneration("generated.local/gen", nil) + pkg := generation.GeneratedPackage("generated.local/gen/types") + user, err := pkg.DeclareUserType(generatedUserType("Widget", "widget")) + require.NoError(t, err) + union, alias := generatedUnionWithBranch("Value", "text", "text", expr.String) + unionDeclaration, err := pkg.DeclareUnion(union) + require.NoError(t, err) + branchType, err := pkg.DeclareUnionBranchType(union, "text", alias) + require.NoError(t, err) + branch, err := pkg.UnionBranch(union, "text") + require.NoError(t, err) + + require.Same(t, user.Declaration(), user.Declaration()) + require.Same(t, unionDeclaration.Declaration(), unionDeclaration.Declaration()) + require.Same(t, unionDeclaration.KindDeclaration(), unionDeclaration.KindDeclaration()) + require.Same(t, branchType.Declaration(), branchType.Declaration()) + require.Same(t, branch.KindDeclaration(), branch.KindDeclaration()) + require.Same(t, branch.ConstructorDeclaration(), branch.ConstructorDeclaration()) + require.Panics(t, func() { user.Declaration().Name() }) + require.Panics(t, func() { unionDeclaration.Declaration().Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, user.Name(), user.Declaration().Name()) + require.Equal(t, unionDeclaration.Name(), unionDeclaration.Declaration().Name()) + require.Equal(t, unionDeclaration.KindName(), unionDeclaration.KindDeclaration().Name()) + require.Equal(t, branch.KindConst(), branch.KindDeclaration().Name()) + require.Equal(t, branch.Constructor(), branch.ConstructorDeclaration().Name()) +} + // TestGeneratedTypesRejectRelocatedNameCollision verifies that one generated // package rejects distinct DSL names that produce the same exported Go name. func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { @@ -94,7 +254,7 @@ func TestGeneratedPackageUserTypes(t *testing.T) { first, err := types.DeclareUserType(widget) require.NoError(t, err) - require.Equal(t, "Widget", first.Name()) + require.Panics(t, func() { first.Name() }) require.Equal(t, "generated.local/gen/types", first.PackagePath()) second, err := types.DeclareUserType(widget) require.NoError(t, err) @@ -105,8 +265,10 @@ func TestGeneratedPackageUserTypes(t *testing.T) { require.Same(t, first, lookedUp) declaredMissing, err := types.DeclareUserType(missing) require.NoError(t, err) - require.Equal(t, "Missing", declaredMissing.Name()) + require.Panics(t, func() { declaredMissing.Name() }) require.NoError(t, generation.Freeze()) + require.Equal(t, "Widget", first.Name()) + require.Equal(t, "Missing", declaredMissing.Name()) require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) } @@ -236,7 +398,7 @@ func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) require.NoError(t, err) require.Same(t, firstDeclaration, secondDeclaration) - require.Empty(t, firstDeclaration.Name()) + require.Panics(t, func() { firstDeclaration.Name() }) require.NoError(t, generation.Freeze()) require.Equal(t, "ValueText", firstDeclaration.Name()) @@ -309,7 +471,7 @@ func TestGeneratedPackageUnions(t *testing.T) { firstDeclaration, err := types.DeclareUnion(first) require.NoError(t, err) - require.Empty(t, firstDeclaration.Name()) + require.Panics(t, func() { firstDeclaration.Name() }) require.Equal(t, "generated.local/gen/types", firstDeclaration.PackagePath()) equivalentDeclaration, err := types.DeclareUnion(equivalent) require.NoError(t, err) @@ -317,7 +479,7 @@ func TestGeneratedPackageUnions(t *testing.T) { differentDeclaration, err := types.DeclareUnion(different) require.NoError(t, err) - require.Empty(t, differentDeclaration.Name()) + require.Panics(t, func() { differentDeclaration.Name() }) require.NotSame(t, firstDeclaration, differentDeclaration) lookedUp, err := types.Union(equivalent) @@ -383,10 +545,10 @@ func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { require.NoError(t, err) } - require.Equal(t, "Value", userDeclaration.Name()) - require.Equal(t, "ValueKind", kindDeclaration.Name()) - require.Empty(t, unionDeclaration.Name()) - require.Empty(t, unionDeclaration.KindName()) + require.Panics(t, func() { userDeclaration.Name() }) + require.Panics(t, func() { kindDeclaration.Name() }) + require.Panics(t, func() { unionDeclaration.Name() }) + require.Panics(t, func() { unionDeclaration.KindName() }) require.NoError(t, generation.Freeze()) require.Equal(t, "Value", userDeclaration.Name()) require.Equal(t, "ValueKind", kindDeclaration.Name()) @@ -410,7 +572,7 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { require.NoError(t, err) unionDeclaration, err := types.DeclareUnion(union) require.NoError(t, err) - require.Empty(t, unionDeclaration.Name()) + require.Panics(t, func() { unionDeclaration.Name() }) require.NoError(t, generation.Freeze()) lookedUpUser, err := types.UserType(widget) diff --git a/codegen/generation.go b/codegen/generation.go index c580754a69..acc394cc50 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -5,6 +5,9 @@ package codegen import ( "fmt" + "path" + "path/filepath" + "strings" "goa.design/goa/v3/eval" ) @@ -18,12 +21,15 @@ type ( packages map[string]*GeneratedPackage importPlan *importAliasPlan imports map[string]importAliasBinding + pathInputs map[string]string + pathErr error frozen bool } ) // NewGeneration creates an independent generation catalog for roots. func NewGeneration(genpkg string, roots []eval.Root) *Generation { + genpkg = path.Clean(genpkg) return &Generation{ genpkg: genpkg, roots: append([]eval.Root(nil), roots...), @@ -31,6 +37,7 @@ func NewGeneration(genpkg string, roots []eval.Root) *Generation { importPlan: &importAliasPlan{ candidates: make(map[string]*importAliasCandidate), }, + pathInputs: make(map[string]string), } } @@ -47,14 +54,36 @@ func (g *Generation) Roots() []eval.Root { // GeneratedPackage returns the naming catalog for path, creating it before // the generation is frozen. It panics if path was not planned before freeze. func (g *Generation) GeneratedPackage(path string) *GeneratedPackage { - if generatedPackage, ok := g.packages[path]; ok { - return generatedPackage + rawPath := path + path = cleanImportPath(path) + if existing, ok := g.pathInputs[path]; ok { + if existing == rawPath { + return g.packages[path] + } + err := fmt.Errorf( + "generated package paths %q and %q normalize to %q", + existing, + rawPath, + path, + ) + if g.frozen { + panic(err) + } + if g.pathErr == nil { + g.pathErr = err + } + return g.packages[path] } if g.frozen { panic(fmt.Sprintf("generated package %q requested after generation freeze", path)) } - generatedPackage := newGeneratedPackage(path) + outputDir, err := generatedOutputDirectory(g.genpkg, path) + if err != nil && g.pathErr == nil { + g.pathErr = err + } + generatedPackage := newGeneratedPackage(path, outputDir) g.packages[path] = generatedPackage + g.pathInputs[path] = rawPath return generatedPackage } @@ -65,12 +94,77 @@ func (g *Generation) Freeze() error { if g.frozen { return nil } + if g.pathErr != nil { + return g.pathErr + } if err := g.freezeImports(); err != nil { return err } for _, generatedPackage := range g.packages { - generatedPackage.freeze() + if err := generatedPackage.freeze(); err != nil { + return err + } } g.frozen = true return nil } + +// ImportPath returns the canonical Go import path owned by the package. +func (p *GeneratedPackage) ImportPath() string { + return p.path +} + +// OutputDirectory returns the canonical directory relative to the generation +// output root where this package's files are written. +func (p *GeneratedPackage) OutputDirectory() string { + return p.outputDir +} + +// cleanImportPath canonicalizes slash-based Go import paths. +func cleanImportPath(importPath string) string { + return path.Clean(strings.ReplaceAll(importPath, "\\", "/")) +} + +// generatedOutputDirectory maps a generated import path to its directory +// below gen and rejects packages outside the generated module root. +func generatedOutputDirectory(genpkg, importPath string) (string, error) { + var relative string + switch genpkg { + case "/": + if !strings.HasPrefix(importPath, "/") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = strings.TrimPrefix(importPath, "/") + case ".": + if path.IsAbs(importPath) || importPath == ".." || strings.HasPrefix(importPath, "../") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = importPath + default: + if importPath != genpkg && !strings.HasPrefix(importPath, genpkg+"/") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = strings.TrimPrefix(importPath, genpkg) + relative = strings.TrimPrefix(relative, "/") + } + if relative == ".." || strings.HasPrefix(relative, "../") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + return filepath.Clean(filepath.FromSlash(path.Join(Gendir, relative))), nil +} diff --git a/codegen/generator/example.go b/codegen/generator/example.go index b5348aa9ab..66df7f1d35 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -32,12 +32,12 @@ func Example(generation *codegen.Generation) ([]*codegen.File, error) { } // server main - if fs := example.ServerFiles(generation.GenPkg(), r, services); len(fs) != 0 { + if fs := example.ServerFiles(r, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(generation.GenPkg(), r); len(fs) != 0 { + if fs := example.CLIFiles(r); len(fs) != 0 { files = append(files, fs...) } diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 17182f3e2b..d2e4a5d80e 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -15,16 +15,25 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" "golang.org/x/tools/go/packages" ) // Generate runs the code generation algorithms. func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { + return generate(dir, cmd, debug, defaultRegistry) +} + +// generate runs code generation with an explicit registry so package tests can +// use isolated factories without replacing production globals. +func generate(dir, cmd string, debug bool, registry *registry) (outputs []string, err1 error) { startGenerate := time.Now() if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] Starting generator.Generate()\n") } + run, err := newGenerationRun(cmd, registry) + if err != nil { + return nil, err + } // 1. Compute design roots. var roots []eval.Root @@ -91,97 +100,15 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } } - // 3. Retrieve goa generators for given command. - var genfuncs []Genfunc - { - start := time.Now() - gs, err := Generators(cmd) - if err != nil { - return nil, err - } - genfuncs = gs - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Retrieve goa generators took %v (%d generators)\n", time.Since(start), len(genfuncs)) - } + // 3. Prepare roots, build and freeze one plan, then render core and plugin + // files through the fresh run objects instantiated before root evaluation. + startLifecycle := time.Now() + genfiles, err := run.execute(genpkg, roots) + if err != nil { + return nil, err } - - // 4. Run the code pre generation plugins then normalize the design - // roots. NormalizeRoot is the only sanctioned design mutation past eval - // finalization; it runs after the prepare plugins so plugin contributed - // endpoints are normalized too and before the generators so they all - // observe the same read-only design tree. - { - start := time.Now() - err := codegen.RunPluginsPrepare(cmd, genpkg, roots) - if err != nil { - return nil, err - } - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - codegen.NormalizeRoot(r) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 4: Run pre-generation plugins took %v\n", time.Since(start)) - } - } - - // 5. Create one generation context, plan every core and plugin declaration, - // then freeze all generated package names before rendering begins. - generation := codegen.NewGeneration(genpkg, roots) - { - start := time.Now() - for _, gen := range genfuncs { - if gen.Plan == nil { - continue - } - if err := gen.Plan(generation); err != nil { - return nil, err - } - } - if err := codegen.RunPluginsPlan(cmd, generation); err != nil { - return nil, err - } - if err := generation.Freeze(); err != nil { - return nil, err - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 5: Plan and freeze declarations took %v\n", time.Since(start)) - } - } - - // 6. Generate the initial files produced by the core generators. - // NOTE: Parallelization causes infinite recursion in AsObject() for circular type references - var genfiles []*codegen.File - { - start := time.Now() - for i, gen := range genfuncs { - genStart := time.Now() - fs, err := gen.Generate(generation) - if err != nil { - return nil, err - } - genfiles = append(genfiles, fs...) - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Generator %d produced %d files in %v\n", i, len(fs), time.Since(genStart)) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 6: Generate initial files took %v (total %d files)\n", time.Since(start), len(genfiles)) - } - } - - // 7. Run the code generation plugins with the same frozen generation. - { - start := time.Now() - var err error - genfiles, err = codegen.RunPlugins(cmd, generation, genfiles) - if err != nil { - return nil, err - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 7: Run post-generation plugins took %v (now %d files)\n", time.Since(start), len(genfiles)) - } + if debug { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Lifecycle produced %d files in %v\n", len(genfiles), time.Since(startLifecycle)) } // 8. Merge files that target the same path to avoid overwriting content when diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go index 15331f10c6..796505598e 100644 --- a/codegen/generator/generate_grpc_metadata_integration_test.go +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -12,13 +12,11 @@ import ( ) func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistry( + "gen", + testGenerator(planServiceData, Service), + testGenerator(planTransportData, Transport), + ) _ = codegen.RunDSL(t, func() { d.API("metadata", func() {}) @@ -68,7 +66,7 @@ func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - if _, err := Generate(dir, "gen", false); err != nil { + if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("generate gRPC metadata module: %v", err) } writeGRPCMetadataRoundTripTest(t, genDir) diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index 5bf4a990e5..2675329038 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -16,13 +16,11 @@ import ( ) func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistry( + "gen", + testGenerator(planServiceData, Service), + testGenerator(planTransportData, Transport), + ) dsl := func() { d.API("test", func() {}) @@ -67,7 +65,7 @@ func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - if _, err := Generate(dir, "gen", false); err != nil { + if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } assertGeneratedUnionDeclarations(t, genDir) diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index 413052529d..98208a0eb3 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -1,3 +1,5 @@ +// This file verifies file aggregation across core generators and plugins, +// including the separately assigned same-label section regression. package generator import ( @@ -17,32 +19,29 @@ import ( // TestMergeFilesPreservesSameLabelSections verifies that diagnostic section // labels do not cause the merger to discard different generated bodies. func TestMergeFilesPreservesSameLabelSections(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { - return []*codegen.File{{ - Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type-def", Source: "type First struct{}\n"}, - }, - }}, nil - }), - renderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { - return []*codegen.File{{ - Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type-def", Source: "type Second struct{}\n"}, - }, - }}, nil - }), - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type First struct{}\n"}, + }, + }}, nil + }), + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type Second struct{}\n"}, + }, + }}, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "types", "same_label.go")) require.NoError(t, err) @@ -56,40 +55,37 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { // an issue where only a later section (e.g., a union value method) remained and // the earlier struct definition was lost. func TestGenerateMergesSamePathFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) // Fake generators emit two files with identical Path, one containing a // type definition and the other containing a method. Without merging, the // second write would overwrite the first. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // struct definition - Name: "struct-type", - Source: "type MergeTest struct{}\n", - }, - } - return []*codegen.File{f}, nil - }), - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // method on MergeTest - Name: "method", - Source: "func (*MergeTest) Marker() {}\n", - }, - } - return []*codegen.File{f}, nil - }), - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // struct definition + Name: "struct-type", + Source: "type MergeTest struct{}\n", + }, + } + return []*codegen.File{f}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // method on MergeTest + Name: "method", + Source: "func (*MergeTest) Marker() {}\n", + }, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -114,35 +110,32 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { // pool distribution. This ensures all workers process files and all files are // written correctly. func TestGenerateParallelManyFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) // Generate 20 files to ensure we exceed typical CPU counts and exercise // the worker pool's work distribution. const numFiles = 20 - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, numFiles) - for i := 0; i < numFiles; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - { - Name: "type-def", - Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", - }, - } - files[i] = f + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, numFiles) + for i := 0; i < numFiles; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), } - return files, nil - }), - }, nil - } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + { + Name: "type-def", + Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", + }, + } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -172,41 +165,38 @@ func TestGenerateParallelManyFiles(t *testing.T) { // handles file merging when multiple generators target the same path. This // tests the interaction between mergeFilesByPath and parallel rendering. func TestGenerateParallelWithMerge(t *testing.T) { - t.Cleanup(func() { Generators = generators }) // Three generators: first two merge to same path, third is separate. // This exercises both merging and parallel writing with NumCPU workers. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f1.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type1", Source: "type Type1 struct{}\n"}, - } - return []*codegen.File{f1}, nil - }), - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f2.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type2", Source: "type Type2 struct{}\n"}, - } - return []*codegen.File{f2}, nil - }), - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} - f3.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type3", Source: "type Type3 struct{}\n"}, - } - return []*codegen.File{f3}, nil - }), - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f1.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type1", Source: "type Type1 struct{}\n"}, + } + return []*codegen.File{f1}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f2.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type2", Source: "type Type2 struct{}\n"}, + } + return []*codegen.File{f2}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} + f3.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type3", Source: "type Type3 struct{}\n"}, + } + return []*codegen.File{f3}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -246,38 +236,35 @@ func TestGenerateParallelWithMerge(t *testing.T) { // in the parallel worker pool, the first error is captured and returned while // other workers continue processing. func TestGenerateParallelErrorHandling(t *testing.T) { - t.Cleanup(func() { Generators = generators }) // Create multiple files where some will fail to render due to invalid paths. // Worker pool should capture first error but continue processing other files. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, 5) - for i := 0; i < 5; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, - } - // Make file 2 fail by adding an invalid path character after writing starts - if i == 2 { - // Use a FinalizeFunc that returns an error - f.FinalizeFunc = func(fp string) error { - return os.ErrInvalid - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, 5) + for i := 0; i < 5; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), + } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, + } + // Make file 2 fail by adding an invalid path character after writing starts + if i == 2 { + // Use a FinalizeFunc that returns an error + f.FinalizeFunc = func(fp string) error { + return os.ErrInvalid } - files[i] = f } - return files, nil - }), - }, nil - } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) if err == nil { t.Fatal("expected error from parallel generation, got nil") } @@ -290,23 +277,20 @@ func TestGenerateParallelErrorHandling(t *testing.T) { // TestGenerateParallelSingleFile verifies that parallel file writing works // correctly with just a single file (minimal parallelism edge case). func TestGenerateParallelSingleFile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - renderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type Single struct{}\n"}, - } - return []*codegen.File{f}, nil - }), - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type Single struct{}\n"}, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index f5cdb87f2e..c3f4060047 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -1,3 +1,5 @@ +// This file verifies that complete generation merges shared union +// declarations without losing their package-owned names. package generator import ( @@ -16,14 +18,12 @@ import ( // the union marker method for the union branch type. This mirrors the original // failure mode where only the union method remained and the struct was lost. func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - {Plan: planServiceData, Generate: OpenAPI}, - }, nil - } + registry := testRegistry( + "gen", + testGenerator(planServiceData, Service), + testGenerator(planTransportData, Transport), + testGenerator(planServiceData, OpenAPI), + ) dsl := func() { d.API("test", func() {}) @@ -61,7 +61,7 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { _ = cg.RunDSL(t, dsl) dir := t.TempDir() - if _, err := Generate(dir, "gen", false); err != nil { + if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index df9044e7eb..92125960a2 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -15,10 +15,7 @@ import ( func TestGeneratePhasesShareOneGeneration(t *testing.T) { command := fmt.Sprintf("test-generation-phases-%p", t) - codegen.RunDSL(t, func() {}) - t.Cleanup(func() { - Generators = generators - }) + root := codegen.RunDSL(t, func() {}) var ( events []string @@ -39,18 +36,20 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { } return nil } - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - { - Plan: func(generation *codegen.Generation) error { + registry := newRegistry() + registry.addCommand(command, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { events = append(events, "core-plan-first") - planned = generation - typesPath = generation.GenPkg() + "/types" - _, err := generation.GeneratedPackage(typesPath).DeclareUnion(union) + planned = plan.Generation() + typesPath = planned.GenPkg() + "/types" + _, err := planned.GeneratedPackage(typesPath).DeclareUnion(union) return err }, - Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + Generate: func(plan *Plan) ([]*codegen.File, error) { events = append(events, "core-render-first") + generation := plan.Generation() if err := assertGeneration(generation); err != nil { return nil, err } @@ -67,38 +66,45 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { } return nil, nil }, - }, - { - Plan: func(generation *codegen.Generation) error { + } + }, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { events = append(events, "core-plan-second") - return assertGeneration(generation) + return assertGeneration(plan.Generation()) }, - Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + Generate: func(plan *Plan) ([]*codegen.File, error) { events = append(events, "core-render-second") - return nil, assertGeneration(generation) + return nil, assertGeneration(plan.Generation()) }, - }, - }, nil - } - codegen.RegisterPlugin( + } + }, + ) + registry.registerPlugin( "lifecycle", command, - func(_ string, roots []eval.Root) error { - events = append(events, "plugin-prepare") - preparedRoots = roots - return nil - }, - func(generation *codegen.Generation) error { - events = append(events, "plugin-plan") - return assertGeneration(generation) - }, - func(generation *codegen.Generation, files []*codegen.File) ([]*codegen.File, error) { - events = append(events, "plugin-render") - return files, assertGeneration(generation) + pluginNormal, + func() Plugin { + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + events = append(events, "plugin-prepare") + preparedRoots = roots + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plugin-plan") + return assertGeneration(plan.Generation()) + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "plugin-render") + return files, assertGeneration(plan.Generation()) + }, + } }, ) - _, err := Generate(t.TempDir(), command, false) + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, command, registry) require.NoError(t, err) require.ErrorContains(t, lateDeclare, "frozen") require.Equal(t, []string{ diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 222db2d065..b96477fad0 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -1,53 +1,71 @@ -// Generate asks this file for the core callbacks selected by the gen or example -// command. It receives Genfunc records whose Plan callbacks all run before the -// same frozen Generation is passed to their file-producing Generate callbacks. +// This file defines the fresh core generator objects selected by each command. +// Factories are immutable; every run receives new callback values and retains +// one Plan from declaration planning through rendering. package generator -import ( - "fmt" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/eval" -) +import "goa.design/goa/v3/codegen" type ( - // Genfunc plans declarations and renders files for one generation run. - Genfunc struct { - // Plan declares generated package types before any generator renders files. - Plan codegen.PlanFunc - // Generate renders files from the frozen generation catalog. - Generate func(*codegen.Generation) ([]*codegen.File, error) + // coreGenerator plans and renders one core subsystem for a single run. + coreGenerator struct { + // Plan declares package symbols and retains run-specific analysis. + Plan func(*Plan) error + // Generate renders files from the same frozen plan. + Generate func(*Plan) ([]*codegen.File, error) } -) -// Generators returns the generation lifecycle callbacks for the given command, -// or an error if the command is not supported. Generators is a public variable -// so external code may replace the default generators. -var Generators = generators + // generatorFactory creates one core generator instance for a run. + generatorFactory func() coreGenerator +) -// generators returns the generator functions exposed by the generator package -// for the given command. -func generators(cmd string) ([]Genfunc, error) { - switch cmd { - case "gen": - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - {Plan: planServiceData, Generate: OpenAPI}, - }, nil - case "example": - return []Genfunc{{Plan: planTransportData, Generate: Example}}, nil - default: - return nil, fmt.Errorf("unknown command %q", cmd) +// genGeneratorFactories returns fresh service, transport, and OpenAPI factories. +func genGeneratorFactories() []generatorFactory { + return []generatorFactory{ + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + return planServiceData(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + return Service(plan.Generation()) + }, + } + }, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + return planTransportData(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + return Transport(plan.Generation()) + }, + } + }, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + return planServiceData(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + return OpenAPI(plan.Generation()) + }, + } + }, } } -// renderOnly adapts a generator that does not yet plan package declarations to -// render with the generation context selected by the top-level lifecycle. -func renderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) Genfunc { - return Genfunc{ - Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { - return generate(generation.GenPkg(), generation.Roots()) +// exampleGeneratorFactories returns a fresh example generator factory. +func exampleGeneratorFactories() []generatorFactory { + return []generatorFactory{ + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + return planTransportData(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + return Example(plan.Generation()) + }, + } }, } } diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go new file mode 100644 index 0000000000..674c5f3b6e --- /dev/null +++ b/codegen/generator/lifecycle.go @@ -0,0 +1,103 @@ +// This file executes the prepare, plan, freeze, and render phases for explicit +// roots. The public filesystem-facing generator and isolated tests both use +// this path, so lifecycle behavior has one implementation. +package generator + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // generationRun owns every fresh core and plugin instance for one execution. + generationRun struct { + cores []coreGenerator + plugins []Plugin + } +) + +// executeGeneration instantiates fresh core and plugin objects, prepares roots, +// and renders files from one retained frozen plan. +func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) ([]*codegen.File, error) { + run, err := newGenerationRun(command, registry) + if err != nil { + return nil, err + } + return run.execute(genpkg, roots) +} + +// newGenerationRun snapshots immutable factories and invokes each exactly once. +func newGenerationRun(command string, registry *registry) (*generationRun, error) { + coreFactories, pluginDescriptors, err := registry.snapshot(command) + if err != nil { + return nil, err + } + cores := make([]coreGenerator, len(coreFactories)) + for i, factory := range coreFactories { + cores[i] = factory() + } + plugins := make([]Plugin, len(pluginDescriptors)) + for i, descriptor := range pluginDescriptors { + plugins[i] = descriptor.factory() + } + return &generationRun{cores: cores, plugins: plugins}, nil +} + +// execute runs all phases for explicit prepared-root inputs. +func (r *generationRun) execute(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + for _, plugin := range r.plugins { + if plugin.Prepare != nil { + if err := plugin.Prepare(genpkg, roots); err != nil { + return nil, err + } + } + } + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + codegen.NormalizeRoot(design) + } + } + + plan := &Plan{generation: codegen.NewGeneration(genpkg, roots)} + for _, core := range r.cores { + if core.Plan != nil { + if err := core.Plan(plan); err != nil { + return nil, err + } + } + } + for _, plugin := range r.plugins { + if plugin.Plan != nil { + if err := plugin.Plan(plan); err != nil { + return nil, err + } + } + } + if err := plan.Generation().Freeze(); err != nil { + return nil, err + } + + var files []*codegen.File + for _, core := range r.cores { + if core.Generate == nil { + continue + } + generated, err := core.Generate(plan) + if err != nil { + return nil, err + } + files = append(files, generated...) + } + for _, plugin := range r.plugins { + if plugin.Generate == nil { + continue + } + generated, err := plugin.Generate(plan, files) + if err != nil { + return nil, err + } + files = generated + } + return files, nil +} diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go new file mode 100644 index 0000000000..e3653fb8df --- /dev/null +++ b/codegen/generator/plan.go @@ -0,0 +1,19 @@ +// This file defines the run-private plan shared by core generators and plugins. +// Task-specific retained analyses are added as typed private fields by the +// subsystem tasks that consume this lifecycle foundation. +package generator + +import "goa.design/goa/v3/codegen" + +type ( + // Plan is the immutable generation context passed from planning to rendering. + // Its fields are private so it cannot become a generic analysis registry. + Plan struct { + generation *codegen.Generation + } +) + +// Generation returns the declaration and import catalog for this run. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go new file mode 100644 index 0000000000..f30f1155ed --- /dev/null +++ b/codegen/generator/plugin.go @@ -0,0 +1,155 @@ +// This file owns immutable plugin factories and creates fresh callback objects +// for every generation run. The registry seals when its first run snapshots +// factories, preventing process history from changing later runs. +package generator + +import ( + "fmt" + "slices" + "strings" + "sync" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // PrepareFunc may amend evaluated roots before normalization and planning. + // Preparation is the only plugin phase allowed to mutate design expressions. + PrepareFunc func(genpkg string, roots []eval.Root) error + + // Plugin contains the optional callbacks run on one fresh plugin instance. + // Plan and Generate receive the same retained Plan pointer. + Plugin struct { + // Prepare may amend roots before the Generation snapshot is created. + Prepare PrepareFunc + // Plan declares plugin-owned package symbols before generation freeze. + Plan func(*Plan) error + // Generate appends or transforms files using the frozen retained plan. + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) + } + + // PluginFactory creates one independent plugin instance for each run. + PluginFactory func() Plugin + + // registry owns core and plugin factories used by one command namespace. + registry struct { + mu sync.Mutex + commands map[string][]generatorFactory + plugins []pluginDescriptor + sealed bool + next uint64 + } + + // pluginDescriptor is immutable registration metadata retained globally. + pluginDescriptor struct { + name string + command string + position pluginPosition + sequence uint64 + factory PluginFactory + } + + // pluginPosition defines the three stable registration groups. + pluginPosition uint8 +) + +const ( + pluginFirst pluginPosition = iota + pluginNormal + pluginLast +) + +var defaultRegistry = newDefaultRegistry() + +// RegisterPlugin registers a factory in the normal alphabetically ordered group. +func RegisterPlugin(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginNormal, factory) +} + +// RegisterPluginFirst registers a factory before normal and Last plugins. +func RegisterPluginFirst(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginFirst, factory) +} + +// RegisterPluginLast registers a factory after First and normal plugins. +func RegisterPluginLast(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginLast, factory) +} + +// newRegistry creates an empty mutable registry for init-time setup or tests. +func newRegistry() *registry { + return ®istry{commands: make(map[string][]generatorFactory)} +} + +// newDefaultRegistry creates the production command registry before external +// package initialization registers plugins. +func newDefaultRegistry() *registry { + registry := newRegistry() + registry.commands["gen"] = genGeneratorFactories() + registry.commands["example"] = exampleGeneratorFactories() + return registry +} + +// addCommand installs private core factories in an isolated test registry. +func (r *registry) addCommand(command string, factories ...generatorFactory) { + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator registry is sealed") + } + r.commands[command] = slices.Clone(factories) +} + +// registerPlugin records immutable factory metadata before the first snapshot. +func (r *registry) registerPlugin(name, command string, position pluginPosition, factory PluginFactory) { + if factory == nil { + panic("plugin factory is nil") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator plugin registry is sealed") + } + r.plugins = append(r.plugins, pluginDescriptor{ + name: name, + command: command, + position: position, + sequence: r.next, + factory: factory, + }) + r.next++ +} + +// snapshot seals the registry and returns copied factories in stable order. +func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescriptor, error) { + r.mu.Lock() + defer r.mu.Unlock() + factories, ok := r.commands[command] + if !ok { + return nil, nil, fmt.Errorf("unknown command %q", command) + } + r.sealed = true + plugins := make([]pluginDescriptor, 0, len(r.plugins)) + for _, plugin := range r.plugins { + if plugin.command == command { + plugins = append(plugins, plugin) + } + } + slices.SortStableFunc(plugins, func(left, right pluginDescriptor) int { + if left.position != right.position { + return int(left.position) - int(right.position) + } + if compared := strings.Compare(left.name, right.name); compared != 0 { + return compared + } + if left.sequence < right.sequence { + return -1 + } + if left.sequence > right.sequence { + return 1 + } + return 0 + }) + return slices.Clone(factories), plugins, nil +} diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go new file mode 100644 index 0000000000..6aada38b48 --- /dev/null +++ b/codegen/generator/plugin_test.go @@ -0,0 +1,215 @@ +// This file verifies that generator and plugin factories create isolated run +// objects and that every phase receives one retained Plan in stable order. +package generator + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPluginFactoryOrderAndPlan verifies First, normal, and Last ordering and +// proves that plugin planning and rendering receive the exact same Plan pointer. +func TestPluginFactoryOrderAndPlan(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + var ( + events []string + plans []*Plan + planMux sync.Mutex + ) + register := func(position pluginPosition, name string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + events = append(events, "prepare:"+name) + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plan:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "generate:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return files, nil + }, + } + }) + } + register(pluginLast, "z-last") + register(pluginNormal, "z-normal") + register(pluginFirst, "b-first") + register(pluginFirst, "a-first") + register(pluginNormal, "a-normal") + register(pluginLast, "a-last") + + _, err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:b-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "plan:a-first", "plan:b-first", "plan:a-normal", "plan:z-normal", "plan:a-last", "plan:z-last", + "generate:a-first", "generate:b-first", "generate:a-normal", "generate:z-normal", "generate:a-last", "generate:z-last", + }, events) + require.Len(t, plans, 12) + for _, plan := range plans[1:] { + require.Same(t, plans[0], plan) + } + require.NotNil(t, plans[0].Generation()) +} + +// TestPluginFactorySequentialIsolation verifies that every run invokes the +// factory again and no mutable callback state survives from an earlier run. +func TestPluginFactorySequentialIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + + for i := range 2 { + root := &expr.RootExpr{API: &expr.APIExpr{Name: fmt.Sprintf("run-%d", i)}} + _, err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", i), + []eval.Root{root}, + "test", + registry, + ) + require.NoError(t, err) + } +} + +// TestPluginFactoryConcurrentIsolation verifies that registry snapshots are +// race-safe and concurrent runs own independent callback state. +func TestPluginFactoryConcurrentIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + var wait sync.WaitGroup + errs := make(chan error, 2) + for i := range 2 { + wait.Add(1) + go func(index int) { + defer wait.Done() + root := &expr.RootExpr{API: &expr.APIExpr{Name: fmt.Sprintf("run-%d", index)}} + _, err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", index), + []eval.Root{root}, + "test", + registry, + ) + errs <- err + }(i) + } + wait.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } +} + +// TestPreparedRootsBecomeExactGenerationSnapshot verifies that plugin +// preparation completes before Generation copies root membership and values. +func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{Name: "before"}} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + if !plan.Generation().HasRoot(root) { + return fmt.Errorf("prepared root is absent from generation") + } + if root.API.Name != "after" { + return fmt.Errorf("generation observed API name %q", root.API.Name) + } + return nil + }, + } + }) + registry.registerPlugin("prepare", "test", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + roots[0].(*expr.RootExpr).API.Name = "after" + return nil + }} + }) + + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) +} + +// TestPluginRegistrySealsOnFirstSnapshot verifies that a run cannot observe +// factories registered after the registry's immutable snapshot is established. +func TestPluginRegistrySealsOnFirstSnapshot(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { return coreGenerator{} }) + _, err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Panics(t, func() { + registry.registerPlugin("late", "test", pluginNormal, func() Plugin { return Plugin{} }) + }) +} + +// isolatedPluginRegistry builds a factory whose private phase counter must +// always start at zero and advance exactly once through the three phases. +func isolatedPluginRegistry(t *testing.T) *registry { + t.Helper() + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(plan *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: plan.Generation().GenPkg()}}, nil + }} + }) + registry.registerPlugin("state", "test", pluginNormal, func() Plugin { + var ( + phase int + preparedRoot eval.Root + planned *Plan + ) + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + if phase != 0 { + return fmt.Errorf("prepare started at phase %d", phase) + } + if len(roots) != 1 { + return fmt.Errorf("prepare received %d roots", len(roots)) + } + preparedRoot = roots[0] + phase++ + return nil + }, + Plan: func(plan *Plan) error { + if phase != 1 { + return fmt.Errorf("plan started at phase %d", phase) + } + roots := plan.Generation().Roots() + if len(roots) != 1 || roots[0] != preparedRoot { + return fmt.Errorf("plan received another run's roots") + } + planned = plan + phase++ + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + if phase != 2 { + return nil, fmt.Errorf("generate started at phase %d", phase) + } + if plan != planned { + return nil, fmt.Errorf("generate received another run's plan") + } + if len(files) != 1 || files[0].Path != plan.Generation().GenPkg() { + return nil, fmt.Errorf("generate received another run's files") + } + phase++ + return files, nil + }, + } + }) + return registry +} diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index 53001db9cf..61b4aa63c7 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -1,3 +1,5 @@ +// This file snapshots evaluated design expressions and verifies that only the +// lifecycle preparation phase may change them. package generator import ( @@ -79,19 +81,8 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { before := snapshotDesign(root) for _, cmd := range []string{"gen", "example"} { - genfuncs, err := Generators(cmd) + _, err := executeGeneration("gen", []eval.Root{root}, cmd, newDefaultRegistry()) require.NoError(t, err) - generation := codegen.NewGeneration("gen", []eval.Root{root}) - for _, gen := range genfuncs { - if gen.Plan != nil { - require.NoError(t, gen.Plan(generation)) - } - } - require.NoError(t, generation.Freeze()) - for _, gen := range genfuncs { - _, err := gen.Generate(generation) - require.NoError(t, err) - } } after := snapshotDesign(root) @@ -107,6 +98,38 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { } } +// TestPreparedRootsDetectPostPrepareMutation proves that the exact design +// snapshot used by the purity boundary rejects a plugin that changes an +// expression during planning, after the only mutable lifecycle phase closed. +func TestPreparedRootsDetectPostPrepareMutation(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + codegen.NormalizeRoot(root) + registry := newRegistry() + var ( + prepared map[*expr.AttributeExpr]attrState + target *expr.AttributeExpr + ) + registry.registerPlugin("mutation", "test", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, _ []eval.Root) error { + prepared = snapshotDesign(root) + for target = range prepared { + break + } + return nil + }} + }) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Plan: func(_ *Plan) error { + target.Description = "changed after preparation" + return nil + }} + }) + + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) + require.NotEqual(t, prepared, snapshotDesign(root), "purity snapshot accepted a planning mutation") +} + // snapshotDesign walks every expression reachable from the root via exported // fields and captures the state of each attribute expression encountered. func snapshotDesign(root *expr.RootExpr) map[*expr.AttributeExpr]attrState { diff --git a/codegen/generator/registry_test.go b/codegen/generator/registry_test.go new file mode 100644 index 0000000000..7b8431362a --- /dev/null +++ b/codegen/generator/registry_test.go @@ -0,0 +1,70 @@ +// This file supplies isolated command registries to generator integration +// tests. Test factories adapt the transitional Generation-based core renderers +// without restoring mutable production hooks. +package generator + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // testGenfunc retains the previous fixture shape while adapting callbacks + // into fresh core generator objects. + testGenfunc struct { + // Plan declares package symbols through the fixture's Generation seam. + Plan func(*codegen.Generation) error + // Generate renders fixture files through the same Generation seam. + Generate func(*codegen.Generation) ([]*codegen.File, error) + } +) + +// testRegistry returns an isolated registry for one command. +func testRegistry(command string, factories ...generatorFactory) *registry { + registry := newRegistry() + registry.addCommand(command, factories...) + return registry +} + +// testRegistryFromGenfuncs creates one isolated command from fixture callbacks. +func testRegistryFromGenfuncs(command string, callbacks []testGenfunc) *registry { + factories := make([]generatorFactory, len(callbacks)) + for i, callback := range callbacks { + factories[i] = testGenerator(callback.Plan, callback.Generate) + } + return testRegistry(command, factories...) +} + +// testRenderOnly adapts a root-based rendering fixture into a test callback. +func testRenderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) testGenfunc { + return testGenfunc{Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + return generate(generation.GenPkg(), generation.Roots()) + }} +} + +// testGenerator adapts the current Generation-based core callback functions to +// a fresh run factory. Retained subsystem plans replace this adapter in Tasks 7–10. +func testGenerator(plan func(*codegen.Generation) error, generate func(*codegen.Generation) ([]*codegen.File, error)) generatorFactory { + return func() coreGenerator { + generator := coreGenerator{} + if plan != nil { + generator.Plan = func(retained *Plan) error { + return plan(retained.Generation()) + } + } + if generate != nil { + generator.Generate = func(retained *Plan) ([]*codegen.File, error) { + return generate(retained.Generation()) + } + } + return generator + } +} + +// testRenderGenerator adapts a legacy render-only test callback without adding +// a production lifecycle path. +func testRenderGenerator(generate func(string, []eval.Root) ([]*codegen.File, error)) generatorFactory { + return testGenerator(nil, func(generation *codegen.Generation) ([]*codegen.File, error) { + return generate(generation.GenPkg(), generation.Roots()) + }) +} diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index daf4ce0e66..b1dfb29287 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -20,13 +20,10 @@ import ( // TestRelocatedUnionPackageNamesCompile verifies that two services and their // HTTP and gRPC transports compile against distinct unions in one shared package. func TestRelocatedUnionPackageNamesCompile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) root := func() { dsl.API("relocated union package names", func() {}) @@ -93,7 +90,7 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, path := range []string{ filepath.Join("types", "first_input.go"), @@ -114,13 +111,10 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { // HTTP and gRPC response policy binds to the equivalent error value declared by // the endpoint method instead of retaining the API declaration object. func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { dsl.API("error policy", func() { @@ -140,7 +134,7 @@ func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) } @@ -148,13 +142,10 @@ func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { // TestNestedTransportMetadataOwnsRecursiveImports verifies conversion helpers // import a custom field type nested inside a relocated service declaration. func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { outer := dsl.Type("Outer", func() { @@ -182,7 +173,7 @@ func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "value"), "custom") runGeneratedTests(t, genDir) @@ -192,13 +183,10 @@ func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { // natural name collides with a fixed runtime import is declared and referenced // with the same generation-owned qualifier in every transport. func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { dsl.Service("Goa", func() { @@ -233,7 +221,7 @@ func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) } @@ -242,13 +230,10 @@ func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { // imports the relocated effective error referenced by generated HTTP and gRPC // encoders even though the method does not redeclare it. func TestInheritedTransportErrorsOwnImports(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { fault := dsl.Type("Fault", func() { @@ -271,7 +256,7 @@ func TestInheritedTransportErrorsOwnImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) } @@ -279,10 +264,7 @@ func TestInheritedTransportErrorsOwnImports(t *testing.T) { // TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch // aliases with one natural name but different primitive shapes remain distinct. func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) codegen.RunDSL(t, func() { first := dsl.Type("FirstValue", func() { @@ -308,7 +290,7 @@ func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) unionSource, err := os.ReadFile(filepath.Join(genDir, "types", "unions.go")) require.NoError(t, err) @@ -320,10 +302,7 @@ func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { // TestServiceUnionFamilyNamesAvoidExactDeclarations verifies that union // constants and constructors cannot collide with exact DSL type names. func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) codegen.RunDSL(t, func() { kind := dsl.Type("ValueKindText", dsl.String) @@ -345,7 +324,7 @@ func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) } @@ -353,13 +332,10 @@ func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { // TestServiceFilesOwnTheirImports verifies that imports used by one service do // not leak into another service file generated from the same design root. func TestServiceFilesOwnTheirImports(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { dsl.API("file-owned imports", func() {}) @@ -396,7 +372,7 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) } @@ -405,13 +381,10 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { // result declarations never relocate the request/response wrappers consumed by // the raw HTTP body path. func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { upload := dsl.Type("Upload", func() { @@ -450,7 +423,7 @@ func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) clientSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "client.go")) @@ -468,10 +441,7 @@ func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { // reference generated packages with the same Go package name without emitting // duplicate import aliases or ambiguous qualified references. func TestServiceReferencesUseImportPathAliases(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) codegen.RunDSL(t, func() { dsl.API("path-owned aliases", func() {}) @@ -496,7 +466,7 @@ func TestServiceReferencesUseImportPathAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) require.NoError(t, err) @@ -512,13 +482,10 @@ func TestServiceReferencesUseImportPathAliases(t *testing.T) { // JSON-RPC files qualify two same-basename service packages with the aliases // frozen by the shared generation. func TestTransportReferencesUseImportPathAliases(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { first := dsl.Type("First", func() { @@ -573,7 +540,7 @@ func TestTransportReferencesUseImportPathAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, transport := range []string{"http", "grpc", "jsonrpc"} { source := generatedTreeSource(t, filepath.Join(genDir, transport, "values")) @@ -613,10 +580,7 @@ func generatedTreeSource(t *testing.T, root string) string { // expand a named branch definition and import packages used only where that // named type itself is declared. func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) codegen.RunDSL(t, func() { dsl.API("named branch imports", func() {}) @@ -637,7 +601,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "json"), "json") content, err := os.ReadFile(filepath.Join(genDir, "values", "unions.go")) @@ -652,10 +616,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { // object wrappers collide only with declarations emitted in the same service // package, never with a nested declaration relocated elsewhere. func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) t.Run("relocated name does not collide", func(t *testing.T) { codegen.RunDSL(t, func() { @@ -691,7 +652,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) require.NoError(t, err) @@ -701,12 +662,10 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { }) t.Run("local name collides", func(t *testing.T) { - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { dsl.API("local wrapper names", func() {}) local := dsl.Type("UsePayload", func() { @@ -737,7 +696,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) require.NoError(t, err) @@ -752,10 +711,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { // used by two relocated declarations stay in their respective declaration // files and do not leak into the service file that references their package. func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{{Plan: planServiceData, Generate: Service}}, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) codegen.RunDSL(t, func() { dsl.API("nested file-owned imports", func() {}) @@ -784,7 +740,7 @@ func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "first", "shared"), "shared") writeStubPackage(t, filepath.Join(genDir, "custom", "second", "shared"), "shared") @@ -845,13 +801,10 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { // files resolve relocated streaming declarations through the frozen service // packages while their event and frame bodies remain transport-owned. func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { streamInput := relocatedStreamingType("StreamInput", "InputChoice", dsl.String) @@ -895,7 +848,7 @@ func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, path := range []string{ filepath.Join("http", "http_streams", "server", "websocket.go"), @@ -1086,13 +1039,10 @@ func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { // TestTransportStaticAliasesCompileWithHttpAndPathServices verifies transport // imports retain their literal qualifiers beside conflicting service names. func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(_ string) ([]Genfunc, error) { - return []Genfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, - }, nil - } + registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + {Plan: planServiceData, Generate: Service}, + {Plan: planTransportData, Generate: Transport}, + }) codegen.RunDSL(t, func() { for _, name := range []string{"Http", "Path"} { @@ -1115,7 +1065,7 @@ func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) writeGeneratedModule(t, genDir, "gen") - _, err := Generate(dir, "gen", false) + _, err := generate(dir, "gen", false, registry) require.NoError(t, err) httpServers, err := filepath.Glob(filepath.Join(genDir, "http", "*", "server", "server.go")) require.NoError(t, err) diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go new file mode 100644 index 0000000000..7e7ce8d742 --- /dev/null +++ b/codegen/name_declaration.go @@ -0,0 +1,154 @@ +// This file defines the canonical package-level Go name shared by declaration +// planning and rendering. Generated packages allocate exact names before +// compiler-preferred names and freeze each record before source rendering. +package codegen + +import ( + "fmt" + "strings" +) + +type ( + // PackageNameKind identifies the Go declaration category for diagnostics. + // Types, functions, constants, and variables still share one package namespace. + PackageNameKind uint8 + + // PackageNameOrder supplies deterministic ordering for preferred names in + // one subsystem-owned declaration family. Implementations compare only + // values with the same PackageNameFamily. + PackageNameOrder interface { + PackageNameFamily() string + ComparePackageName(PackageNameOrder) int + } + + // NameDeclaration records one package-level Go identifier. Its final name is + // unavailable until the owning generation freezes. + NameDeclaration struct { + kind PackageNameKind + preferred string + final string + packagePath string + exact bool + order PackageNameOrder + base *NameDeclaration + prefix string + suffix string + hashes []Hasher + frozen bool + } +) + +const ( + // NameType identifies a package-level type declaration. + NameType PackageNameKind = iota + 1 + // NameFunction identifies a package-level function declaration. + NameFunction + // NameConstant identifies a package-level constant declaration. + NameConstant + // NameVariable identifies a package-level variable declaration. + NameVariable +) + +// NewExactName creates an authored or external declaration whose exported Go +// identifier must not change. The owning generated package rejects collisions. +func NewExactName(kind PackageNameKind, preferred string) *NameDeclaration { + return &NameDeclaration{ + kind: kind, + preferred: Goify(preferred, true), + exact: true, + } +} + +// NewPreferredName creates a compiler-owned declaration whose preferred Go +// identifier may receive a deterministic numeric suffix. +func NewPreferredName(kind PackageNameKind, preferred string, order PackageNameOrder) *NameDeclaration { + if order == nil { + panic("preferred package name requires stable ordering") + } + return &NameDeclaration{ + kind: kind, + preferred: Goify(preferred, true), + order: order, + } +} + +// Name returns the frozen Go identifier. It panics before the owning +// generation freezes because no renderer may observe a provisional spelling. +func (d *NameDeclaration) Name() string { + if !d.frozen { + panic(fmt.Sprintf("package name %q requested before generation freeze", d.preferredName())) + } + return d.final +} + +// PreferredName returns the unsuffixed Go identifier requested during planning. +func (d *NameDeclaration) PreferredName() string { + return d.preferredName() +} + +// Kind returns the declaration category used for collision diagnostics. +func (d *NameDeclaration) Kind() PackageNameKind { + return d.kind +} + +// PackagePath returns the generated import path that owns the declaration. It +// is empty until a generated package accepts the record. +func (d *NameDeclaration) PackagePath() string { + return d.packagePath +} + +// String returns the declaration category used in planning errors. +func (k PackageNameKind) String() string { + switch k { + case NameType: + return "type" + case NameFunction: + return "function" + case NameConstant: + return "constant" + case NameVariable: + return "variable" + default: + return "unknown" + } +} + +// newDependentName creates a compiler-owned name whose preferred spelling is +// derived from another canonical declaration after that declaration freezes. +func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) *NameDeclaration { + if base == nil { + panic("dependent package name requires a base declaration") + } + if order == nil { + panic("dependent package name requires stable ordering") + } + return &NameDeclaration{ + kind: kind, + order: order, + base: base, + prefix: prefix, + suffix: suffix, + } +} + +// preferredName returns the requested name, using the base declaration's +// frozen spelling for linked declaration families such as union constructors. +func (d *NameDeclaration) preferredName() string { + if d.base == nil { + return d.preferred + } + base := d.base.preferred + if d.base.frozen { + base = d.base.final + } + return d.prefix + base + d.suffix +} + +// comparePackageNames orders independent records without consulting discovery +// order. Equal ordering facts for distinct records are a planning error. +func comparePackageNames(left, right *NameDeclaration) int { + if compared := strings.Compare(left.order.PackageNameFamily(), right.order.PackageNameFamily()); compared != 0 { + return compared + } + return left.order.ComparePackageName(right.order) +} diff --git a/codegen/plugin.go b/codegen/plugin.go deleted file mode 100644 index affc6acea1..0000000000 --- a/codegen/plugin.go +++ /dev/null @@ -1,146 +0,0 @@ -// Plugins register prepare, plan, and render callbacks in this file; the -// top-level generator invokes matching callbacks with design roots, the active -// Generation, and generated files. Preparation may change roots, planning may -// declare types, and rendering receives the same Generation only after freeze. -package codegen - -import "goa.design/goa/v3/eval" - -type ( - // PlanFunc declares generated package types before the generation is frozen. - // Planning functions must not render files. - PlanFunc func(*Generation) error - - // GenerateFunc makes it possible to modify the files generated by the - // goa code generators and other plugins. It receives the frozen generation - // used by core generators and the files produced by preceding callbacks. - GenerateFunc func(*Generation, []*File) ([]*File, error) - - // PrepareFunc makes it possible to modify the design roots before - // the files being generated by the goa code generators or other plugins. - PrepareFunc func(genpkg string, roots []eval.Root) error - - // plugin is a plugin that has been registered with a given command. - plugin struct { - // PrepareFunc is the plugin preparation function. - PrepareFunc - // PlanFunc is the plugin declaration planning function. - PlanFunc - // GenerateFunc is the plugin generator function. - GenerateFunc - // name is the plugin name. - name string - // cmd is the name of cmd to run. - cmd string - // if first is set the plugin cmd must run before all other plugins. - first bool - // if last is set the plugin cmd must run after all other plugins. - last bool - } -) - -// plugins keeps track of the registered plugins sorted by their first/last bools, -// names, or registration order. -var plugins []*plugin - -// RegisterPlugin adds the plugin to the list of plugins to be invoked with the -// given command. -func RegisterPlugin(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd} - var inserted bool - for i, plgn := range plugins { - if plgn.last || (!plgn.first && np.name < plgn.name) { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } -} - -// RegisterPluginFirst adds the plugin to the beginning of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd, first: true} - var inserted bool - for i, plgn := range plugins { - if !plgn.first || np.name < plgn.name { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } -} - -// RegisterPluginLast adds the plugin to the end of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginLast(name string, cmd string, pre PrepareFunc, plan PlanFunc, generate GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, PlanFunc: plan, GenerateFunc: generate, cmd: cmd, last: true} - var inserted bool - for i := len(plugins) - 1; i >= 0; i-- { - plgn := plugins[i] - if !plgn.last || plgn.name < np.name { - plugins = append(plugins[:i+1], append([]*plugin{np}, plugins[i+1:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } -} - -// RunPluginsPrepare executes the plugins prepare functions in the order -// they were registered. -func RunPluginsPrepare(cmd, genpkg string, roots []eval.Root) error { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - if plugin.PrepareFunc != nil { - err := plugin.PrepareFunc(genpkg, roots) - if err != nil { - return err - } - } - } - return nil -} - -// RunPluginsPlan executes plugin planning functions in registration order. -func RunPluginsPlan(cmd string, generation *Generation) error { - for _, plugin := range plugins { - if plugin.cmd != cmd || plugin.PlanFunc == nil { - continue - } - if err := plugin.PlanFunc(generation); err != nil { - return err - } - } - return nil -} - -// RunPlugins executes the plugins registered with the given command in the order -// they were registered. -func RunPlugins(cmd string, generation *Generation, genfiles []*File) ([]*File, error) { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - gs, err := plugin.GenerateFunc(generation, genfiles) - if err != nil { - return nil, err - } - genfiles = gs - } - return genfiles, nil -} diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go deleted file mode 100644 index 0e09ab514c..0000000000 --- a/codegen/plugin_test.go +++ /dev/null @@ -1,159 +0,0 @@ -// This file verifies plugin registration order and the shared generation -// lifecycle used by plugin prepare, plan, and render callbacks. -package codegen - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" -) - -func TestRegisterPlugin(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} - - pf1 = &plugin{name: "abc", first: true} - - pl1 = &plugin{name: "abc", last: true} - - pIns = &plugin{name: "cde"} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1}, []*plugin{p1, pIns}}, - {"plugins-with-first", []*plugin{pf1, p2}, []*plugin{pf1, pIns, p2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns, p2}, []*plugin{pf1, pIns, pIns, p2}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, p1, p2}, []*plugin{pf1, p1, pIns, p2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPlugin(pIns.name, "", nil, nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } -} - -func TestRegisterPluginFirst(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} - - pf1 = &plugin{name: "abc", first: true} - pf2 = &plugin{name: "def", first: true} - - pl1 = &plugin{name: "abc", last: true} - - pIns = &plugin{name: "cde", first: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1, p2}, []*plugin{pIns, p1, p2}}, - {"plugins-with-first", []*plugin{pf1, pf2}, []*plugin{pf1, pIns, pf2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns}, []*plugin{pf1, pIns, pIns}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, pf2, p1, p2}, []*plugin{pf1, pIns, pf2, p1, p2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginFirst(pIns.name, "", nil, nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } -} - -func TestRegisterPluginLast(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} - - pl1 = &plugin{name: "abc", last: true} - pl2 = &plugin{name: "def", last: true} - - pf1 = &plugin{name: "abc", first: true} - - pIns = &plugin{name: "cde", last: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-last", []*plugin{p1, p2}, []*plugin{p1, p2, pIns}}, - {"plugins-with-last", []*plugin{pl1, pl2}, []*plugin{pl1, pIns, pl2}}, - {"plugins-with-same-name", []*plugin{pl1, pIns}, []*plugin{pl1, pIns, pIns}}, - {"plugins-with-first", []*plugin{pf1, pl2}, []*plugin{pf1, pIns, pl2}}, - {"mixed", []*plugin{pf1, p1, p2, pl1, pl2}, []*plugin{pf1, p1, p2, pl1, pIns, pl2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginLast(pIns.name, "", nil, nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } -} - -func TestRegisterPluginLifecycleCallbacksUseGeneration(t *testing.T) { - existing := plugins - plugins = nil - t.Cleanup(func() { - plugins = existing - }) - - var ( - events []string - plannedGen *Generation - ) - RegisterPlugin( - "lifecycle", - "test", - func(_ string, _ []eval.Root) error { - events = append(events, "prepare") - return nil - }, - func(generation *Generation) error { - events = append(events, "plan") - plannedGen = generation - _, err := generation.GeneratedPackage("generated.local/gen/types").DeclareUnion( - &expr.Union{TypeName: "Value"}, - ) - return err - }, - func(generation *Generation, files []*File) ([]*File, error) { - events = append(events, "render") - require.Same(t, plannedGen, generation) - return files, nil - }, - ) - - generation := NewGeneration("generated.local/gen", nil) - require.NoError(t, RunPluginsPrepare("test", generation.GenPkg(), generation.Roots())) - require.NoError(t, RunPluginsPlan("test", generation)) - require.NoError(t, generation.Freeze()) - _, err := RunPlugins("test", generation, nil) - require.NoError(t, err) - require.Equal(t, []string{"prepare", "plan", "render"}, events) -} diff --git a/codegen/scope.go b/codegen/scope.go index 8df4755fea..2c361547c8 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -92,6 +92,22 @@ func (s *NameScope) Freeze() { s.frozen = true } +// bind associates an already reserved name with one hash without allocating a +// second package identifier. Generated packages call it only during freeze. +func (s *NameScope) bind(key Hasher, name string) { + if s.frozen { + panic("cannot bind a hashed name in a frozen name scope") + } + hash := key.Hash() + if existing, ok := s.names[hash]; ok && existing != name { + panic(fmt.Sprintf("hash %q is already bound to package name %q", hash, existing)) + } + if _, ok := s.counts[name]; !ok { + panic(fmt.Sprintf("package name %q must be reserved before hash binding", name)) + } + s.names[hash] = name +} + // PeekUnique returns the name that Unique would return for the same inputs, // without mutating the scope. // diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 052795359a..38664e2ffa 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -335,11 +335,13 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) - resultDeclaration, err := serviceTypes.Type(rootTypes.canonical(resultType)) - if err != nil { + if _, err := serviceTypes.Type(rootTypes.canonical(resultType)); err != nil { return err } - if _, err := views.DeclareDerivedType(codegen.NewViewedResultTypeID(resultType), resultDeclaration.Name()); err != nil { + if _, err := views.DeclareDerivedType( + codegen.NewViewedResultTypeID(resultType), + codegen.Goify(resultType.Name(), true), + ); err != nil { return err } } diff --git a/expr/root.go b/expr/root.go index 5ba20b8580..810155f038 100644 --- a/expr/root.go +++ b/expr/root.go @@ -1,3 +1,6 @@ +// This file defines the evaluated design root and validates relationships +// between its API, services, generated types, and explicitly relocated user +// types before code generation begins. package expr import ( @@ -229,21 +232,21 @@ func (r *RootExpr) Validate() error { // types. func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { var verr eval.ValidationErrors - declared := make(map[string]struct{}, len(r.Types)) + declared := make(map[UserType]struct{}, len(r.Types)) for _, ut := range r.Types { - declared[ut.ID()] = struct{}{} + declared[ut.Origin()] = struct{}{} } for _, ut := range r.Types { pkgPath, ok := ut.Attribute().Meta.Last("struct:pkg:path") if !ok || pkgPath == "" { continue } - seen := make(map[string]struct{}) + seen := make(map[UserType]struct{}) r.walkUserTypeDependencies(ut, seen, "", func(dep UserType, path string) { - if dep.ID() == ut.ID() { + if dep.Origin() == ut.Origin() { return } - if _, ok := declared[dep.ID()]; !ok { + if _, ok := declared[dep.Origin()]; !ok { // Generated/derived user types (e.g. union branch wrappers) are // materialized alongside their owning types and do not require an // explicit struct:pkg:path. @@ -275,7 +278,7 @@ func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { // walkUserTypeDependencies traverses the attribute graph reachable from root and // invokes visit for each encountered user type. -func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if root == nil || root.Attribute() == nil { return } @@ -287,16 +290,17 @@ func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struc // // The path argument records the traversal path through objects, arrays, maps, // and unions and is intended for diagnostics. -func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if att == nil || att.Type == Empty { return } switch t := att.Type.(type) { case UserType: - if _, ok := seen[t.ID()]; ok { + origin := t.Origin() + if _, ok := seen[origin]; ok { return } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} visit(t, path) r.walkAttributeUserTypes(t.Attribute(), seen, path, visit) case *Object: diff --git a/expr/root_test.go b/expr/root_test.go index a177ad2195..6d1c26627c 100644 --- a/expr/root_test.go +++ b/expr/root_test.go @@ -1,13 +1,66 @@ +// This file verifies root validation, including exact-origin dependency +// traversal for explicitly relocated user types. package expr import ( "errors" "fmt" + "strings" "testing" "goa.design/goa/v3/eval" ) +func TestRelocatedDependenciesUseDeclarationOrigin(t *testing.T) { + dependency := &UserTypeExpr{ + TypeName: "Dependency", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{Type: String}, + } + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: &Object{&NamedAttributeExpr{ + Name: "dependency", + Attribute: &AttributeExpr{Type: dependency}, + }}, + }, + } + root := &RootExpr{Types: []UserType{relocated, dependency}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 1 { + t.Fatalf("expected one relocated dependency error, got %d", len(errors.Errors)) + } + if message := errors.Errors[0].Error(); !strings.Contains(message, "Dependency") { + t.Errorf("expected dependency name in error, got %q", message) + } +} + +func TestRelocatedDependencyWalkStopsAtExactOriginCopy(t *testing.T) { + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "relocated", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: String, + }, + } + copy := relocated.Dup(DupAtt(relocated.Attribute())) + relocated.AttributeExpr.Type = &Object{&NamedAttributeExpr{ + Name: "self", + Attribute: &AttributeExpr{Type: copy}, + }} + root := &RootExpr{Types: []UserType{relocated}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 0 { + t.Errorf("expected exact origin copy to be treated as recursion, got %v", errors) + } +} + func TestRootExprValidate(t *testing.T) { cases := map[string]struct { api *APIExpr diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index 49a80fd2dc..d574b28471 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -32,7 +32,7 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { if _, err := os.Stat(mainPath); !os.IsNotExist(err) { return nil // file already exists, skip it. } - rootPath := example.RootPath(genpkg) + rootPath := path.Dir(genpkg) cliImport := services.PackageImport(path.Join(genpkg, "grpc", "cli", svrdata.Dir)) specs := []*codegen.ImportSpec{ diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index f0e39da93e..2f072ca6d4 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -58,7 +58,7 @@ func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { specs = append(specs, serverImport, serviceImport, protobufImport) } - rootPath := example.RootPath(genpkg) + rootPath := path.Dir(genpkg) apiImport := services.PackageImport(rootPath) specs = append(specs, apiImport) diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index 8c513c479f..ca94bb3ad6 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -38,7 +38,7 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { if services.jsonrpc { funcSuffix = "JSONRPC" } - rootPath := example.RootPath(genpkg) + rootPath := path.Dir(genpkg) cliImport := services.PackageImport(path.Join(genpkg, services.dir(), "cli", svrdata.Dir)) specs := []*codegen.ImportSpec{ {Path: "context"}, diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index b8ee84b8c9..90c362fb04 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,8 +1,6 @@ -// This file renders example HTTP server wiring and multipart stubs, attaching -// relocated type imports only to the example file that references them. -// This file renders runnable HTTP servers and multipart helpers whose -// generated service, transport, and application imports use the qualifiers -// selected during planning. +// This file renders runnable HTTP servers and multipart helpers. Each example +// file imports relocated types and generated service packages with the +// qualifiers selected during planning. package codegen import ( @@ -60,7 +58,7 @@ func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services specs = append(specs, serverImport, serviceImport) } - rootPath := example.RootPath(genpkg) + rootPath := path.Dir(genpkg) apiImport := services.PackageImport(rootPath) apiPkg := apiImport.Name specs = append(specs, apiImport) @@ -148,9 +146,10 @@ func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *code } } specs = append(specs, services.ServiceImport(svc.Name())) - specs = append(specs, services.AttributeImports(example.RootPath(genpkg), ServiceReferenceAttributes(multipartEndpoints...)...)...) + rootPath := path.Dir(genpkg) + specs = append(specs, services.AttributeImports(rootPath, ServiceReferenceAttributes(multipartEndpoints...)...)...) - apiPkg := services.PackageImport(example.RootPath(genpkg)).Name + apiPkg := services.PackageImport(rootPath).Name sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} for _, e := range data.Endpoints { if e.MultipartRequestDecoder != nil { From 84bed415df7d9649853e800d5e597ccb76b7a453 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 11:56:03 -0700 Subject: [PATCH 28/43] fix(codegen): make generation state exact and run-owned --- codegen/ARCHITECTURE.md | 54 +- codegen/example/example_server_test.go | 6 +- codegen/generated_types.go | 251 ++++--- codegen/generated_types_test.go | 639 +++++++++++++++--- codegen/generation.go | 189 ++++-- codegen/generator/design_snapshot.go | 519 ++++++++++++++ codegen/generator/design_snapshot_test.go | 155 +++++ codegen/generator/example.go | 9 +- codegen/generator/example_state_test.go | 139 ++++ codegen/generator/generate.go | 247 ++++--- ...generate_grpc_metadata_integration_test.go | 4 +- ...erate_http_union_shape_integration_test.go | 4 +- codegen/generator/generate_merge_test.go | 196 +++++- .../generate_union_merge_integration_test.go | 6 +- ...erated_transport_alias_integration_test.go | 10 +- codegen/generator/generation_test.go | 52 +- codegen/generator/generators.go | 14 +- codegen/generator/lifecycle.go | 87 ++- codegen/generator/openapi.go | 13 +- codegen/generator/plan.go | 41 +- codegen/generator/plugin.go | 41 +- .../plugin_registry_contract_test.go | 90 +++ codegen/generator/plugin_test.go | 145 +++- codegen/generator/purity_test.go | 216 +----- codegen/generator/registry_test.go | 14 +- codegen/generator/run_examples.go | 21 + codegen/generator/service.go | 10 +- .../service_union_package_scope_test.go | 102 +-- codegen/generator/test_helpers_test.go | 49 ++ codegen/generator/transport.go | 9 +- codegen/import_aliases_test.go | 10 +- codegen/name_declaration.go | 144 ++-- codegen/normalize.go | 85 ++- codegen/service/convert.go | 98 +-- codegen/service/convert_test.go | 22 + codegen/service/declaration_resolver.go | 6 +- codegen/service/declaration_resolver_test.go | 18 +- codegen/service/example_generator_test.go | 75 ++ codegen/service/generated_package.go | 140 ++-- codegen/service/imports.go | 29 +- codegen/service/imports_test.go | 26 +- codegen/service/service.go | 43 +- codegen/service/service_data.go | 67 +- .../service_data_union_nilability_test.go | 4 +- .../service/service_data_union_order_test.go | 10 +- codegen/service/service_test.go | 143 +++- codegen/service/test_helpers_test.go | 28 + codegen/service/testing.go | 9 +- codegen/testing.go | 5 +- .../2026-08-20-generated-package-ownership.md | 54 +- dsl/api.go | 19 +- dsl/randomizer_test.go | 37 + expr/api.go | 19 +- expr/example.go | 19 +- expr/example_identity.go | 274 ++++++++ expr/example_stability_test.go | 30 +- expr/example_test.go | 27 +- expr/http_body_types.go | 117 +--- expr/http_body_types_test.go | 72 ++ expr/method.go | 6 + expr/project_test.go | 24 +- expr/random.go | 465 +++++++------ expr/random_factory_test.go | 231 +++++++ expr/result_type.go | 36 +- expr/service.go | 12 + expr/types.go | 11 +- expr/types_test.go | 9 +- expr/user_type.go | 38 +- expr/user_type_example_test.go | 91 ++- go.mod | 2 +- grpc/codegen/example_cli_test.go | 8 +- grpc/codegen/example_identity_test.go | 12 + .../oneof_anonymous_user_union_test.go | 6 +- grpc/codegen/parse_endpoint_test.go | 4 +- grpc/codegen/plan_test.go | 5 +- grpc/codegen/protobuf.go | 61 +- grpc/codegen/protobuf_test.go | 125 +++- grpc/codegen/protobuf_transform_test.go | 12 +- grpc/codegen/service_data.go | 75 +- .../testdata/client-interceptors.golden | 4 +- grpc/codegen/testdata/client-no-server.golden | 2 +- ...nt-server-hosting-multiple-services.golden | 2 +- ...lient-server-hosting-service-subset.golden | 2 +- ...endpoint-endpoint-with-interceptors.golden | 10 +- grpc/codegen/testing.go | 15 +- http/codegen/client_cli_test.go | 19 +- http/codegen/cookie_security_test.go | 6 +- http/codegen/openapi.go | 24 +- http/codegen/openapi/json_schema.go | 80 +-- .../codegen/openapi/json_schema_union_test.go | 8 +- http/codegen/openapi/v2/builder.go | 37 +- http/codegen/openapi/v2/builder_test.go | 18 +- http/codegen/openapi/v2/files.go | 7 +- http/codegen/openapi/v2/files_test.go | 10 +- .../TestSections/with-any_file0.golden | 2 - .../TestSections/with-any_file1.golden | 2 - .../TestSections/with-map_file0.golden | 6 + .../TestSections/with-map_file1.golden | 2 + .../TestValidations/array_file0.golden | 16 +- .../TestValidations/array_file1.golden | 10 +- http/codegen/openapi/v3/builder.go | 53 +- http/codegen/openapi/v3/builder_test.go | 16 +- http/codegen/openapi/v3/files.go | 7 +- http/codegen/openapi/v3/files_test.go | 6 +- http/codegen/openapi/v3/parameters.go | 28 +- http/codegen/openapi/v3/parameters_test.go | 30 +- http/codegen/openapi/v3/response.go | 24 +- .../testdata/golden/alias-type_file0.golden | 37 +- .../testdata/golden/alias-type_file1.golden | 37 +- .../v3/testdata/golden/array_file0.golden | 84 ++- .../v3/testdata/golden/array_file1.golden | 44 +- .../golden/error-examples_file0.golden | 8 +- .../golden/error-examples_file1.golden | 8 +- .../v3/testdata/golden/headers_file0.golden | 8 +- .../v3/testdata/golden/headers_file1.golden | 8 +- .../golden/not-generate-host_file0.golden | 4 +- .../golden/not-generate-host_file1.golden | 4 +- .../golden/not-generate-server_file0.golden | 4 +- .../golden/not-generate-server_file1.golden | 4 +- ...h-multiple-explicit-wildcards_file0.golden | 8 +- ...h-multiple-explicit-wildcards_file1.golden | 8 +- .../path-with-multiple-wildcards_file0.golden | 8 +- .../path-with-multiple-wildcards_file1.golden | 8 +- .../golden/path-with-wildcards_file0.golden | 4 +- .../golden/path-with-wildcards_file1.golden | 4 +- .../golden/sse-all-fields_file0.golden | 6 +- .../golden/sse-all-fields_file1.golden | 6 +- .../golden/sse-mixed-results_file0.golden | 12 +- .../golden/sse-mixed-results_file1.golden | 12 +- .../testdata/golden/sse-string_file0.golden | 4 +- .../testdata/golden/sse-string_file1.golden | 4 +- .../golden/type-extension_file0.golden | 8 +- .../golden/type-extension_file1.golden | 8 +- .../golden/v3.2/alias-type_file0.golden | 35 +- .../golden/v3.2/alias-type_file1.golden | 35 +- .../golden/v3.2/sse-all-fields_file0.golden | 10 +- .../golden/v3.2/sse-all-fields_file1.golden | 10 +- .../golden/v3.2/sse-data-field_file0.golden | 6 +- .../golden/v3.2/sse-data-field_file1.golden | 6 +- .../v3.2/sse-mixed-results_file0.golden | 16 +- .../v3.2/sse-mixed-results_file1.golden | 16 +- .../golden/v3.2/sse-object_file0.golden | 20 +- .../golden/v3.2/sse-object_file1.golden | 20 +- .../golden/v3.2/sse-request-id_file0.golden | 12 +- .../golden/v3.2/sse-request-id_file1.golden | 12 +- .../golden/v3.2/sse-string_file0.golden | 2 +- .../golden/v3.2/sse-string_file1.golden | 2 +- .../golden/v3.2/websocket_file0.golden | 10 +- .../golden/v3.2/websocket_file1.golden | 10 +- .../golden/v3.2/with-tags_file0.golden | 4 +- .../golden/v3.2/with-tags_file1.golden | 4 +- .../v3/testdata/golden/websocket_file0.golden | 10 +- .../v3/testdata/golden/websocket_file1.golden | 10 +- .../v3/testdata/golden/with-any_file0.golden | 1 - .../v3/testdata/golden/with-any_file1.golden | 1 - .../v3/testdata/golden/with-map_file0.golden | 15 + .../v3/testdata/golden/with-map_file1.golden | 5 + .../testdata/golden/with-spaces_file0.golden | 6 +- .../testdata/golden/with-spaces_file1.golden | 2 +- .../v3/testdata/golden/with-tags_file0.golden | 4 +- .../v3/testdata/golden/with-tags_file1.golden | 4 +- http/codegen/openapi/v3/types.go | 102 +-- http/codegen/openapi/v3/types_test.go | 12 +- http/codegen/openapi/v3/types_union_test.go | 8 +- .../codegen/openapi_disabled_examples_test.go | 59 ++ .../openapi_order_independence_test.go | 15 +- http/codegen/openapi_test.go | 6 +- http/codegen/plan_test.go | 13 +- http/codegen/service_data.go | 110 +-- .../client_cli_body-custom-name.go.golden | 2 +- ...cli_body-query-path-object-build.go.golden | 2 +- .../client_cli_empty-body-build.go.golden | 2 +- .../client_cli_map-query-object.go.golden | 2 +- .../golden/client_cli_map-query.go.golden | 2 +- .../golden/client_cli_multi-build.go.golden | 2 +- ...cli_payload-array-primitive-type.go.golden | 2 +- ...ient_cli_payload-array-user-type.go.golden | 2 +- ..._cli_payload-object-default-type.go.golden | 2 +- .../client_cli_payload-object-type.go.golden | 2 +- .../golden/client_cli_simple-build.go.golden | 2 +- ..._cli_with-params-and-headers-dsl.go.golden | 2 +- http/codegen/testing.go | 15 +- http/codegen/websocket.go | 9 +- jsonrpc/codegen/kitchen_sink_test.go | 70 +- jsonrpc/codegen/plan_test.go | 10 +- .../gen/http/cli/kitchen_sink/cli.go.golden | 4 +- .../gen/http/mixed/client/cli.go.golden | 2 +- .../gen/jsonrpc/calc/client/cli.go.golden | 4 +- .../gen/jsonrpc/chat/client/cli.go.golden | 2 +- .../jsonrpc/cli/kitchen_sink/cli.go.golden | 18 +- .../gen/jsonrpc/feed/client/cli.go.golden | 2 +- .../gen/jsonrpc/mixed/client/cli.go.golden | 2 +- jsonrpc/codegen/testing.go | 14 +- 193 files changed, 5685 insertions(+), 2095 deletions(-) create mode 100644 codegen/generator/design_snapshot.go create mode 100644 codegen/generator/design_snapshot_test.go create mode 100644 codegen/generator/example_state_test.go create mode 100644 codegen/generator/plugin_registry_contract_test.go create mode 100644 codegen/generator/run_examples.go create mode 100644 codegen/generator/test_helpers_test.go create mode 100644 codegen/service/example_generator_test.go create mode 100644 codegen/service/test_helpers_test.go create mode 100644 dsl/randomizer_test.go create mode 100644 expr/example_identity.go create mode 100644 expr/random_factory_test.go create mode 100644 grpc/codegen/example_identity_test.go create mode 100644 http/codegen/openapi_disabled_examples_test.go diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index c3a2e46d5a..9a6d6d43bf 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -34,11 +34,16 @@ design. One run follows this order: 1. Resolve the command and instantiate fresh core generator and plugin objects from immutable registered factories. 2. Evaluate and validate the design roots. -3. Run preparation. Preparation plugins may add or change expressions, and the - core normalizer may wrap raw method attributes. No later phase may mutate an - expression root. -4. Create one `codegen.Generation` from an immutable snapshot of the prepared - roots and generated module path. +3. Run preparation plugins, which may add or change expressions. Construct one + `codegen.Generation` with exclusive access to those evaluated roots. Its + final preparation step wraps raw method attributes and records each exact + generated wrapper. Concurrent runs use distinct expression graphs; the + generation does not copy the graph or coordinate two runs mutating the same + unprepared objects. +4. Record the prepared roots for mutation auditing. No later phase may mutate + an expression root. The audit compares retained semantic state after every + later callback; it does not claim that the expression graph was physically + copied or made immutable. 5. Build one typed `generator.Plan`. It creates and retains the core service plan for each root, then the selected HTTP, gRPC, JSON-RPC, OpenAPI, and example plans that consume those exact service plans. Plugin planning @@ -80,6 +85,11 @@ The `codegen` package owns generated declarations and files; it does not own a process-global plugin lifecycle. Core generator factories follow the same fresh-instance rule. +Plugin names are non-empty and unique within one command across the First, +normal, and Last groups. Registration rejects unknown commands and stops after +the first run snapshots the registry. This makes alphabetical order a complete +ordering rule instead of relying on mutable registration sequence. + A factory may close over immutable configuration. Per-run roots, plans, files, caches, and errors belong to the returned object. Concurrent and repeated generation runs must not observe one another. The registry itself is immutable @@ -179,7 +189,16 @@ paths before collection. If two different package identities normalize to the same import path or output directory, planning rejects them. It does not let one package win, merge their declarations, or add a suffix to a directory. Multiple file contributions may share a canonical path only when they declare -the same package identity; the file merger then appends all sections. +the same package identity. The file merger appends every body section and runs +every file finalizer in contributor order. It rejects conflicting package +headers, import bindings, or keep-existing-file settings instead of choosing a +contributor. + +Planning claims packages with the exact raw path supplied by the owner. The +claim is validated as a legal Go import path and preserves enough information +to reject two distinct raw paths that normalize to one import or output +directory. After freeze, generators use only canonical-path lookup; every +claim, including a repeated claim, is rejected because collection is closed. ## Expression identity and declaration identity @@ -205,6 +224,29 @@ identities such as `UnionTypeID` describe emitted union families. Do not change expression hashes, decorate string keys, or add general expression provenance to coordinate code generation. +## Example value identity + +Example configuration is immutable. One generation run creates an unanchored +`ExampleGenerator` for each prepared root, and every value draw must first +select a typed `ExampleIdentity`. The public identity constructors accept the +owning evaluated expression: a user type, method payload or result, method +error, HTTP request body, successful HTTP response, or HTTP error. Callers do +not join service names, response positions, or role labels into seed strings. + +Structural descent is also typed. Object members, array elements, map keys, map +values, and union branches use distinct kind-tagged, length-framed segments. +For example, object member `"0"` cannot share a stream with array element zero, +and a result field named `NotFound` cannot share a stream with the method error +named `NotFound`. Length-constrained arrays and maps derive one stream per +element, key, and value instead of consuming a shared stream in traversal +order. + +Named user types own their examples globally. Anonymous request, response, and +streaming shapes retain the explicit method or transport owner passed by their +caller. A zero-value generator intentionally disables OpenAPI examples; every +configured but unanchored value draw panics because it violates the identity +contract. + ## Service plan `service.Plan` owns every service and views package declaration for one root. diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 83c4d55035..3aec585a07 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -17,6 +17,7 @@ import ( "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) // updateGolden is true when -w is passed to `go test`, e.g. `go test ./... -w` @@ -64,11 +65,12 @@ func TestExampleServerFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) fs := ServerFiles(root, services) require.Len(t, fs, 1) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 2bcdff0ff2..8fd07d80fa 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -1,10 +1,11 @@ // This file defines the declaration catalog owned by one generated Go -// package. Planning reserves every public type name here before generators use -// the frozen records to render declarations and references. +// package. Planning reserves every package-level name here before generators +// use the frozen records to render declarations and references. package codegen import ( "fmt" + "reflect" "slices" "strings" @@ -12,22 +13,20 @@ import ( ) type ( - // GeneratedPackage owns type declarations and their shared naming scope for - // one generated Go package. + // GeneratedPackage owns declarations and their shared naming scope for one + // generated Go package. GeneratedPackage struct { - path string - outputDir string - scope *NameScope - names []*NameDeclaration - nameRecords map[*NameDeclaration]struct{} - exactNames map[string]*NameDeclaration - userTypes map[expr.UserType]*TypeDeclaration - typeBindings map[expr.UserType]*TypeDeclaration - derivedTypes map[DerivedTypeID]*derivedTypeDeclaration - unions map[UnionTypeID]*unionDeclaration - userTypeNames map[string]string - derivedKeys map[derivedTypeOrder]DerivedTypeID - frozen bool + claim string + path string + outputDir string + scope *NameScope + names []*NameDeclaration + exactNames map[string]*NameDeclaration + userTypes map[expr.UserType]*TypeDeclaration + typeBindings map[expr.UserType]*TypeDeclaration + derivedTypes map[DerivedTypeID]*TypeDeclaration + unions map[UnionTypeID]*unionDeclaration + frozen bool } // DerivedTypeID identifies a generated declaration by the exact source @@ -41,9 +40,10 @@ type ( // It supplies both the semantic expression UID and the compiler declaration // kind used for the wrapper created from a raw object. MethodTypeIdentity struct { - serviceName string - methodName string - kind derivedTypeKind + name string + kind derivedTypeKind + exampleIdentity expr.ExampleIdentity + origin expr.UserType } // TypeDeclaration records the canonical name and package path of one @@ -65,7 +65,6 @@ type ( kindDeclaration *NameDeclaration constructorDeclaration *NameDeclaration branchType *TypeDeclaration - typeName string } // unionDeclaration retains the expression needed to allocate the public @@ -86,13 +85,6 @@ type ( // independently during planning and rendering. derivedTypeKind uint - // derivedTypeDeclaration retains the preferred name until package freeze. - derivedTypeDeclaration struct { - declaration *TypeDeclaration - name string - order derivedTypeOrder - } - // derivedTypeOrder contains only stable semantic values so view declaration // suffixes never depend on expression pointer addresses or traversal order. derivedTypeOrder struct { @@ -142,42 +134,16 @@ func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { return newDerivedTypeID(source, viewedResultTypeKind) } -// NewMethodPayloadIdentity returns the identity of a method payload wrapper. -func NewMethodPayloadIdentity(serviceName, methodName string) MethodTypeIdentity { - return newMethodTypeIdentity(serviceName, methodName, methodPayloadTypeKind) -} - -// NewMethodStreamingPayloadIdentity returns the identity of a method streaming -// payload wrapper. -func NewMethodStreamingPayloadIdentity(serviceName, methodName string) MethodTypeIdentity { - return newMethodTypeIdentity(serviceName, methodName, methodStreamingPayloadTypeKind) -} - -// NewMethodResultIdentity returns the identity of a method result wrapper. -func NewMethodResultIdentity(serviceName, methodName string) MethodTypeIdentity { - return newMethodTypeIdentity(serviceName, methodName, methodResultTypeKind) -} - -// NewMethodStreamingResultIdentity returns the identity of a method streaming -// result wrapper. -func NewMethodStreamingResultIdentity(serviceName, methodName string) MethodTypeIdentity { - return newMethodTypeIdentity(serviceName, methodName, methodStreamingResultTypeKind) -} - // Name returns the semantic wrapper name assigned during normalization. func (i MethodTypeIdentity) Name() string { - return Goify(i.methodName, true) + i.kind.methodSuffix() + return i.name } -// UID returns the semantic expression identifier assigned during -// normalization. +// UID returns the stable semantic expression identifier assigned during +// normalization. It reuses the wrapper's typed example owner so declaration +// identity and example identity cannot disagree about the method role. func (i MethodTypeIdentity) UID() string { - return i.serviceName + "#" + i.Name() -} - -// Matches reports whether userType was normalized for this exact method role. -func (i MethodTypeIdentity) Matches(userType expr.UserType) bool { - return userType.ID() == i.UID() + return "generated:" + i.exampleIdentity.Seed() } // Name returns the unqualified Go declaration name. It panics until the @@ -188,7 +154,7 @@ func (d *TypeDeclaration) Name() string { // PackagePath returns the import path of the package that owns the declaration. func (d *TypeDeclaration) PackagePath() string { - return d.declaration.PackagePath() + return d.declaration.packagePath() } // Declaration returns the canonical package-owned name record. @@ -210,7 +176,7 @@ func (d *UnionDeclaration) KindName() string { // PackagePath returns the import path of the package that owns the union. func (d *UnionDeclaration) PackagePath() string { - return d.declaration.PackagePath() + return d.declaration.packagePath() } // Declaration returns the canonical package-owned union type name. @@ -261,16 +227,38 @@ func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { if p.frozen { return fmt.Errorf("generated package %q is frozen", p.path) } - if declaration.packagePath != "" { - if declaration.packagePath == p.path { + if err := validateNameDeclaration(declaration); err != nil { + return err + } + if declaration.owner != nil { + if declaration.owner == p { return nil } return fmt.Errorf( "package name %q already belongs to generated package %q", declaration.preferredName(), - declaration.packagePath, + declaration.owner.path, ) } + if declaration.base != nil { + switch { + case declaration.base.owner == nil: + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration is not owned", + p.path, + declaration.kind, + declaration.preferredName(), + ) + case declaration.base.owner != p: + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration belongs to generated package %q", + p.path, + declaration.kind, + declaration.preferredName(), + declaration.base.owner.path, + ) + } + } if declaration.exact { if existing, ok := p.exactNames[declaration.preferred]; ok { return fmt.Errorf( @@ -283,12 +271,20 @@ func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { } p.exactNames[declaration.preferred] = declaration } else { - for existing := range p.nameRecords { - if existing.exact || existing.base != nil || declaration.base != nil { + if err := validatePackageNameOrder(declaration.order); err != nil { + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: %w", + p.path, + declaration.kind, + declaration.preferredName(), + err, + ) + } + for _, existing := range p.names { + if existing.exact || reflect.TypeOf(existing.order) != reflect.TypeOf(declaration.order) { continue } - if existing.order.PackageNameFamily() == declaration.order.PackageNameFamily() && - existing.order.ComparePackageName(declaration.order) == 0 { + if existing.order.ComparePackageName(declaration.order) == 0 { return fmt.Errorf( "generated package %q cannot deterministically order preferred %s %q", p.path, @@ -298,9 +294,8 @@ func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { } } } - declaration.packagePath = p.path + declaration.owner = p p.names = append(p.names, declaration) - p.nameRecords[declaration] = struct{}{} return nil } @@ -311,20 +306,20 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara return nil, fmt.Errorf("generated package %q is frozen", p.path) } origin := userType.Origin() + name := Goify(userType.Name(), true) if declaration, ok := p.userTypes[origin]; ok { + if declaration.declaration.preferred != name { + return nil, fmt.Errorf( + "user type origin %q cannot declare both %q and %q in generated package %q", + origin.Name(), + declaration.declaration.preferred, + name, + p.path, + ) + } return declaration, nil } - name := Goify(userType.Name(), true) - if declaredName, ok := p.userTypeNames[name]; ok { - return nil, fmt.Errorf( - "generated package %q cannot declare user type %q as %q: already declared by user type %q", - p.path, - userType.Name(), - name, - declaredName, - ) - } nameDeclaration := NewExactName(NameType, name) if err := p.DeclareName(nameDeclaration); err != nil { return nil, fmt.Errorf("declare user type %q: %w", userType.Name(), err) @@ -335,7 +330,6 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara } p.bindName(nameDeclaration, userType) p.userTypes[origin] = declaration - p.userTypeNames[name] = userType.Name() return declaration, nil } @@ -346,28 +340,21 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } - if planned, ok := p.derivedTypes[identity]; ok { - if planned.name != name { + canonicalName := Goify(name, true) + if declaration, ok := p.derivedTypes[identity]; ok { + if declaration.declaration.preferred != canonicalName { return nil, fmt.Errorf( "derived type from %q cannot declare both %q and %q in generated package %q", identity.origin.Name(), - planned.name, - name, + declaration.declaration.preferred, + canonicalName, p.path, ) } - return planned.declaration, nil - } - order := newDerivedTypeOrder(identity, name) - if existing, ok := p.derivedKeys[order]; ok && existing != identity { - return nil, fmt.Errorf( - "generated package %q cannot deterministically order derived type %q from %q", - p.path, - name, - identity.origin.Name(), - ) + return declaration, nil } - nameDeclaration := NewPreferredName(NameType, name, order) + order := newDerivedTypeOrder(identity, canonicalName) + nameDeclaration := NewPreferredName(NameType, canonicalName, order) if err := p.DeclareName(nameDeclaration); err != nil { return nil, err } @@ -377,21 +364,16 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin return nil, err } } - p.derivedTypes[identity] = &derivedTypeDeclaration{ - declaration: declaration, - name: name, - order: order, - } - p.derivedKeys[order] = identity + p.derivedTypes[identity] = declaration return declaration, nil } // DeclareMethodType records the declaration created for identity from source // and returns the derived identity used for later lookup. func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source expr.UserType) (*TypeDeclaration, DerivedTypeID, error) { - if !identity.Matches(source) { + if identity.origin == nil || identity.origin != source.Origin() { return nil, DerivedTypeID{}, fmt.Errorf( - "user type %q does not match method wrapper %q", + "user type %q is not the compiler-owned method wrapper %q", source.Name(), identity.UID(), ) @@ -493,12 +475,12 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName } if branch.branchType != nil { name := Goify(userType.Name(), true) - if branch.typeName != name { + if branch.branchType.declaration.preferred != name { return nil, fmt.Errorf( "branch %q of union %q cannot declare both %q and %q", branchName, union.Name(), - branch.typeName, + branch.branchType.declaration.preferred, name, ) } @@ -522,7 +504,6 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName return nil, err } branch.branchType = declaration - branch.typeName = typeName return declaration, nil } @@ -546,8 +527,8 @@ func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error // DerivedType returns a previously planned generated view declaration. func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration, error) { - if planned, ok := p.derivedTypes[identity]; ok { - return planned.declaration, nil + if declaration, ok := p.derivedTypes[identity]; ok { + return declaration, nil } return nil, fmt.Errorf( "derived type from %q is not declared in generated package %q", @@ -601,21 +582,11 @@ func (p *GeneratedPackage) Scope() *NameScope { return p.scope } -// PackageNameFamily groups derived service declarations for typed comparison. -func (o derivedTypeOrder) PackageNameFamily() string { - return "service-derived-type" -} - // ComparePackageName orders two derived service declaration identities. func (o derivedTypeOrder) ComparePackageName(other PackageNameOrder) int { return compareDerivedTypeOrder(o, other.(derivedTypeOrder)) } -// PackageNameFamily groups complete union families for typed comparison. -func (o unionNameOrder) PackageNameFamily() string { - return "union" -} - // ComparePackageName orders union declarations by emitted identity and role. func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { right := other.(unionNameOrder) @@ -629,19 +600,17 @@ func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { } // newGeneratedPackage creates an empty mutable declaration catalog for path. -func newGeneratedPackage(path, outputDir string) *GeneratedPackage { +func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { return &GeneratedPackage{ - path: path, - outputDir: outputDir, - scope: NewNameScope(), - nameRecords: make(map[*NameDeclaration]struct{}), - exactNames: make(map[string]*NameDeclaration), - userTypes: make(map[expr.UserType]*TypeDeclaration), - typeBindings: make(map[expr.UserType]*TypeDeclaration), - derivedTypes: make(map[DerivedTypeID]*derivedTypeDeclaration), - unions: make(map[UnionTypeID]*unionDeclaration), - userTypeNames: make(map[string]string), - derivedKeys: make(map[derivedTypeOrder]DerivedTypeID), + claim: claim, + path: path, + outputDir: outputDir, + scope: NewNameScope(), + exactNames: make(map[string]*NameDeclaration), + userTypes: make(map[expr.UserType]*TypeDeclaration), + typeBindings: make(map[expr.UserType]*TypeDeclaration), + derivedTypes: make(map[DerivedTypeID]*TypeDeclaration), + unions: make(map[UnionTypeID]*unionDeclaration), } } @@ -701,7 +670,7 @@ func (p *GeneratedPackage) freeze() error { } dependent = waiting } - for declaration := range p.nameRecords { + for _, declaration := range p.names { for _, hash := range declaration.hashes { p.scope.bind(hash, declaration.final) } @@ -744,12 +713,24 @@ func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID return DerivedTypeID{origin: source.Origin(), kind: kind} } -// newMethodTypeIdentity constructs one of the four compiler-owned method roles. -func newMethodTypeIdentity(serviceName, methodName string, kind derivedTypeKind) MethodTypeIdentity { +// newMethodTypeIdentity records the declaration role and exact example owner +// of one wrapper created from a raw method object. +func newMethodTypeIdentity(methodName string, kind derivedTypeKind, exampleIdentity expr.ExampleIdentity) MethodTypeIdentity { if !kind.isMethodType() { panic("method type identity requires a method role") } - return MethodTypeIdentity{serviceName: serviceName, methodName: methodName, kind: kind} + return MethodTypeIdentity{ + name: Goify(methodName, true) + kind.methodSuffix(), + kind: kind, + exampleIdentity: exampleIdentity, + } +} + +// bind records the exact wrapper created during normalization. The pointer is +// provenance for this run and does not participate in stable names or IDs. +func (i MethodTypeIdentity) bind(source expr.UserType) MethodTypeIdentity { + i.origin = source.Origin() + return i } // methodSuffix returns the semantic suffix for one closed method wrapper kind. diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index ad52c22549..df8953d500 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -17,33 +17,60 @@ import ( type ( // testNameOrder supplies stable typed ordering facts to package-name tests. testNameOrder struct { - family string - value string + value string } -) -// PackageNameFamily returns the declaration family used for cross-family ordering. -func (o testNameOrder) PackageNameFamily() string { - return o.family -} + // alphaTestNameOrder and omegaTestNameOrder are unrelated order families + // whose comparers reject foreign values. + alphaTestNameOrder string + omegaTestNameOrder string + + // unstableTestNameOrder is invalid because its value may change after + // declaration collection. + unstableTestNameOrder []string + + // indirectTestNameOrder is invalid because it contains pointer identity. + indirectTestNameOrder struct { + value *string + } +) // ComparePackageName orders declarations from the same test family. func (o testNameOrder) ComparePackageName(other PackageNameOrder) int { return strings.Compare(o.value, other.(testNameOrder).value) } +// ComparePackageName orders declarations from the alpha test family. +func (o alphaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(alphaTestNameOrder))) +} + +// ComparePackageName orders declarations from the omega test family. +func (o omegaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(omegaTestNameOrder))) +} + +// ComparePackageName orders an invalid mutable test value. +func (o unstableTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(strings.Join(o, "/"), strings.Join(other.(unstableTestNameOrder), "/")) +} + +// ComparePackageName orders an invalid pointer-backed test value. +func (o indirectTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(*o.value, *other.(indirectTestNameOrder).value) +} + // TestNameDeclarationOwnsOnePackageNamespace verifies that exact and preferred // package symbols of every kind share one collision domain and one frozen name. func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") exact := NewExactName(NameType, "Build") - preferred := NewPreferredName(NameFunction, "Build", testNameOrder{family: "helper", value: "build"}) + preferred := NewPreferredName(NameFunction, "Build", testNameOrder{value: "build"}) require.NoError(t, types.DeclareName(exact)) require.NoError(t, types.DeclareName(exact)) require.NoError(t, types.DeclareName(preferred)) - require.Equal(t, "Build", exact.PreferredName()) require.Equal(t, NameType, exact.Kind()) require.Panics(t, func() { exact.Name() }) require.Panics(t, func() { preferred.Name() }) @@ -51,26 +78,102 @@ func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { require.NoError(t, generation.Freeze()) require.Equal(t, "Build", exact.Name()) require.Equal(t, "Build2", preferred.Name()) - require.Equal(t, "generated.local/gen/types", exact.PackagePath()) require.Equal(t, "Build", exact.Name()) for _, kind := range []PackageNameKind{NameType, NameFunction, NameConstant, NameVariable} { - collisionGeneration := NewGeneration("generated.local/gen", nil) - pkg := collisionGeneration.GeneratedPackage("generated.local/gen/types") + collisionGeneration := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, collisionGeneration, "generated.local/gen/types") require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Shared"))) err := pkg.DeclareName(NewExactName(kind, "Shared")) require.ErrorContains(t, err, "Shared") } } +// TestNameDeclarationRejectsUnownedPackageAccess verifies that only a package +// catalog can make internal declaration ownership available to typed records. +func TestNameDeclarationRejectsUnownedPackageAccess(t *testing.T) { + declaration := NewExactName(NameType, "Value") + require.Panics(t, func() { declaration.packagePath() }) +} + +// TestNameDeclarationRejectsEmptyPreferredName verifies that exact and +// suffixable declarations cannot mutate a package catalog without a Go name. +func TestNameDeclarationRejectsEmptyPreferredName(t *testing.T) { + tests := []struct { + name string + declaration *NameDeclaration + }{ + {"exact", NewExactName(NameType, "")}, + {"preferred", NewPreferredName(NameFunction, "", testNameOrder{value: "empty"})}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + + err := pkg.DeclareName(test.declaration) + require.ErrorContains(t, err, "name must not be empty") + require.Nil(t, test.declaration.owner) + require.Empty(t, pkg.names) + require.Empty(t, pkg.exactNames) + }) + } +} + +// TestNameDeclarationRejectsInvalidKind verifies that direct and dependent +// declarations cannot mutate a package catalog with an unknown category. +func TestNameDeclarationRejectsInvalidKind(t *testing.T) { + tests := []struct { + name string + declaration func(*testing.T, *GeneratedPackage) *NameDeclaration + wantNames int + }{ + { + "exact", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewExactName(0, "Value") + }, + 0, + }, + { + "preferred", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewPreferredName(NameVariable+1, "Value", testNameOrder{value: "invalid"}) + }, + 0, + }, + { + "dependent", + func(t *testing.T, pkg *GeneratedPackage) *NameDeclaration { + base := NewPreferredName(NameType, "Value", testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + return newDependentName(0, base, "New", "", testNameOrder{value: "dependent"}) + }, + 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := test.declaration(t, pkg) + + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "invalid package name kind") + require.Nil(t, declaration.owner) + require.Len(t, pkg.names, test.wantNames) + }) + } +} + // TestNameDeclarationPreferredOrder verifies that typed stable identity, not // discovery order, decides suffix ownership and rejects an indistinguishable tie. func TestNameDeclarationPreferredOrder(t *testing.T) { declare := func(reverse bool) (string, string) { - generation := NewGeneration("generated.local/gen", nil) - pkg := generation.GeneratedPackage("generated.local/gen/types") - first := NewPreferredName(NameFunction, "Build", testNameOrder{family: "helper", value: "a"}) - second := NewPreferredName(NameConstant, "Build", testNameOrder{family: "helper", value: "b"}) + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := NewPreferredName(NameFunction, "Build", testNameOrder{value: "a"}) + second := NewPreferredName(NameConstant, "Build", testNameOrder{value: "b"}) declarations := []*NameDeclaration{first, second} if reverse { declarations[0], declarations[1] = declarations[1], declarations[0] @@ -89,63 +192,204 @@ func TestNameDeclarationPreferredOrder(t *testing.T) { require.Equal(t, first, reversedFirst) require.Equal(t, second, reversedSecond) - generation := NewGeneration("generated.local/gen", nil) - pkg := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") require.NoError(t, pkg.DeclareName(NewPreferredName( NameFunction, "Build", - testNameOrder{family: "helper", value: "same"}, + testNameOrder{value: "same"}, ))) err := pkg.DeclareName(NewPreferredName( NameVariable, "Build", - testNameOrder{family: "helper", value: "same"}, + testNameOrder{value: "same"}, )) require.ErrorContains(t, err, "cannot deterministically order") } +// TestNameDeclarationOrdersConcreteFamilies verifies that unrelated named +// order types never receive each other's values and remain discovery-order independent. +func TestNameDeclarationOrdersConcreteFamilies(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + alpha := NewPreferredName(NameFunction, "Build", alphaTestNameOrder("same")) + omega := NewPreferredName(NameConstant, "Build", omegaTestNameOrder("same")) + declarations := []*NameDeclaration{alpha, omega} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return alpha.Name(), omega.Name() + } + + alpha, omega := declare(false) + reversedAlpha, reversedOmega := declare(true) + require.Equal(t, "Build", alpha) + require.Equal(t, "Build2", omega) + require.Equal(t, alpha, reversedAlpha) + require.Equal(t, omega, reversedOmega) +} + +// TestNameDeclarationRejectsUnstableOrderTypes verifies that collection +// returns deterministic errors instead of accepting mutable or ambiguous order values. +func TestNameDeclarationRejectsUnstableOrderTypes(t *testing.T) { + stable := testNameOrder{value: "stable"} + tests := []struct { + name string + order PackageNameOrder + }{ + {"nil", nil}, + {"pointer", &stable}, + {"unnamed", struct{ PackageNameOrder }{PackageNameOrder: stable}}, + {"slice", unstableTestNameOrder{"mutable"}}, + {"pointer field", indirectTestNameOrder{value: new(string)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := NewPreferredName(NameFunction, "Build", test.order) + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "stable concrete named value type") + }) + } +} + +// TestNameDeclarationRejectsDependentOrderTie verifies that dependency phase +// ordering cannot hide indistinguishable facts within one concrete family. +func TestNameDeclarationRejectsDependentOrderTie(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + base := NewPreferredName(NameType, "Value", testNameOrder{value: "base"}) + first := newDependentName(NameFunction, base, "New", "First", testNameOrder{value: "same"}) + second := newDependentName(NameFunction, base, "New", "Second", testNameOrder{value: "same"}) + require.NoError(t, pkg.DeclareName(base)) + require.NoError(t, pkg.DeclareName(first)) + err := pkg.DeclareName(second) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestNameDeclarationRejectsInvalidDependentOwners verifies that a dependent +// declaration derives its spelling only from a base already owned by its package. +func TestNameDeclarationRejectsInvalidDependentOwners(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/first") + second := mustClaimTestPackage(t, generation, "generated.local/gen/second") + unowned := NewPreferredName(NameType, "Value", testNameOrder{value: "unowned"}) + dependent := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "dependent"}) + err := first.DeclareName(dependent) + require.ErrorContains(t, err, "base declaration is not owned") + + require.NoError(t, first.DeclareName(unowned)) + crossPackage := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "cross-package"}) + err = second.DeclareName(crossPackage) + require.ErrorContains(t, err, "base declaration belongs to generated package") +} + // TestNameDeclarationRejectsMultipleOwners verifies that one canonical name // record cannot be rebound to another generated package. func TestNameDeclarationRejectsMultipleOwners(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) declaration := NewExactName(NameType, "Value") - require.NoError(t, generation.GeneratedPackage("generated.local/gen/first").DeclareName(declaration)) - err := generation.GeneratedPackage("generated.local/gen/second").DeclareName(declaration) + require.NoError(t, mustClaimTestPackage(t, generation, "generated.local/gen/first").DeclareName(declaration)) + err := mustClaimTestPackage(t, generation, "generated.local/gen/second").DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestNameDeclarationRejectsSameImportAcrossGenerations verifies that +// canonical import spelling does not substitute for exact package ownership. +func TestNameDeclarationRejectsSameImportAcrossGenerations(t *testing.T) { + declaration := NewExactName(NameType, "Value") + first := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + second := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + require.NoError(t, first.DeclareName(declaration)) + err := second.DeclareName(declaration) require.ErrorContains(t, err, "already belongs") } // TestGeneratedOutputPathRejectsNormalizedCollisions verifies that equivalent // import spellings cannot make two requested package identities share output. func TestGeneratedOutputPathRejectsNormalizedCollisions(t *testing.T) { - generation := NewGeneration("generated.local/root/../gen", nil) - first := generation.GeneratedPackage("generated.local/gen/types") - second := generation.GeneratedPackage("generated.local/gen/values/../types") - require.Same(t, first, second) + generation := mustTestGeneration(t, "generated.local/root/../gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") require.Equal(t, "generated.local/gen", generation.GenPkg()) require.Equal(t, "generated.local/gen/types", first.ImportPath()) require.Equal(t, "gen/types", first.OutputDirectory()) - err := generation.Freeze() - require.ErrorContains(t, err, "normalize") - require.ErrorContains(t, err, "generated.local/gen/types") + _, err := generation.ClaimPackage("generated.local/gen/values/../types") + require.EqualError(t, err, + `generated package paths "generated.local/gen/types" and "generated.local/gen/values/../types" normalize to import path "generated.local/gen/types"`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/types")) + require.NoError(t, generation.Freeze()) +} + +// TestGeneratedOutputPathEmitsCanonicalImport verifies that a package keeps +// its exact planner claim for reuse while generated source sees a clean path. +func TestGeneratedOutputPathEmitsCanonicalImport(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values/../types") + require.Same(t, pkg, mustClaimTestPackage(t, generation, "generated.local/gen/values/../types")) + require.Equal(t, "generated.local/gen/types", pkg.ImportPath()) + require.Equal(t, "gen/types", pkg.OutputDirectory()) + require.NoError(t, generation.Freeze()) } // TestGeneratedOutputPathRejectsLateNormalizedIdentity verifies that freeze // does not let a new raw package identity reach an existing canonical package. func TestGeneratedOutputPathRejectsLateNormalizedIdentity(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - first := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") require.NoError(t, generation.Freeze()) - require.Same(t, first, generation.GeneratedPackage("generated.local/gen/types")) - require.Panics(t, func() { - generation.GeneratedPackage("generated.local/gen/values/../types") - }) + require.Same(t, first, generation.Package("generated.local/gen/types")) + _, err := generation.ClaimPackage("generated.local/gen/types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") + _, err = generation.ClaimPackage("generated.local/gen/values/../types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") +} + +// TestGeneratedOutputPathRejectsPortableDirectoryCollision verifies that two +// import identities cannot claim the same directory on a case-insensitive host. +func TestGeneratedOutputPathRejectsPortableDirectoryCollision(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/Foo") + _, err := generation.ClaimPackage("generated.local/gen/foo") + require.EqualError(t, err, + `generated package paths "generated.local/gen/Foo" and "generated.local/gen/foo" resolve to output directory "gen/foo" on a case-insensitive filesystem`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/Foo")) +} + +// TestGeneratedOutputPathRejectsBackslashes verifies that invalid Go import +// separators are rejected instead of translated into another package identity. +func TestGeneratedOutputPathRejectsBackslashes(t *testing.T) { + _, err := NewGeneration(`generated.local\gen`, nil) + require.ErrorContains(t, err, "backslash") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + _, err = generation.ClaimPackage(`generated.local\gen\types`) + require.ErrorContains(t, err, "contains a backslash") +} + +// TestGenerationRejectsImplicitLocalRoots verifies that only the exact local +// output sentinels are accepted as non-module generation roots. +func TestGenerationRejectsImplicitLocalRoots(t *testing.T) { + for _, genpkg := range []string{"", "./", "//"} { + _, err := NewGeneration(genpkg, nil) + require.Error(t, err) + } + for _, genpkg := range []string{".", "/"} { + _, err := NewGeneration(genpkg, nil) + require.NoError(t, err) + } } // TestGeneratedTypeFamiliesContainCanonicalNames verifies that existing type // and union records expose the package-owned records used for rendering. func TestGeneratedTypeFamiliesContainCanonicalNames(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - pkg := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") user, err := pkg.DeclareUserType(generatedUserType("Widget", "widget")) require.NoError(t, err) union, alias := generatedUnionWithBranch("Value", "text", "text", expr.String) @@ -188,24 +432,24 @@ func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { }) }) - generation := NewGeneration("generated.local/gen", []eval.Root{root}) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") _, err := types.DeclareUserType(first) require.NoError(t, err) _, err = types.DeclareUserType(second) - require.ErrorContains(t, err, "foo-bar") require.ErrorContains(t, err, "foo_bar") require.ErrorContains(t, err, "FooBar") + require.ErrorContains(t, err, "already declared by exact type") } // TestGenerationOwnsPackageRecords verifies that one generation returns one // stable package record and scope for each output path. func TestGenerationOwnsPackageRecords(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) - first := generation.GeneratedPackage("generated.local/gen/types") - second := generation.GeneratedPackage("generated.local/gen/types") - other := generation.GeneratedPackage("generated.local/gen/other") + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + second := mustClaimTestPackage(t, generation, "generated.local/gen/types") + other := mustClaimTestPackage(t, generation, "generated.local/gen/other") require.Same(t, first, second) require.Panics(t, func() { first.Scope() @@ -223,7 +467,7 @@ func TestGenerationCopiesConstructionState(t *testing.T) { first := RunDSL(t, func() {}) second := RunDSL(t, func() {}) roots := []eval.Root{first} - generation := NewGeneration("generated.local/gen", roots) + generation := mustTestGeneration(t, "generated.local/gen", roots) roots[0] = second returnedRoots := generation.Roots() @@ -244,8 +488,8 @@ func TestGenerationCopiesConstructionState(t *testing.T) { // TestGeneratedPackageUserTypes verifies that a generated package records one // declaration per user type and that lookups do not reserve names. func TestGeneratedPackageUserTypes(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") widget := generatedUserType("Widget", "widget") missing := generatedUserType("Missing", "missing") @@ -275,8 +519,8 @@ func TestGeneratedPackageUserTypes(t *testing.T) { // TestGeneratedPackageExactUserTypesDoNotMerge verifies that structural // equality does not weaken the exact-name contract for DSL declarations. func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") first := generatedUserType("ValueText", "first") equivalent := generatedUserType("ValueText", "second") @@ -291,8 +535,8 @@ func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { // compiler copies use their declaration origin instead of one transient copy // pointer as package identity. func TestGeneratedPackageUserTypeCopiesShareDeclaration(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") original := generatedUserType("ValueText", "value-text") copy := original.Dup(expr.DupAtt(original.Attribute())) @@ -308,12 +552,32 @@ func TestGeneratedPackageUserTypeCopiesShareDeclaration(t *testing.T) { require.Same(t, first, lookedUp) } +// TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames verifies that +// one origin may repeat an equivalent Go spelling but not change declarations. +func TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + userType := generatedUserType("value-text", "value-text") + expression := userType.(*expr.UserTypeExpr) + first, err := types.DeclareUserType(userType) + require.NoError(t, err) + + expression.TypeName = "value_text" + second, err := types.DeclareUserType(userType) + require.NoError(t, err) + require.Same(t, first, second) + + expression.TypeName = "different" + _, err = types.DeclareUserType(userType) + require.ErrorContains(t, err, "cannot declare both") +} + // TestGeneratedPackageDerivedTypesUseTypedSourceIdentity verifies that view // declarations rebuilt in the render phase select the records planned from // the same exact source declaration. func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - views := generation.GeneratedPackage("generated.local/gen/service/views") + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") source := generatedUserType("Value", "value") copy := source.Dup(expr.DupAtt(source.Attribute())) projectedID := NewProjectedTypeID(source) @@ -336,12 +600,29 @@ func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { require.Equal(t, "Value", viewed.Name()) } +// TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames verifies that +// one typed identity accepts equivalent Go spellings but not another name. +func TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + identity := NewProjectedTypeID(generatedUserType("Value", "value")) + first, err := views.DeclareDerivedType(identity, "value-view") + require.NoError(t, err) + + second, err := views.DeclareDerivedType(identity, "value_view") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = views.DeclareDerivedType(identity, "different") + require.ErrorContains(t, err, "cannot declare both") +} + // TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder verifies that stable // semantic source identifiers, not traversal order, decide suffix ownership. func TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder(t *testing.T) { declare := func(reverse bool) (string, string) { - generation := NewGeneration("generated.local/gen", nil) - views := generation.GeneratedPackage("generated.local/gen/service/views") + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") first := generatedUserType("Value", "first") second := generatedUserType("Value", "second") ids := []DerivedTypeID{NewProjectedTypeID(first), NewProjectedTypeID(second)} @@ -370,8 +651,8 @@ func TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder(t *testing.T) { // origins cannot rely on unstable expression shape to break an otherwise // identical semantic ordering tuple. func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - views := generation.GeneratedPackage("generated.local/gen/service/views") + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") first := generatedUserTypeOf("Value", "same", expr.String) second := generatedUserTypeOf("Value", "same", expr.Int) @@ -384,8 +665,8 @@ func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { // TestGeneratedPackageUnionBranchesShareDeclaration verifies that separately // allocated copies of one structural union reuse their generated branch alias. func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) @@ -410,8 +691,8 @@ func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { // TestGeneratedPackageUnionBranchesAreIsolatedByUnion verifies that branch // aliases from different emitted union definitions never collapse together. func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) secondUnion.TypeKey = "kind" @@ -437,8 +718,8 @@ func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { // constants and constructors use package-owned frozen names instead of // colliding with exact DSL type declarations. func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") for _, name := range []string{"ValueKindText", "NewValueText"} { _, err := types.DeclareUserType(generatedUserType(name, name)) require.NoError(t, err) @@ -463,8 +744,8 @@ func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { // equivalent unions idempotent while different unions with the same base name // receive distinct declarations. func TestGeneratedPackageUnions(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") first := generatedUnion("Value", "type", "value") equivalent := generatedUnion("Value", "type", "value") different := generatedUnion("Value", "kind", "data") @@ -495,8 +776,8 @@ func TestGeneratedPackageUnions(t *testing.T) { differentDeclaration.KindName(), }) - reversedGeneration := NewGeneration("generated.local/gen", nil) - reversedTypes := reversedGeneration.GeneratedPackage("generated.local/gen/types") + reversedGeneration := mustTestGeneration(t, "generated.local/gen", nil) + reversedTypes := mustClaimTestPackage(t, reversedGeneration, "generated.local/gen/types") reversedDifferent, err := reversedTypes.DeclareUnion(generatedUnion("Value", "kind", "data")) require.NoError(t, err) reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("Value", "type", "value")) @@ -512,8 +793,8 @@ func TestGeneratedPackageUnions(t *testing.T) { func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { for _, unionFirst := range []bool{true, false} { t.Run(fmt.Sprintf("union first %t", unionFirst), func(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") userType := generatedUserType("Value", "value") kindUserType := generatedUserType("ValueKind", "value-kind") union := generatedUnion("Value", "type", "value") @@ -564,8 +845,8 @@ func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { // TestGeneratedPackageLookupAcrossFreeze verifies that freeze keeps existing // declarations readable and rejects every later declaration attempt. func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") widget := generatedUserType("Widget", "widget") union := generatedUnion("Value", "type", "value") userDeclaration, err := types.DeclareUserType(widget) @@ -603,10 +884,20 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { func TestGeneratedPackageRejectsConflictingOriginBindings(t *testing.T) { for _, derivedFirst := range []bool{false, true} { t.Run(fmt.Sprintf("derived first %t", derivedFirst), func(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/values") - wrapper := generatedUserType("ReadPayload", "Values#ReadPayload") - identity := NewMethodPayloadIdentity("Values", "Read") + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/values") + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity, ok := generation.NormalizedMethodType(wrapper) + require.True(t, ok) if derivedFirst { _, _, err := types.DeclareMethodType(identity, wrapper) @@ -624,9 +915,9 @@ func TestGeneratedPackageRejectsConflictingOriginBindings(t *testing.T) { } } -// TestMethodTypeIdentityMatchesNormalizedWrapper verifies that normalization +// TestGenerationOwnsNormalizedWrapper verifies that normalization // and declaration planning share the same closed method-role identity. -func TestMethodTypeIdentityMatchesNormalizedWrapper(t *testing.T) { +func TestGenerationOwnsNormalizedWrapper(t *testing.T) { root := RunDSL(t, func() { dsl.Service("Values", func() { dsl.Method("Read", func() { @@ -636,21 +927,177 @@ func TestMethodTypeIdentityMatchesNormalizedWrapper(t *testing.T) { }) }) }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) - identity := NewMethodPayloadIdentity("Values", "Read") + identity, ok := generation.NormalizedMethodType(wrapper) + require.True(t, ok) require.Equal(t, "ReadPayload", identity.Name()) - require.Equal(t, "Values#ReadPayload", identity.UID()) - require.True(t, identity.Matches(wrapper)) + require.Equal(t, wrapper.ID(), identity.UID()) +} + +// TestGenerationAssignsExactMethodOwners verifies that every raw object role +// becomes a generated wrapper whose declaration and example identities agree. +func TestGenerationAssignsExactMethodOwners(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + method := &expr.MethodExpr{ + Name: "Stream", + Service: service, + Payload: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingPayload: &expr.AttributeExpr{Type: &expr.Object{}}, + Result: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingResult: &expr.AttributeExpr{Type: &expr.Object{}}, + } + service.Methods = []*expr.MethodExpr{method} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service}} + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + cases := []struct { + name string + attribute *expr.AttributeExpr + expected expr.ExampleIdentity + }{ + {"payload", method.Payload, expr.MethodPayloadExampleIdentity(method)}, + {"streaming payload", method.StreamingPayload, expr.MethodStreamingPayloadExampleIdentity(method)}, + {"result", method.Result, expr.MethodResultExampleIdentity(method)}, + {"streaming result", method.StreamingResult, expr.MethodStreamingResultExampleIdentity(method)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapper := tc.attribute.Type.(expr.UserType) + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(wrapper) + require.True(t, generated) + require.Equal(t, tc.expected, exampleIdentity) + declarationIdentity, normalized := generation.NormalizedMethodType(wrapper) + require.True(t, normalized) + require.Equal(t, wrapper.ID(), declarationIdentity.UID()) + }) + } +} + +// TestMethodTypeIdentityPreservesRawOwner proves semantic wrapper identity does +// not collapse distinct DSL names that share one preferred Go spelling. +func TestMethodTypeIdentityPreservesRawOwner(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "foo-bar", Service: &expr.ServiceExpr{Name: "Values"}} + secondMethod := &expr.MethodExpr{Name: "foo_bar", Service: &expr.ServiceExpr{Name: "Values"}} + cases := []struct { + name string + kind derivedTypeKind + first expr.ExampleIdentity + second expr.ExampleIdentity + }{ + {"payload", methodPayloadTypeKind, expr.MethodPayloadExampleIdentity(firstMethod), expr.MethodPayloadExampleIdentity(secondMethod)}, + {"streaming payload", methodStreamingPayloadTypeKind, expr.MethodStreamingPayloadExampleIdentity(firstMethod), expr.MethodStreamingPayloadExampleIdentity(secondMethod)}, + {"result", methodResultTypeKind, expr.MethodResultExampleIdentity(firstMethod), expr.MethodResultExampleIdentity(secondMethod)}, + {"streaming result", methodStreamingResultTypeKind, expr.MethodStreamingResultExampleIdentity(firstMethod), expr.MethodStreamingResultExampleIdentity(secondMethod)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + first := newMethodTypeIdentity(firstMethod.Name, tc.kind, tc.first) + second := newMethodTypeIdentity(secondMethod.Name, tc.kind, tc.second) + + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.UID(), second.UID()) + require.Equal(t, first.UID(), newMethodTypeIdentity(firstMethod.Name, tc.kind, tc.first).UID()) + }) + } +} + +// TestGenerationPreservesRawMethodOwner proves synthesized wrappers retain +// the raw method identity even when their preferred generated names coincide. +func TestGenerationPreservesRawMethodOwner(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(func() { + dsl.Attribute("first", dsl.String) + }) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(func() { + dsl.Attribute("second", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + first := root.Service("Values").Method("foo-bar").Payload.Type.(expr.UserType) + second := root.Service("Values").Method("foo_bar").Payload.Type.(expr.UserType) + firstIdentity, firstGenerated := generation.NormalizedMethodType(first) + secondIdentity, secondGenerated := generation.NormalizedMethodType(second) + + require.True(t, firstGenerated) + require.True(t, secondGenerated) + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.ID(), second.ID()) + require.Equal(t, first.ID(), firstIdentity.UID()) + require.Equal(t, second.ID(), secondIdentity.UID()) +} + +// TestGenerationOwnsNormalizedMethodProvenance proves only wrappers created by +// this generation are classified as compiler-owned method types. Authored text +// that equals a synthesized UID is still an authored declaration. +func TestGenerationOwnsNormalizedMethodProvenance(t *testing.T) { + authoredMethod := &expr.MethodExpr{Name: "Authored", Service: &expr.ServiceExpr{Name: "Values"}} + authoredUID := "generated:" + expr.MethodPayloadExampleIdentity(authoredMethod).Seed() + root := RunDSL(t, func() { + authored := dsl.Type(authoredUID, func() { + dsl.Attribute("authored", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Authored", func() { + dsl.Payload(authored) + }) + dsl.Method("Raw", func() { + dsl.Payload(func() { + dsl.Attribute("raw", dsl.String) + }) + }) + }) + }) + generation, err := NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + authored := root.Service("Values").Method("Authored").Payload.Type.(expr.UserType) + _, ok := generation.NormalizedMethodType(authored) + require.False(t, ok) + raw := root.Service("Values").Method("Raw").Payload.Type.(expr.UserType) + rawIdentity, ok := generation.NormalizedMethodType(raw) + require.True(t, ok) + require.Equal(t, raw.ID(), rawIdentity.UID()) +} + +// TestGenerationRecoversNormalizedMethodProvenance verifies that constructing +// another generation over the same evaluated root recognizes the exact typed +// wrapper instead of parsing its generated name or ID. +func TestGenerationRecoversNormalizedMethodProvenance(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + first := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + firstIdentity, ok := first.NormalizedMethodType(wrapper) + require.True(t, ok) + + second := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + secondIdentity, ok := second.NormalizedMethodType(wrapper) + require.True(t, ok) + require.Equal(t, firstIdentity.UID(), secondIdentity.UID()) } // TestGenerationCatalogsAreIsolated verifies that standalone generation runs // do not share declaration records or name reservations. func TestGenerationCatalogsAreIsolated(t *testing.T) { - firstGeneration := NewGeneration("generated.local/gen", nil) - first := firstGeneration.GeneratedPackage("generated.local/gen/types") - secondGeneration := NewGeneration("generated.local/gen", nil) - second := secondGeneration.GeneratedPackage("generated.local/gen/types") + firstGeneration := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, firstGeneration, "generated.local/gen/types") + secondGeneration := mustTestGeneration(t, "generated.local/gen", nil) + second := mustClaimTestPackage(t, secondGeneration, "generated.local/gen/types") firstUnion := generatedUnion("Value", "type", "value") secondUnion := generatedUnion("Value", "type", "value") @@ -701,3 +1148,21 @@ func generatedUnionWithBranch(unionName, branchName, aliasID string, dataType ex }}, }, alias } + +// mustTestGeneration creates a generation for tests whose package root is +// known to be valid. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *Generation { + t.Helper() + generation, err := NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims a package for tests whose planner path is known +// to be valid and unique. +func mustClaimTestPackage(t *testing.T, generation *Generation, path string) *GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} diff --git a/codegen/generation.go b/codegen/generation.go index acc394cc50..e2e473007c 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -9,36 +9,58 @@ import ( "path/filepath" "strings" + "golang.org/x/mod/module" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) type ( - // Generation owns the evaluated design roots and generated-package naming + // Generation owns the normalized design roots and generated-package naming // catalogs for one standalone code generation run. Generation struct { - genpkg string - roots []eval.Root - packages map[string]*GeneratedPackage - importPlan *importAliasPlan - imports map[string]importAliasBinding - pathInputs map[string]string - pathErr error - frozen bool + genpkg string + roots []eval.Root + packages map[string]*GeneratedPackage + importOwners map[string]*GeneratedPackage + outputOwners map[string]*GeneratedPackage + importPlan *importAliasPlan + imports map[string]importAliasBinding + methodTypes map[expr.UserType]MethodTypeIdentity + frozen bool } ) -// NewGeneration creates an independent generation catalog for roots. -func NewGeneration(genpkg string, roots []eval.Root) *Generation { - genpkg = path.Clean(genpkg) +// NewGeneration normalizes raw method objects, records their exact generated +// wrappers, creates an independent generation catalog, and rejects an invalid +// generated module import path. Construction has exclusive preparation access +// to the supplied evaluated expression graphs; callers must not concurrently +// construct another generation over the same graphs. +func NewGeneration(genpkg string, roots []eval.Root) (*Generation, error) { + canonicalGenPkg, err := canonicalGenerationRoot(genpkg) + if err != nil { + return nil, err + } + ownedRoots := append([]eval.Root(nil), roots...) return &Generation{ - genpkg: genpkg, - roots: append([]eval.Root(nil), roots...), - packages: make(map[string]*GeneratedPackage), + genpkg: canonicalGenPkg, + roots: ownedRoots, + packages: make(map[string]*GeneratedPackage), + importOwners: make(map[string]*GeneratedPackage), + outputOwners: make(map[string]*GeneratedPackage), + methodTypes: normalizeRoots(ownedRoots), importPlan: &importAliasPlan{ candidates: make(map[string]*importAliasCandidate), }, - pathInputs: make(map[string]string), - } + }, nil +} + +// NormalizedMethodType returns the exact compiler-owned method role recorded +// when this generation wrapped source. Authored user types are never present, +// regardless of their name or semantic ID. +func (g *Generation) NormalizedMethodType(source expr.UserType) (MethodTypeIdentity, bool) { + identity, ok := g.methodTypes[source.Origin()] + return identity, ok } // GenPkg returns the import path of the generated module root. @@ -46,44 +68,67 @@ func (g *Generation) GenPkg() string { return g.genpkg } -// Roots returns a copy of the evaluated DSL roots participating in the run. +// Roots returns a copy of the root slice participating in the run. The +// expression graphs themselves remain the prepared objects owned by the run. func (g *Generation) Roots() []eval.Root { return append([]eval.Root(nil), g.roots...) } -// GeneratedPackage returns the naming catalog for path, creating it before -// the generation is frozen. It panics if path was not planned before freeze. -func (g *Generation) GeneratedPackage(path string) *GeneratedPackage { - rawPath := path - path = cleanImportPath(path) - if existing, ok := g.pathInputs[path]; ok { - if existing == rawPath { - return g.packages[path] - } - err := fmt.Errorf( - "generated package paths %q and %q normalize to %q", - existing, - rawPath, +// ClaimPackage claims the exact planner-supplied import path and returns its +// package catalog. Repeating the exact claim is idempotent; a second claim for +// the same canonical import or portable output directory is rejected. +func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("generated package %q cannot be claimed after generation freeze", path) + } + if generatedPackage, ok := g.packages[path]; ok { + return generatedPackage, nil + } + canonicalPath, err := canonicalGeneratedPackagePath(g.genpkg, path) + if err != nil { + return nil, err + } + if owner, ok := g.importOwners[canonicalPath]; ok { + return nil, fmt.Errorf( + "generated package paths %q and %q normalize to import path %q", + owner.claim, path, + canonicalPath, ) - if g.frozen { - panic(err) - } - if g.pathErr == nil { - g.pathErr = err - } - return g.packages[path] } - if g.frozen { - panic(fmt.Sprintf("generated package %q requested after generation freeze", path)) + outputDir, err := generatedOutputDirectory(g.genpkg, canonicalPath) + if err != nil { + return nil, err } - outputDir, err := generatedOutputDirectory(g.genpkg, path) - if err != nil && g.pathErr == nil { - g.pathErr = err + for existingDir, owner := range g.outputOwners { + if strings.EqualFold(existingDir, outputDir) { + return nil, fmt.Errorf( + "generated package paths %q and %q resolve to output directory %q on a case-insensitive filesystem", + owner.claim, + path, + outputDir, + ) + } } - generatedPackage := newGeneratedPackage(path, outputDir) + generatedPackage := newGeneratedPackage(path, canonicalPath, outputDir) g.packages[path] = generatedPackage - g.pathInputs[path] = rawPath + g.importOwners[canonicalPath] = generatedPackage + g.outputOwners[outputDir] = generatedPackage + return generatedPackage, nil +} + +// Package returns the package already claimed for canonicalPath. It panics +// when a renderer supplies a noncanonical or unplanned path because planning +// must establish every output package before freeze. +func (g *Generation) Package(canonicalPath string) *GeneratedPackage { + cleaned, err := canonicalGeneratedPackagePath(g.genpkg, canonicalPath) + if err != nil || cleaned != canonicalPath { + panic(fmt.Sprintf("generated package lookup path %q is not canonical", canonicalPath)) + } + generatedPackage, ok := g.importOwners[canonicalPath] + if !ok { + panic(fmt.Sprintf("generated package %q was not claimed during planning", canonicalPath)) + } return generatedPackage } @@ -94,9 +139,6 @@ func (g *Generation) Freeze() error { if g.frozen { return nil } - if g.pathErr != nil { - return g.pathErr - } if err := g.freezeImports(); err != nil { return err } @@ -120,9 +162,54 @@ func (p *GeneratedPackage) OutputDirectory() string { return p.outputDir } -// cleanImportPath canonicalizes slash-based Go import paths. -func cleanImportPath(importPath string) string { - return path.Clean(strings.ReplaceAll(importPath, "\\", "/")) +// canonicalGenerationRoot validates the module import prefix used by one run. +// Dot and slash are explicit local-output sentinels used by generator tests. +func canonicalGenerationRoot(genpkg string) (string, error) { + if genpkg == "." || genpkg == "/" { + return genpkg, nil + } + canonical, err := cleanImportPath("generated package root", genpkg) + if err != nil { + return "", err + } + if canonical == "." || canonical == "/" { + return "", fmt.Errorf("generated package root %q is invalid", genpkg) + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("generated package root %q is invalid: %w", genpkg, err) + } + return canonical, nil +} + +// canonicalGeneratedPackagePath validates one package import claimed beneath +// genpkg and returns the cleaned spelling emitted by generated source. +func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { + canonical, err := cleanImportPath("generated package path", importPath) + if err != nil { + return "", err + } + validated := canonical + if genpkg == "/" { + validated = strings.TrimPrefix(canonical, "/") + if validated == "" { + return canonical, nil + } + } else if genpkg == "." && canonical == "." { + return canonical, nil + } + if err := module.CheckImportPath(validated); err != nil { + return "", fmt.Errorf("generated package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// cleanImportPath rejects filesystem separators in Go import identities and +// preserves the raw spelling for diagnostics before cleaning dot segments. +func cleanImportPath(label, importPath string) (string, error) { + if strings.Contains(importPath, "\\") { + return "", fmt.Errorf("%s %q contains a backslash", label, importPath) + } + return path.Clean(importPath), nil } // generatedOutputDirectory maps a generated import path to its directory diff --git a/codegen/generator/design_snapshot.go b/codegen/generator/design_snapshot.go new file mode 100644 index 0000000000..91e9aeb0a1 --- /dev/null +++ b/codegen/generator/design_snapshot.go @@ -0,0 +1,519 @@ +// This file records prepared design state so generation can audit persistent +// semantic mutations after callbacks and completed file rendering. Snapshot +// entries retain pointer topology and use deterministic map ordering so the +// first changed path can be reported. +package generator + +import ( + "fmt" + "math" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // designSnapshot is the immutable prepared-state reference for one run. + designSnapshot struct { + states []designState + references []reflect.Value + } + + // designState records one scalar, container, or reference reached at path. + designState struct { + path string + typ reflect.Type + value string + } + + // designSnapshotter walks one evaluated graph while preserving aliases and + // terminating cycles. + designSnapshotter struct { + states []designState + references []reflect.Value + visited map[designVisit]struct{} + } + + // designVisit identifies one reference node. Slice capacity distinguishes + // overlapping views whose reachable backing-array ranges differ. + designVisit struct { + typ reflect.Type + kind reflect.Kind + ptr uintptr + extent int + } + + // mapSnapshotEntry retains a map pair after deterministic ordering. + mapSnapshotEntry struct { + key reflect.Value + value reflect.Value + keyOrder mapOrderValue + order mapOrderValue + } + + // mapOrderValue is a structured shallow value used only for deterministic + // map traversal. References are compared by identity; values by exact bits. + mapOrderValue struct { + typ reflect.Type + kind reflect.Kind + isNil bool + boolean bool + integer int64 + unsigned uint64 + text string + reference uintptr + length int + capacity int + children []mapOrderValue + } +) + +var ( + dslFuncType = reflect.TypeFor[eval.DSLFunc]() + typeMapType = reflect.TypeFor[expr.TypeMap]() +) + +// snapshotPreparedDesign captures every value reachable from roots after +// preparation and normalization have completed. +func snapshotPreparedDesign(roots []eval.Root) (*designSnapshot, error) { + snapshotter := &designSnapshotter{visited: make(map[designVisit]struct{})} + for i, root := range roots { + if err := snapshotter.appendValue(fmt.Sprintf("roots[%d]", i), reflect.ValueOf(root)); err != nil { + return nil, err + } + } + return &designSnapshot{ + states: snapshotter.states, + references: snapshotter.references, + }, nil +} + +// changedPath returns the first deterministic semantic path whose value or +// reference topology differs from the prepared snapshot. +func orderedMapEntries(value reflect.Value) ([]mapSnapshotEntry, error) { + entries := make([]mapSnapshotEntry, 0, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := iterator.Key() + mapValue := iterator.Value() + keyOrder, err := mapValueOrder(key) + if err != nil { + return nil, err + } + valueOrder, err := mapValueOrder(mapValue) + if err != nil { + return nil, err + } + entries = append(entries, mapSnapshotEntry{ + key: key, + value: mapValue, + keyOrder: keyOrder, + order: valueOrder, + }) + } + if err := validateMapOrderTypes(entries); err != nil { + return nil, err + } + slices.SortFunc(entries, func(left, right mapSnapshotEntry) int { + if compared := compareMapOrderValue(left.keyOrder, right.keyOrder); compared != 0 { + return compared + } + return compareMapOrderValue(left.order, right.order) + }) + return entries, nil +} + +// validateMapOrderTypes rejects reflected types that have no stable ordering. +// This can only arise when separately constructed dynamic types have the same +// printed identity; silently tying them would expose randomized map iteration. +func validateMapOrderTypes(entries []mapSnapshotEntry) error { + for i := range entries { + for j := i + 1; j < len(entries); j++ { + if err := validateMapOrderType(entries[i].keyOrder, entries[j].keyOrder); err != nil { + return err + } + if err := validateMapOrderType(entries[i].order, entries[j].order); err != nil { + return err + } + } + } + return nil +} + +// validateMapOrderType checks exact reflected type identity recursively, +// including concrete values stored below interface map keys and values. +func validateMapOrderType(left, right mapOrderValue) error { + if left.typ != right.typ && stableTypeName(left.typ) == stableTypeName(right.typ) { + return fmt.Errorf("cannot deterministically order distinct reflected map types %q", stableTypeName(left.typ)) + } + common := min(len(left.children), len(right.children)) + for i := range common { + if err := validateMapOrderType(left.children[i], right.children[i]); err != nil { + return err + } + } + return nil +} + +// mapValueOrder encodes comparable map keys and shallow value identity without +// traversing mutable targets. It is used only to make map traversal stable. +func mapValueOrder(value reflect.Value) (mapOrderValue, error) { + if !value.IsValid() { + return mapOrderValue{}, nil + } + order := mapOrderValue{typ: value.Type(), kind: value.Kind()} + switch value.Kind() { + case reflect.Bool: + order.boolean = value.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + order.integer = value.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + order.unsigned = value.Uint() + case reflect.Float32: + order.unsigned = uint64(math.Float32bits(float32(value.Float()))) + case reflect.Float64: + order.unsigned = math.Float64bits(value.Float()) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + order.children = []mapOrderValue{ + {unsigned: uint64(math.Float32bits(real(complexValue)))}, + {unsigned: uint64(math.Float32bits(imag(complexValue)))}, + } + case reflect.Complex128: + complexValue := value.Complex() + order.children = []mapOrderValue{ + {unsigned: math.Float64bits(real(complexValue))}, + {unsigned: math.Float64bits(imag(complexValue))}, + } + case reflect.String: + order.text = value.String() + case reflect.Interface: + if value.IsNil() { + order.isNil = true + break + } + inner, err := mapValueOrder(value.Elem()) + if err != nil { + return mapOrderValue{}, err + } + order.children = []mapOrderValue{inner} + case reflect.Pointer, reflect.Chan: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.UnsafePointer: + order.reference = value.Pointer() + case reflect.Slice: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + order.capacity = value.Cap() + case reflect.Map: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + case reflect.Func: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.Struct: + order.children = make([]mapOrderValue, value.NumField()) + for i := range value.NumField() { + field, err := mapValueOrder(value.Field(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = field + } + case reflect.Array: + order.children = make([]mapOrderValue, value.Len()) + for i := range value.Len() { + element, err := mapValueOrder(value.Index(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = element + } + default: + return mapOrderValue{}, fmt.Errorf("cannot order map %s value", value.Kind()) + } + return order, nil +} + +// compareMapOrderValue provides a total order over structured reflected values. +func compareMapOrderValue(left, right mapOrderValue) int { + if left.typ != right.typ { + return strings.Compare(stableTypeName(left.typ), stableTypeName(right.typ)) + } + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + if left.isNil != right.isNil { + if left.isNil { + return -1 + } + return 1 + } + if left.boolean != right.boolean { + if !left.boolean { + return -1 + } + return 1 + } + if left.integer != right.integer { + if left.integer < right.integer { + return -1 + } + return 1 + } + if left.unsigned != right.unsigned { + if left.unsigned < right.unsigned { + return -1 + } + return 1 + } + if compared := strings.Compare(left.text, right.text); compared != 0 { + return compared + } + if left.reference != right.reference { + if left.reference < right.reference { + return -1 + } + return 1 + } + if left.length != right.length { + return left.length - right.length + } + if left.capacity != right.capacity { + return left.capacity - right.capacity + } + common := min(len(left.children), len(right.children)) + for i := range common { + if compared := compareMapOrderValue(left.children[i], right.children[i]); compared != 0 { + return compared + } + } + return len(left.children) - len(right.children) +} + +// stableTypeName is used only when distinct reflected types need map order. +// Exact type identity remains in the snapshot state itself. +func stableTypeName(typ reflect.Type) string { + if typ == nil { + return "" + } + return typ.PkgPath() + ":" + typ.String() +} + +// mapEntryPath returns a readable diagnostic path without using its label for ordering. +func mapEntryPath(path string, index int, key reflect.Value) string { + if key.Kind() == reflect.String { + return path + "[" + strconv.Quote(key.String()) + "]" + } + return fmt.Sprintf("%s{%d}", path, index) +} + +// formatPointer renders reference identity without treating it as order data. +func formatPointer(pointer uintptr) string { + return "0x" + strconv.FormatUint(uint64(pointer), 16) +} +func (s *designSnapshot) changedPath(roots []eval.Root) (string, error) { + defer runtime.KeepAlive(s.references) + + current, err := snapshotPreparedDesign(roots) + if err != nil { + return "", err + } + common := min(len(s.states), len(current.states)) + for i := range common { + if s.states[i] != current.states[i] { + return current.states[i].path, nil + } + } + if len(s.states) > common { + return s.states[common].path, nil + } + if len(current.states) > common { + return current.states[common].path, nil + } + return "", nil +} + +// appendValue records value and recursively records all state it can reach. +func (s *designSnapshotter) appendValue(path string, value reflect.Value) error { + if !value.IsValid() { + s.append(path, nil, "invalid") + return nil + } + typ := value.Type() + switch value.Kind() { + case reflect.Bool: + s.append(path, typ, strconv.FormatBool(value.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s.append(path, typ, strconv.FormatInt(value.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s.append(path, typ, strconv.FormatUint(value.Uint(), 10)) + case reflect.Float32: + s.append(path, typ, strconv.FormatUint(uint64(math.Float32bits(float32(value.Float()))), 16)) + case reflect.Float64: + s.append(path, typ, strconv.FormatUint(math.Float64bits(value.Float()), 16)) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float32bits(real(complexValue)), math.Float32bits(imag(complexValue)))) + case reflect.Complex128: + complexValue := value.Complex() + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float64bits(real(complexValue)), math.Float64bits(imag(complexValue)))) + case reflect.String: + s.append(path, typ, value.String()) + case reflect.Interface: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + s.append(path, typ, value.Elem().Type().String()) + return s.appendValue(path, value.Elem()) + case reflect.Pointer: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, formatPointer(pointer)) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + return s.appendValue(path, value.Elem()) + case reflect.Struct: + s.append(path, typ, "struct") + if typ == typeMapType { + if err := s.appendValue(path+".User", value.FieldByName("User")); err != nil { + return err + } + s.appendExternalType(path+".External", value.FieldByName("External")) + return nil + } + for i := range value.NumField() { + if err := s.appendValue(path+"."+typ.Field(i).Name, value.Field(i)); err != nil { + return err + } + } + case reflect.Array: + s.append(path, typ, strconv.Itoa(value.Len())) + for i := range value.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), value.Index(i)); err != nil { + return err + } + } + case reflect.Slice: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d:%d", formatPointer(pointer), value.Len(), value.Cap())) + visit := designVisit{typ: typ, kind: value.Kind(), ptr: pointer, extent: value.Cap()} + if s.seen(visit) { + return nil + } + reachable := value.Slice(0, value.Cap()) + for i := range reachable.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), reachable.Index(i)); err != nil { + return err + } + } + case reflect.Map: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d", formatPointer(pointer), value.Len())) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + entries, err := orderedMapEntries(value) + if err != nil { + return fmt.Errorf("snapshot prepared design at %s: %w", path, err) + } + for i, entry := range entries { + entryPath := mapEntryPath(path, i, entry.key) + if err := s.appendValue(entryPath+".key", entry.key); err != nil { + return err + } + if err := s.appendValue(entryPath, entry.value); err != nil { + return err + } + } + case reflect.Func: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + if typ != dslFuncType { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil function %s", path, typ) + } + // DSL evaluation has already completed. The function body and captured + // environment are dormant input, so only nilness and code identity are + // part of the prepared semantic design audit. + s.append(path, typ, formatPointer(value.Pointer())) + case reflect.Chan: + if !value.IsNil() { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil channel %s", path, typ) + } + s.append(path, typ, "nil") + case reflect.UnsafePointer: + if value.Pointer() != 0 { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil unsafe pointer %s", path, typ) + } + s.append(path, typ, "nil") + default: + return fmt.Errorf("snapshot prepared design at %s: unsupported %s value", path, value.Kind()) + } + return nil +} + +// appendExternalType records the only semantic fact carried by a conversion +// exemplar: its exact dynamic Go type. Conversion generators never inspect +// the exemplar's runtime fields, locks, channels, or other instance state. +func (s *designSnapshotter) appendExternalType(path string, value reflect.Value) { + if value.IsNil() { + s.append(path, value.Type(), "nil") + return + } + s.append(path, value.Elem().Type(), "external exemplar type") +} + +// append adds one comparable state entry to the traversal. +func (s *designSnapshotter) append(path string, typ reflect.Type, value string) { + s.states = append(s.states, designState{path: path, typ: typ, value: value}) +} + +// seen records a reference and reports whether this exact node was already traversed. +func (s *designSnapshotter) seen(visit designVisit) bool { + if _, ok := s.visited[visit]; ok { + return true + } + s.visited[visit] = struct{}{} + return false +} + +// orderedMapEntries returns map pairs in an order derived from exact key and +// shallow value facts rather than Go's randomized iteration order. diff --git a/codegen/generator/design_snapshot_test.go b/codegen/generator/design_snapshot_test.go new file mode 100644 index 0000000000..9c283a7066 --- /dev/null +++ b/codegen/generator/design_snapshot_test.go @@ -0,0 +1,155 @@ +// This file verifies that the prepared-design snapshot reports persistent +// semantic mutations made after the lifecycle's explicit preparation phase. +package generator + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPreparedDesignSnapshotRejectsUnsupportedState proves live behavior not +// owned by the evaluated design cannot enter the persistent mutation audit. +func TestPreparedDesignSnapshotRejectsUnsupportedState(t *testing.T) { + value := 1 + tests := []struct { + name string + value any + want string + }{ + {"function", func() {}, "unsupported non-nil function"}, + {"channel", make(chan int), "unsupported non-nil channel"}, + {"unsafe pointer", unsafe.Pointer(&value), "unsupported non-nil unsafe pointer"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: test.value, + }, + }}} + _, err := snapshotPreparedDesign([]eval.Root{root}) + require.ErrorContains(t, err, "roots[0].Types[0].AttributeExpr.DefaultValue") + require.ErrorContains(t, err, test.want) + }) + } +} + +// TestPreparedDesignSnapshotAcceptsEvaluatedDSLFunctions proves dormant DSL +// closures remain valid prepared input after evaluation completes. +func TestPreparedDesignSnapshotAcceptsEvaluatedDSLFunctions(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DSLFunc: eval.DSLFunc(func() {}), + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) +} + +// TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken proves that +// conversion exemplars contribute their exact Go type but not instance state. +func TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken(t *testing.T) { + type firstExternal struct { + channel chan int + values []string + } + type secondExternal struct{} + external := &firstExternal{channel: make(chan int), values: []string{"before"}} + typeMap := &expr.TypeMap{External: external} + root := &expr.RootExpr{Conversions: []*expr.TypeMap{typeMap}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + external.values[0] = "after" + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + + typeMap.External = &secondExternal{} + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Conversions[0].External", changed) +} + +// TestPreparedDesignSnapshotMapOrderIsStable proves randomized Go map +// iteration does not produce false mutation reports. +func TestPreparedDesignSnapshotMapOrderIsStable(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: map[any]any{ + "zeta": []string{"last"}, + "alpha": []string{"first"}, + 42: "number", + }, + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + for range 100 { + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + } +} + +// TestPreparedDesignSnapshotDetectsAliasReplacement proves replacing one of +// two aliases with an equal-value allocation changes the recorded topology. +func TestPreparedDesignSnapshotDetectsAliasReplacement(t *testing.T) { + service := &expr.ServiceExpr{Name: "service"} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service, service}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + replacement := *service + root.Services[1] = &replacement + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Services[1]", changed) +} + +// TestPreparedDesignSnapshotDetectsInPlaceContainerMutation proves changes to +// existing map and slice storage are visible without replacing a container. +func TestPreparedDesignSnapshotDetectsInPlaceContainerMutation(t *testing.T) { + tests := []struct { + name string + mutate func(map[string][]string) + }{ + {"map", func(values map[string][]string) { values["second"] = []string{"new"} }}, + {"slice", func(values map[string][]string) { values["first"][0] = "changed" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + values := map[string][]string{"first": {"original"}} + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: values, + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + test.mutate(values) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.NotEmpty(t, changed) + }) + } +} diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 66df7f1d35..3ce1f67012 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -11,13 +11,14 @@ import ( jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Example iterates through the roots and returns files that implement an -// example service, server, and client. -func Example(generation *codegen.Generation) ([]*codegen.File, error) { +// exampleFiles returns example service, server, and client files described by +// plan's frozen package declarations and run-owned example state. +func exampleFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File + generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { - services, err := service.NewServicesData(r, generation) + services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) if err != nil { return nil, err } diff --git a/codegen/generator/example_state_test.go b/codegen/generator/example_state_test.go new file mode 100644 index 0000000000..3a75af0e20 --- /dev/null +++ b/codegen/generator/example_state_test.go @@ -0,0 +1,139 @@ +// This file verifies that each generator execution owns independent mutable +// example state while sharing only immutable API factory configuration. +package generator + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // mutatingRandomizerFactory violates the immutable API factory contract so + // the lifecycle can prove it attributes the mutation at construction. + mutatingRandomizerFactory struct { + calls int + } +) + +// NewRandomizer records a call before returning a fresh deterministic stream. +func (f *mutatingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + f.calls++ + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestGenerationRejectsFactoryMutationWhenStreamIsCreated(t *testing.T) { + root := expr.RunDSL(t, func() {}) + root.API.RandomizerFactory = &mutatingRandomizerFactory{} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + plan.exampleGenerator(root).At(generatorTestIdentity()) + return nil + }} + }) + + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + + require.ErrorContains(t, err, `core "examples" plan mutated prepared design`) +} + +func TestGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + root := expr.RunDSL(t, func() {}) + factory := root.API.RandomizerFactory + registry := newRegistry() + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []string + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + generator := plan.exampleGenerator(root) + stream := generator.At(generatorTestIdentity()) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, stream.String()) + return nil + }} + }) + + for range 2 { + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) + require.Equal(t, factory, root.API.RandomizerFactory) +} + +// generatorTestIdentity returns a typed owner for values drawn directly by +// lifecycle tests rather than by a code-generation subsystem. +func generatorTestIdentity() expr.ExampleIdentity { + method := &expr.MethodExpr{ + Name: "lifecycle", + Service: &expr.ServiceExpr{Name: "generator-test"}, + } + return expr.MethodPayloadExampleIdentity(method) +} + +func TestConcurrentGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + registry := newRegistry() + roots := []*expr.RootExpr{expr.RunDSL(t, func() {}), expr.RunDSL(t, func() {})} + recursive := make(map[*expr.RootExpr]*expr.UserTypeExpr, len(roots)) + for _, root := range roots { + node := &expr.UserTypeExpr{TypeName: "Node", UID: "test-node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: node}, + }}}, + }} + recursive[root] = node + } + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []any + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + root := plan.preparedRoots[0].(*expr.RootExpr) + generator := plan.exampleGenerator(root) + node := recursive[root] + example := node.Example(generator.At(expr.UserTypeExampleIdentity(node))) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, example) + return nil + }} + }) + + var runs sync.WaitGroup + errs := make(chan error, 2) + for _, root := range roots { + runs.Add(1) + go func(root *expr.RootExpr) { + defer runs.Done() + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + errs <- err + }(root) + } + runs.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) +} diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index d2e4a5d80e..22d356b48c 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -7,9 +7,11 @@ package generator import ( "fmt" "os" + "path" "path/filepath" "runtime" "sort" + "strings" "sync" "time" @@ -103,10 +105,11 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string // 3. Prepare roots, build and freeze one plan, then render core and plugin // files through the fresh run objects instantiated before root evaluation. startLifecycle := time.Now() - genfiles, err := run.execute(genpkg, roots) + result, err := run.execute(genpkg, roots) if err != nil { return nil, err } + genfiles := result.files if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Lifecycle produced %d files in %v\n", len(genfiles), time.Since(startLifecycle)) } @@ -115,7 +118,10 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string // multiple generators (or services) emit sections for the same file. { start := time.Now() - genfiles = mergeFilesByPath(genfiles) + genfiles, err = mergeFilesByPath(genfiles) + if err != nil { + return nil, err + } if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 8: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) } @@ -126,7 +132,8 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string genfiles = append(genfiles, codegen.VersionFile()) } - // 10. Write the files (in parallel). + // 10. Write the files in parallel, then audit the prepared design after all + // templates and file finalizers have completed. written := make(map[string]struct{}) { start := time.Now() @@ -135,32 +142,29 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Starting parallel file writing with %d workers\n", numWorkers) } - // Channel for work items type workItem struct { index int file *codegen.File } workChan := make(chan workItem, len(genfiles)) - // Channel for results - type result struct { + type renderResult struct { index int filename string duration time.Duration err error } - resultChan := make(chan result, len(genfiles)) + resultChan := make(chan renderResult, len(genfiles)) - // Start worker pool - var wg sync.WaitGroup + var workers sync.WaitGroup for range numWorkers { - wg.Add(1) + workers.Add(1) go func() { - defer wg.Done() + defer workers.Done() for work := range workChan { renderStart := time.Now() filename, err := work.file.Render(dir) - resultChan <- result{ + resultChan <- renderResult{ index: work.index, filename: filename, duration: time.Since(renderStart), @@ -170,35 +174,35 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string }() } - // Send all files to work channel - for i, f := range genfiles { - workChan <- workItem{index: i, file: f} + for i, file := range genfiles { + workChan <- workItem{index: i, file: file} } close(workChan) - // Wait for all workers to finish in a separate goroutine go func() { - wg.Wait() + workers.Wait() close(resultChan) }() - // Collect results + firstErrorIndex := len(genfiles) var firstErr error slowRenders := 0 - for res := range resultChan { - if res.err != nil && firstErr == nil { - firstErr = res.err + for render := range resultChan { + if render.err != nil && render.index < firstErrorIndex { + firstErrorIndex = render.index + firstErr = render.err } - if res.filename != "" { - written[res.filename] = struct{}{} + if render.filename != "" { + written[render.filename] = struct{}{} } - // Only log slow renders (>100ms) to avoid spam - if debug && res.duration > 100*time.Millisecond { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", res.index, res.filename, res.duration) + if debug && render.duration > 100*time.Millisecond { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", render.index, render.filename, render.duration) slowRenders++ } } - + if err := result.plan.verifyPreparedDesign("generated file renders"); err != nil { + return nil, err + } if firstErr != nil { return nil, firstErr } @@ -242,58 +246,54 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string // prevents later renders from truncating earlier content when multiple // services contribute sections to the same file (e.g., shared user types with // union value methods). -func mergeFilesByPath(files []*codegen.File) []*codegen.File { - if len(files) <= 1 { - return files +func mergeFilesByPath(files []*codegen.File) ([]*codegen.File, error) { + if len(files) == 0 { + return files, nil } byPath := make(map[string]*codegen.File) - namesByPath := make(map[string]map[string]struct{}) + portablePaths := make(map[string]string) - // First pass: build merged file per path + // First pass: build one complete file per path. for _, f := range files { if f == nil { continue } - path := f.Path - if existing, ok := byPath[path]; ok { - // Merge headers (index 0) imports - if len(existing.SectionTemplates) > 0 && len(f.SectionTemplates) > 0 { - mergeHeaderImports(existing.SectionTemplates[0], f.SectionTemplates[0]) + canonicalPath, portablePath, err := canonicalOutputFilePath(f.Path) + if err != nil { + return nil, err + } + if claimedPath, ok := portablePaths[portablePath]; ok && claimedPath != canonicalPath { + return nil, fmt.Errorf( + "generated file paths %q and %q collide on a case-insensitive filesystem", + claimedPath, + canonicalPath, + ) + } + portablePaths[portablePath] = canonicalPath + f.Path = canonicalPath + if existing, ok := byPath[canonicalPath]; ok { + if existing.SkipExist != f.SkipExist { + return nil, fmt.Errorf("generated file %q has conflicting SkipExist settings", canonicalPath) } - // Initialize seen section names for this path - if namesByPath[path] == nil { - namesByPath[path] = make(map[string]struct{}) - for _, st := range existing.SectionTemplates { - namesByPath[path][st.Name] = struct{}{} - } + existingHeader, existingHasHeader := firstHeader(existing) + contributorHeader, contributorHasHeader := firstHeader(f) + if existingHasHeader != contributorHasHeader { + return nil, fmt.Errorf("generated file %q mixes header and headerless contributions", canonicalPath) } - // Append unique sections (skip header at index 0) - for i, st := range f.SectionTemplates { - if i == 0 { - continue + sectionStart := 0 + if existingHasHeader { + if err := mergeHeaderImports(existingHeader, contributorHeader); err != nil { + return nil, fmt.Errorf("merge generated file %q: %w", canonicalPath, err) } - if _, seen := namesByPath[path][st.Name]; seen { - continue - } - existing.SectionTemplates = append(existing.SectionTemplates, st) - namesByPath[path][st.Name] = struct{}{} - } - // Preserve a finalize function if destination does not have one - if existing.FinalizeFunc == nil && f.FinalizeFunc != nil { - existing.FinalizeFunc = f.FinalizeFunc + sectionStart = 1 } - // Skip adding a duplicate File entry + existing.SectionTemplates = append(existing.SectionTemplates, f.SectionTemplates[sectionStart:]...) + existing.FinalizeFunc = composeFinalizers(existing.FinalizeFunc, f.FinalizeFunc) continue } - // New path: record and initialize seen names - byPath[path] = f - m := make(map[string]struct{}) - for _, st := range f.SectionTemplates { - m[st.Name] = struct{}{} - } - namesByPath[path] = m + byPath[canonicalPath] = f } // Second pass: preserve original order by first occurrence of each path @@ -311,43 +311,112 @@ func mergeFilesByPath(files []*codegen.File) []*codegen.File { seenPaths[f.Path] = struct{}{} } } - return merged + return merged, nil } // mergeHeaderImports merges the import specs from src header into dst header, -// deduplicating by (Name, Path). If either section is not a header produced by -// codegen.Header, this function is a no-op. -func mergeHeaderImports(dst, src *codegen.SectionTemplate) { - if dst == nil || src == nil { - return - } - dmap, dok := dst.Data.(map[string]any) - smap, sok := src.Data.(map[string]any) - if !dok || !sok { - return +// rejecting package and alias conflicts rather than producing invalid Go. +func mergeHeaderImports(dst, src *codegen.SectionTemplate) error { + dmap, _ := dst.Data.(map[string]any) + smap, _ := src.Data.(map[string]any) + dpkg, _ := dmap["Pkg"].(string) + spkg, _ := smap["Pkg"].(string) + if dpkg != spkg { + return fmt.Errorf("header packages %q and %q conflict", dpkg, spkg) } dlist, _ := dmap["Imports"].([]*codegen.ImportSpec) slist, _ := smap["Imports"].([]*codegen.ImportSpec) - if len(slist) == 0 { - return - } - seen := make(map[string]struct{}, len(dlist)) + paths := make(map[string]string, len(dlist)+len(slist)) + aliases := make(map[string]string, len(dlist)+len(slist)) for _, imp := range dlist { - if imp == nil { - continue + if _, err := recordImportSpec(paths, aliases, imp); err != nil { + return err } - seen[imp.Name+"|"+imp.Path] = struct{}{} } for _, imp := range slist { - if imp == nil { - continue + duplicate, err := recordImportSpec(paths, aliases, imp) + if err != nil { + return err } - key := imp.Name + "|" + imp.Path - if _, ok := seen[key]; ok { - continue + if !duplicate { + dlist = append(dlist, imp) } - dlist = append(dlist, imp) - seen[key] = struct{}{} } dmap["Imports"] = dlist + return nil +} + +// recordImportSpec validates one import against the complete merged header and +// reports whether the exact path and alias were already present. +func recordImportSpec(paths, names map[string]string, spec *codegen.ImportSpec) (bool, error) { + if spec == nil { + return true, nil + } + if alias, ok := paths[spec.Path]; ok { + if alias != spec.Name { + return false, fmt.Errorf("import path %q uses aliases %q and %q", spec.Path, alias, spec.Name) + } + return true, nil + } + localName := spec.Name + if localName != "" && localName != "_" && localName != "." { + if importPath, ok := names[localName]; ok { + return false, fmt.Errorf("import name %q refers to paths %q and %q", localName, importPath, spec.Path) + } + names[localName] = spec.Path + } + paths[spec.Path] = spec.Name + return false, nil +} + +// canonicalOutputFilePath returns the one portable relative spelling used to +// group and render a generated file. The second result is case-folded so two +// paths cannot overwrite one another on a case-insensitive filesystem. +func canonicalOutputFilePath(rawPath string) (string, string, error) { + portable := filepath.ToSlash(rawPath) + portable = strings.ReplaceAll(portable, `\`, "/") + canonical := path.Clean(portable) + if canonical == "." || + canonical == ".." || + strings.HasPrefix(canonical, "../") || + strings.HasPrefix(canonical, "/") { + return "", "", fmt.Errorf("generated file path %q must stay within the output directory", rawPath) + } + if strings.Contains(canonical, ":") { + return "", "", fmt.Errorf("generated file path %q is not portable", rawPath) + } + return filepath.FromSlash(canonical), strings.ToLower(canonical), nil +} + +// firstHeader reports the header produced by codegen.Header when it is the +// first section of file. +func firstHeader(file *codegen.File) (*codegen.SectionTemplate, bool) { + if len(file.SectionTemplates) == 0 { + return nil, false + } + header := file.SectionTemplates[0] + data, ok := header.Data.(map[string]any) + if !ok { + return nil, false + } + _, hasPackage := data["Pkg"].(string) + _, hasImports := data["Imports"].([]*codegen.ImportSpec) + return header, hasPackage && hasImports +} + +// composeFinalizers preserves every same-path contributor's post-render work +// in contributor order and stops at the first error. +func composeFinalizers(first, second func(string) error) func(string) error { + if first == nil { + return second + } + if second == nil { + return first + } + return func(path string) error { + if err := first(path); err != nil { + return err + } + return second(path) + } } diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go index 796505598e..aea52f90c4 100644 --- a/codegen/generator/generate_grpc_metadata_integration_test.go +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -14,8 +14,8 @@ import ( func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { registry := testRegistry( "gen", - testGenerator(planServiceData, Service), - testGenerator(planTransportData, Transport), + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), ) _ = codegen.RunDSL(t, func() { diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index 2675329038..d0b8e60121 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -18,8 +18,8 @@ import ( func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { registry := testRegistry( "gen", - testGenerator(planServiceData, Service), - testGenerator(planTransportData, Transport), + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), ) dsl := func() { diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index 98208a0eb3..b57b2925eb 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -13,13 +13,15 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" goa "goa.design/goa/v3/pkg" ) // TestMergeFilesPreservesSameLabelSections verifies that diagnostic section // labels do not cause the merger to discard different generated bodies. func TestMergeFilesPreservesSameLabelSections(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + finalizeMergeTestRoots(t) + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { return []*codegen.File{{ Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), @@ -49,17 +51,173 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { require.Contains(t, string(content), "type Second struct{}") } +// TestMergeFilesRunsEveryFinalizer verifies that same-path contributors keep +// their post-render work in the same order as their generated sections. +func TestMergeFilesRunsEveryFinalizer(t *testing.T) { + var calls []string + files, err := mergeFilesByPath([]*codegen.File{ + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "first") + return nil + }, + }, + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "second") + return nil + }, + }, + }) + require.NoError(t, err) + require.Len(t, files, 1) + require.NoError(t, files[0].FinalizeFunc("gen/types.go")) + require.Equal(t, []string{"first", "second"}, calls) +} + +// TestMergeFilesAllowsUnaliasedImports verifies that the merger does not guess +// Go package identifiers from import path spellings it does not own. +func TestMergeFilesAllowsUnaliasedImports(t *testing.T) { + first := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "first.example/v2"}, + }) + second := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "second.example/v2"}, + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + header := files[0].SectionTemplates[0].Data.(map[string]any) + require.Len(t, header["Imports"], 2) +} + +// TestMergeFilesRejectsConflictingFileContracts verifies that the merger +// reports incompatible contributors instead of silently keeping one value. +func TestMergeFilesRejectsConflictingFileContracts(t *testing.T) { + tests := []struct { + name string + first *codegen.File + second *codegen.File + err string + }{ + { + name: "skip existing", + first: mergeTestFile("types", false, nil), + second: mergeTestFile("types", true, nil), + err: "conflicting SkipExist", + }, + { + name: "package", + first: mergeTestFile("first", false, nil), + second: mergeTestFile("second", false, nil), + err: "header packages", + }, + { + name: "alias", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/first"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/second"}, + }), + err: "import name", + }, + { + name: "path", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "first", Path: "example.com/shared"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "second", Path: "example.com/shared"}, + }), + err: "import path", + }, + { + name: "conflict within first header", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "first.example/value"}, + {Name: "shared", Path: "second.example/value"}, + }), + second: mergeTestFile("types", false, nil), + err: "import name", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := mergeFilesByPath([]*codegen.File{test.first, test.second}) + require.ErrorContains(t, err, test.err) + }) + } +} + +// TestMergeFilesCanonicalizesOutputPaths verifies that contributors targeting +// one cleaned relative file cannot bypass compatibility checks or race writes. +func TestMergeFilesCanonicalizesOutputPaths(t *testing.T) { + first := mergeTestFile("types", false, nil) + first.Path = "gen/types.go" + first.SectionTemplates = append(first.SectionTemplates, &codegen.SectionTemplate{ + Name: "first", + Source: "type First struct{}", + }) + second := mergeTestFile("types", false, nil) + second.Path = "gen/x/../types.go" + second.SectionTemplates = append(second.SectionTemplates, &codegen.SectionTemplate{ + Name: "second", + Source: "type Second struct{}", + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, filepath.Join("gen", "types.go"), files[0].Path) + require.Len(t, files[0].SectionTemplates, 3) +} + +// TestMergeFilesRejectsUnsafeOutputPaths verifies that no generated file can +// escape the output directory or collide only on a portable filesystem. +func TestMergeFilesRejectsUnsafeOutputPaths(t *testing.T) { + tests := []struct { + name string + paths []string + err string + }{ + {"parent", []string{"../outside.go"}, "must stay within"}, + {"absolute", []string{filepath.Join(string(filepath.Separator), "outside.go")}, "must stay within"}, + {"volume", []string{`C:\outside.go`}, "not portable"}, + {"case fold", []string{"gen/Types.go", "gen/types.go"}, "case-insensitive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + files := make([]*codegen.File, len(test.paths)) + for index, outputPath := range test.paths { + files[index] = mergeTestFile("types", false, nil) + files[index].Path = outputPath + } + _, err := mergeFilesByPath(files) + require.ErrorContains(t, err, test.err) + }) + } +} + // TestGenerateMergesSamePathFiles verifies that when two generators emit content // targeting the same output path, Generate merges the sections into a single // file rather than overwriting earlier content. This is a regression test for // an issue where only a later section (e.g., a union value method) remained and // the earlier struct definition was lost. func TestGenerateMergesSamePathFiles(t *testing.T) { + finalizeMergeTestRoots(t) // Fake generators emit two files with identical Path, one containing a // type definition and the other containing a method. Without merging, the // second write would overwrite the first. - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} f.SectionTemplates = []*codegen.SectionTemplate{ @@ -110,11 +268,12 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { // pool distribution. This ensures all workers process files and all files are // written correctly. func TestGenerateParallelManyFiles(t *testing.T) { + finalizeMergeTestRoots(t) // Generate 20 files to ensure we exceed typical CPU counts and exercise // the worker pool's work distribution. const numFiles = 20 - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { files := make([]*codegen.File, numFiles) for i := 0; i < numFiles; i++ { @@ -165,10 +324,11 @@ func TestGenerateParallelManyFiles(t *testing.T) { // handles file merging when multiple generators target the same path. This // tests the interaction between mergeFilesByPath and parallel rendering. func TestGenerateParallelWithMerge(t *testing.T) { + finalizeMergeTestRoots(t) // Three generators: first two merge to same path, third is separate. // This exercises both merging and parallel writing with NumCPU workers. - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} f1.SectionTemplates = []*codegen.SectionTemplate{ @@ -236,10 +396,11 @@ func TestGenerateParallelWithMerge(t *testing.T) { // in the parallel worker pool, the first error is captured and returned while // other workers continue processing. func TestGenerateParallelErrorHandling(t *testing.T) { + finalizeMergeTestRoots(t) // Create multiple files where some will fail to render due to invalid paths. // Worker pool should capture first error but continue processing other files. - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { files := make([]*codegen.File, 5) for i := 0; i < 5; i++ { @@ -277,8 +438,9 @@ func TestGenerateParallelErrorHandling(t *testing.T) { // TestGenerateParallelSingleFile verifies that parallel file writing works // correctly with just a single file (minimal parallelism edge case). func TestGenerateParallelSingleFile(t *testing.T) { + finalizeMergeTestRoots(t) - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ + registry := testRegistryFromGenfuncs([]testGenfunc{ testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} f.SectionTemplates = []*codegen.SectionTemplate{ @@ -340,3 +502,25 @@ func assertVersionFile(t *testing.T, dir string, outputs []string) []string { } return rest } + +// mergeTestFile creates one complete Go file contribution for merger tests. +func mergeTestFile(packageName string, skipExist bool, imports []*codegen.ImportSpec) *codegen.File { + return &codegen.File{ + Path: "gen/types.go", + SkipExist: skipExist, + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", packageName, imports)}, + } +} + +// finalizeMergeTestRoots supplies the evaluated-design precondition that the +// filesystem-facing generator receives from the goa command in production. +func finalizeMergeTestRoots(t *testing.T) { + t.Helper() + roots, err := eval.Context.Roots() + require.NoError(t, err) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + design.Finalize() + } + } +} diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index c3f4060047..eb85853983 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -20,9 +20,9 @@ import ( func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { registry := testRegistry( "gen", - testGenerator(planServiceData, Service), - testGenerator(planTransportData, Transport), - testGenerator(planServiceData, OpenAPI), + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + testGenerator(planServiceData, testOpenAPIFiles), ) dsl := func() { diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go index 85692e7ef1..246fe90617 100644 --- a/codegen/generator/generated_transport_alias_integration_test.go +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -39,17 +39,17 @@ func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing. } }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, planTransportData(generation)) require.NoError(t, generation.Freeze()) - files, err := Service(generation) + files, err := testServiceFiles(generation) require.NoError(t, err) - transport, err := Transport(generation) + transport, err := testTransportFiles(generation) require.NoError(t, err) files = append(files, transport...) - examples, err := Example(generation) + exampleFiles, err := assembleExampleFilesForTest(generation) require.NoError(t, err) - files = append(files, examples...) + files = append(files, exampleFiles...) dir := t.TempDir() writeGeneratedModule(t, dir, "generated.local") diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index 92125960a2..f524a63cd8 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -11,6 +11,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpdata "goa.design/goa/v3/http/codegen/testdata" ) func TestGeneratePhasesShareOneGeneration(t *testing.T) { @@ -44,7 +45,11 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { events = append(events, "core-plan-first") planned = plan.Generation() typesPath = planned.GenPkg() + "/types" - _, err := planned.GeneratedPackage(typesPath).DeclareUnion(union) + types, err := planned.ClaimPackage(typesPath) + if err != nil { + return err + } + _, err = types.DeclareUnion(union) return err }, Generate: func(plan *Plan) ([]*codegen.File, error) { @@ -53,14 +58,14 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { if err := assertGeneration(generation); err != nil { return nil, err } - declaration, err := generation.GeneratedPackage(typesPath).Union(union) + declaration, err := generation.Package(typesPath).Union(union) if err != nil { return nil, err } if declaration.Name() == "" { return nil, fmt.Errorf("union name is empty during render") } - _, lateDeclare = generation.GeneratedPackage(typesPath).DeclareUnion(lateUnion) + _, lateDeclare = generation.Package(typesPath).DeclareUnion(lateUnion) if lateDeclare == nil { return nil, fmt.Errorf("render declared a new union after freeze") } @@ -117,3 +122,44 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { "plugin-render", }, events) } + +// TestPreparedRootsRejectFileRenderMutation proves that persistent mutations +// made by templates and file finalizers are rejected after rendering completes. +func TestPreparedRootsRejectFileRenderMutation(t *testing.T) { + for _, phase := range []string{"template", "finalizer"} { + t.Run(phase, func(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + dir := t.TempDir() + mutate := func() { + root.API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + } + first := &codegen.File{ + Path: "first.txt", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "first", + Source: "first", + }}, + } + if phase == "template" { + first.SectionTemplates[0].Source = "{{ mutate }}" + first.SectionTemplates[0].FuncMap = map[string]any{"mutate": func() string { + mutate() + return "first" + }} + } else { + first.FinalizeFunc = func(_ string) error { + mutate() + return nil + } + } + registry := testRegistry("test", func() coreGenerator { + return coreGenerator{name: "files", Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{first}, nil + }} + }) + + _, err := generate(dir, "test", false, registry) + require.ErrorContains(t, err, "generated file renders mutated prepared design") + }) + } +} diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index b96477fad0..5ea2a3547a 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -8,6 +8,8 @@ import "goa.design/goa/v3/codegen" type ( // coreGenerator plans and renders one core subsystem for a single run. coreGenerator struct { + // name identifies the subsystem in lifecycle diagnostics. + name string // Plan declares package symbols and retains run-specific analysis. Plan func(*Plan) error // Generate renders files from the same frozen plan. @@ -23,31 +25,34 @@ func genGeneratorFactories() []generatorFactory { return []generatorFactory{ func() coreGenerator { return coreGenerator{ + name: "service", Plan: func(plan *Plan) error { return planServiceData(plan.Generation()) }, Generate: func(plan *Plan) ([]*codegen.File, error) { - return Service(plan.Generation()) + return serviceFiles(plan) }, } }, func() coreGenerator { return coreGenerator{ + name: "transport", Plan: func(plan *Plan) error { return planTransportData(plan.Generation()) }, Generate: func(plan *Plan) ([]*codegen.File, error) { - return Transport(plan.Generation()) + return transportFiles(plan) }, } }, func() coreGenerator { return coreGenerator{ + name: "openapi", Plan: func(plan *Plan) error { return planServiceData(plan.Generation()) }, Generate: func(plan *Plan) ([]*codegen.File, error) { - return OpenAPI(plan.Generation()) + return openAPIFiles(plan) }, } }, @@ -59,11 +64,12 @@ func exampleGeneratorFactories() []generatorFactory { return []generatorFactory{ func() coreGenerator { return coreGenerator{ + name: "example", Plan: func(plan *Plan) error { return planTransportData(plan.Generation()) }, Generate: func(plan *Plan) ([]*codegen.File, error) { - return Example(plan.Generation()) + return exampleFiles(plan) }, } }, diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go index 674c5f3b6e..8d49987e0b 100644 --- a/codegen/generator/lifecycle.go +++ b/codegen/generator/lifecycle.go @@ -4,27 +4,44 @@ package generator import ( + "fmt" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" ) type ( // generationRun owns every fresh core and plugin instance for one execution. generationRun struct { cores []coreGenerator - plugins []Plugin + plugins []runPlugin + } + + // runPlugin retains one plugin's registered owner name with its fresh callbacks. + runPlugin struct { + name string + Plugin + } + + // generationResult retains the exact plan needed to verify later file renders. + generationResult struct { + plan *Plan + files []*codegen.File } ) // executeGeneration instantiates fresh core and plugin objects, prepares roots, -// and renders files from one retained frozen plan. +// and produces file descriptions from one retained frozen plan. func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) ([]*codegen.File, error) { run, err := newGenerationRun(command, registry) if err != nil { return nil, err } - return run.execute(genpkg, roots) + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err + } + return result.files, nil } // newGenerationRun snapshots immutable factories and invokes each exactly once. @@ -37,15 +54,15 @@ func newGenerationRun(command string, registry *registry) (*generationRun, error for i, factory := range coreFactories { cores[i] = factory() } - plugins := make([]Plugin, len(pluginDescriptors)) + plugins := make([]runPlugin, len(pluginDescriptors)) for i, descriptor := range pluginDescriptors { - plugins[i] = descriptor.factory() + plugins[i] = runPlugin{name: descriptor.name, Plugin: descriptor.factory()} } return &generationRun{cores: cores, plugins: plugins}, nil } // execute runs all phases for explicit prepared-root inputs. -func (r *generationRun) execute(genpkg string, roots []eval.Root) ([]*codegen.File, error) { +func (r *generationRun) execute(genpkg string, roots []eval.Root) (*generationResult, error) { for _, plugin := range r.plugins { if plugin.Prepare != nil { if err := plugin.Prepare(genpkg, roots); err != nil { @@ -53,51 +70,79 @@ func (r *generationRun) execute(genpkg string, roots []eval.Root) ([]*codegen.Fi } } } - for _, root := range roots { - if design, ok := root.(*expr.RootExpr); ok { - codegen.NormalizeRoot(design) - } + generation, err := codegen.NewGeneration(genpkg, roots) + if err != nil { + return nil, err + } + design, err := snapshotPreparedDesign(roots) + if err != nil { + return nil, err + } + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + design: design, + } + if err := plan.verifyPreparedDesign("example generator creation"); err != nil { + return nil, err } - - plan := &Plan{generation: codegen.NewGeneration(genpkg, roots)} for _, core := range r.cores { if core.Plan != nil { - if err := core.Plan(plan); err != nil { + callbackErr := core.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q plan", core.name)); err != nil { return nil, err } + if callbackErr != nil { + return nil, callbackErr + } } } for _, plugin := range r.plugins { if plugin.Plan != nil { - if err := plugin.Plan(plan); err != nil { + callbackErr := plugin.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q plan", plugin.name)); err != nil { return nil, err } + if callbackErr != nil { + return nil, callbackErr + } } } - if err := plan.Generation().Freeze(); err != nil { + freezeErr := plan.Generation().Freeze() + if err := plan.verifyPreparedDesign("generation freeze"); err != nil { return nil, err } + if freezeErr != nil { + return nil, freezeErr + } var files []*codegen.File for _, core := range r.cores { if core.Generate == nil { continue } - generated, err := core.Generate(plan) - if err != nil { + generated, callbackErr := core.Generate(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q generate", core.name)); err != nil { return nil, err } + if callbackErr != nil { + return nil, callbackErr + } files = append(files, generated...) } for _, plugin := range r.plugins { if plugin.Generate == nil { continue } - generated, err := plugin.Generate(plan, files) - if err != nil { + generated, callbackErr := plugin.Generate(plan, files) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q generate", plugin.name)); err != nil { return nil, err } + if callbackErr != nil { + return nil, callbackErr + } files = generated } - return files, nil + return &generationResult{plan: plan, files: files}, nil } diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 18e5b88a17..7a0c5e1584 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -8,18 +8,19 @@ import ( httpcodegen "goa.design/goa/v3/http/codegen" ) -// OpenAPI iterates through the roots and returns the files needed to render -// the service OpenAPI spec. It produces OpenAPI specifications only if the -// roots define a HTTP service. -func OpenAPI(generation *codegen.Generation) ([]*codegen.File, error) { +// openAPIFiles returns OpenAPI files described by plan's frozen package +// declarations and run-owned example state. +func openAPIFiles(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, root := range designRoots { - if _, err := service.NewServicesData(root, generation); err != nil { + if _, err := service.NewServicesData(root, generation, plan.exampleGenerator(root)); err != nil { return nil, err } } if len(designRoots) > 0 { - return httpcodegen.OpenAPIFiles(designRoots[0]) + root := designRoots[0] + return httpcodegen.OpenAPIFiles(root, plan.exampleGenerator(root)) } return nil, nil } diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go index e3653fb8df..250e09a707 100644 --- a/codegen/generator/plan.go +++ b/codegen/generator/plan.go @@ -3,13 +3,23 @@ // subsystem tasks that consume this lifecycle foundation. package generator -import "goa.design/goa/v3/codegen" +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) type ( - // Plan is the immutable generation context passed from planning to rendering. - // Its fields are private so it cannot become a generic analysis registry. + // Plan retains the typed state shared by planning and rendering in one run. + // Planning may add declarations to Generation; rendering receives the same + // plan only after those declarations are frozen. Plan struct { - generation *codegen.Generation + generation *codegen.Generation + preparedRoots []eval.Root + examples map[*expr.RootExpr]*expr.ExampleGenerator + design *designSnapshot } ) @@ -17,3 +27,26 @@ type ( func (p *Plan) Generation() *codegen.Generation { return p.generation } + +// exampleGenerator returns the mutable example state created for root in this +// run. A root outside the prepared plan is an orchestration bug. +func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { + generator, ok := p.examples[root] + if !ok { + panic(fmt.Sprintf("example generator requested for unplanned design root %q", root.API.Name)) + } + return generator +} + +// verifyPreparedDesign rejects the first expression change made after +// preparation and identifies the callback or render operation that made it. +func (p *Plan) verifyPreparedDesign(operation string) error { + path, err := p.design.changedPath(p.preparedRoots) + if err != nil { + return fmt.Errorf("%s left prepared design unverifiable: %w", operation, err) + } + if path != "" { + return fmt.Errorf("%s mutated prepared design at %s", operation, path) + } + return nil +} diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go index f30f1155ed..e46ab2397d 100644 --- a/codegen/generator/plugin.go +++ b/codegen/generator/plugin.go @@ -38,7 +38,6 @@ type ( commands map[string][]generatorFactory plugins []pluginDescriptor sealed bool - next uint64 } // pluginDescriptor is immutable registration metadata retained globally. @@ -46,7 +45,6 @@ type ( name string command string position pluginPosition - sequence uint64 factory PluginFactory } @@ -62,17 +60,21 @@ const ( var defaultRegistry = newDefaultRegistry() -// RegisterPlugin registers a factory in the normal alphabetically ordered group. +// RegisterPlugin registers a factory in the normal alphabetically ordered +// group. It panics when name is empty, command is unknown, factory is nil, the +// command already has a plugin with name, or generation has already started. func RegisterPlugin(name, command string, factory PluginFactory) { defaultRegistry.registerPlugin(name, command, pluginNormal, factory) } -// RegisterPluginFirst registers a factory before normal and Last plugins. +// RegisterPluginFirst registers a factory before normal and Last plugins. It +// enforces the same registration contract as RegisterPlugin. func RegisterPluginFirst(name, command string, factory PluginFactory) { defaultRegistry.registerPlugin(name, command, pluginFirst, factory) } -// RegisterPluginLast registers a factory after First and normal plugins. +// RegisterPluginLast registers a factory after First and normal plugins. It +// enforces the same registration contract as RegisterPlugin. func RegisterPluginLast(name, command string, factory PluginFactory) { defaultRegistry.registerPlugin(name, command, pluginLast, factory) } @@ -101,7 +103,8 @@ func (r *registry) addCommand(command string, factories ...generatorFactory) { r.commands[command] = slices.Clone(factories) } -// registerPlugin records immutable factory metadata before the first snapshot. +// registerPlugin records one named factory for a known command before the +// first snapshot. Plugin names uniquely identify their owner within a command. func (r *registry) registerPlugin(name, command string, position pluginPosition, factory PluginFactory) { if factory == nil { panic("plugin factory is nil") @@ -111,14 +114,23 @@ func (r *registry) registerPlugin(name, command string, position pluginPosition, if r.sealed { panic("generator plugin registry is sealed") } + if name == "" { + panic("plugin name is empty") + } + if _, ok := r.commands[command]; !ok { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + for _, plugin := range r.plugins { + if plugin.command == command && plugin.name == name { + panic(fmt.Sprintf("plugin %q is already registered for command %q", name, command)) + } + } r.plugins = append(r.plugins, pluginDescriptor{ name: name, command: command, position: position, - sequence: r.next, factory: factory, }) - r.next++ } // snapshot seals the registry and returns copied factories in stable order. @@ -136,20 +148,11 @@ func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescrip plugins = append(plugins, plugin) } } - slices.SortStableFunc(plugins, func(left, right pluginDescriptor) int { + slices.SortFunc(plugins, func(left, right pluginDescriptor) int { if left.position != right.position { return int(left.position) - int(right.position) } - if compared := strings.Compare(left.name, right.name); compared != 0 { - return compared - } - if left.sequence < right.sequence { - return -1 - } - if left.sequence > right.sequence { - return 1 - } - return 0 + return strings.Compare(left.name, right.name) }) return slices.Clone(factories), plugins, nil } diff --git a/codegen/generator/plugin_registry_contract_test.go b/codegen/generator/plugin_registry_contract_test.go new file mode 100644 index 0000000000..66d94f1299 --- /dev/null +++ b/codegen/generator/plugin_registry_contract_test.go @@ -0,0 +1,90 @@ +// This file verifies that plugin registration rejects ambiguous ownership +// before a generation run seals and snapshots the command registry. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPluginRegistrationRejectsInvalidIdentity verifies that malformed plugin +// ownership is rejected before any generation run snapshots the registry. +func TestPluginRegistrationRejectsInvalidIdentity(t *testing.T) { + tests := []struct { + name string + plugin string + command string + }{ + {name: "empty plugin name", command: "test"}, + {name: "unknown command", plugin: "plugin", command: "missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + + require.Panics(t, func() { + registry.registerPlugin(test.plugin, test.command, pluginNormal, func() Plugin { + return Plugin{} + }) + }) + }) + } +} + +// TestPluginRegistrationRejectsDuplicateCommandName verifies that a plugin +// cannot acquire two ordering positions for the same command and owner name. +func TestPluginRegistrationRejectsDuplicateCommandName(t *testing.T) { + positions := []struct { + name string + position pluginPosition + }{ + {name: "first", position: pluginFirst}, + {name: "normal", position: pluginNormal}, + {name: "last", position: pluginLast}, + } + + for _, initial := range positions { + for _, duplicate := range positions { + t.Run(initial.name+" then "+duplicate.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("plugin", "test", initial.position, func() Plugin { + return Plugin{} + }) + + require.Panics(t, func() { + registry.registerPlugin("plugin", "test", duplicate.position, func() Plugin { + return Plugin{} + }) + }) + }) + } + } +} + +// TestPluginRegistrationScopesNamesToCommand verifies that two commands may +// use the same owner name because each command snapshots its own plugin list. +func TestPluginRegistrationScopesNamesToCommand(t *testing.T) { + registry := newRegistry() + registry.addCommand("first") + registry.addCommand("second") + registry.registerPlugin("plugin", "first", pluginNormal, func() Plugin { + return Plugin{} + }) + registry.registerPlugin("plugin", "second", pluginNormal, func() Plugin { + return Plugin{} + }) + + _, first, err := registry.snapshot("first") + require.NoError(t, err) + require.Len(t, first, 1) + require.Equal(t, "first", first[0].command) + + _, second, err := registry.snapshot("second") + require.NoError(t, err) + require.Len(t, second, 1) + require.Equal(t, "second", second[0].command) +} diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go index 6aada38b48..baa5ea2b17 100644 --- a/codegen/generator/plugin_test.go +++ b/codegen/generator/plugin_test.go @@ -12,6 +12,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpdata "goa.design/goa/v3/http/codegen/testdata" ) // TestPluginFactoryOrderAndPlan verifies First, normal, and Last ordering and @@ -77,7 +78,10 @@ func TestPluginFactorySequentialIsolation(t *testing.T) { registry := isolatedPluginRegistry(t) for i := range 2 { - root := &expr.RootExpr{API: &expr.APIExpr{Name: fmt.Sprintf("run-%d", i)}} + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", i), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} _, err := executeGeneration( fmt.Sprintf("generated.local/gen%d", i), []eval.Root{root}, @@ -98,7 +102,10 @@ func TestPluginFactoryConcurrentIsolation(t *testing.T) { wait.Add(1) go func(index int) { defer wait.Done() - root := &expr.RootExpr{API: &expr.APIExpr{Name: fmt.Sprintf("run-%d", index)}} + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", index), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} _, err := executeGeneration( fmt.Sprintf("generated.local/gen%d", index), []eval.Root{root}, @@ -118,7 +125,10 @@ func TestPluginFactoryConcurrentIsolation(t *testing.T) { // TestPreparedRootsBecomeExactGenerationSnapshot verifies that plugin // preparation completes before Generation copies root membership and values. func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { - root := &expr.RootExpr{API: &expr.APIExpr{Name: "before"}} + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} registry := newRegistry() registry.addCommand("test", func() coreGenerator { return coreGenerator{ @@ -144,6 +154,114 @@ func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { require.NoError(t, err) } +// TestPreparedRootsRejectNonAttributeMutations verifies that service, method, +// transport, and pointer-topology changes stop before the next callback. +func TestPreparedRootsRejectNonAttributeMutations(t *testing.T) { + tests := []struct { + name string + phase string + configure func(*registry, func()) + }{ + { + name: "service identity during plugin plan", + phase: `plugin "a-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(plan *Plan) error { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Name = "changed" + return nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(_ *Plan) error { + following() + return nil + }} + }) + }, + }, + { + name: "method identity during core generate", + phase: `core "method-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "method-mutator", Generate: func(plan *Plan) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Methods[0].Name = "changed" + return nil, nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Generate: func(_ *Plan) ([]*codegen.File, error) { + following() + return nil, nil + }} + }, + ) + }, + }, + { + name: "HTTP route during plugin generate", + phase: `plugin "a-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + return files, nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + following() + return files, nil + }} + }) + }, + }, + { + name: "equal service replacement during core plan", + phase: `core "topology-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "topology-mutator", Plan: func(plan *Plan) error { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + copy := *root.Services[0] + root.Services[0] = © + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + following() + return nil + }} + }, + ) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + registry := newRegistry() + followingRan := false + test.configure(registry, func() { + followingRan = true + }) + + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.ErrorContains(t, err, test.phase+" mutated prepared design") + require.False(t, followingRan) + }) + } +} + // TestPluginRegistrySealsOnFirstSnapshot verifies that a run cannot observe // factories registered after the registry's immutable snapshot is established. func TestPluginRegistrySealsOnFirstSnapshot(t *testing.T) { @@ -162,9 +280,24 @@ func isolatedPluginRegistry(t *testing.T) *registry { t.Helper() registry := newRegistry() registry.addCommand("test", func() coreGenerator { - return coreGenerator{Generate: func(plan *Plan) ([]*codegen.File, error) { - return []*codegen.File{{Path: plan.Generation().GenPkg()}}, nil - }} + phase := 0 + return coreGenerator{ + name: "state", + Plan: func(_ *Plan) error { + if phase != 0 { + return fmt.Errorf("core plan started at phase %d", phase) + } + phase++ + return nil + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + if phase != 1 { + return nil, fmt.Errorf("core generate started at phase %d", phase) + } + phase++ + return []*codegen.File{{Path: plan.Generation().GenPkg()}}, nil + }, + } }) registry.registerPlugin("state", "test", pluginNormal, func() Plugin { var ( diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index 61b4aa63c7..2e59089d1f 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -1,15 +1,12 @@ -// This file snapshots evaluated design expressions and verifies that only the -// lifecycle preparation phase may change them. +// This file verifies through the production lifecycle boundary that only +// preparation and normalization may change evaluated design expressions. package generator import ( - "reflect" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpcdata "goa.design/goa/v3/grpc/codegen/testdata" @@ -17,50 +14,16 @@ import ( jsonrpcdata "goa.design/goa/v3/jsonrpc/codegen/testdata" ) -type ( - // attrState captures the mutable state of a design attribute expression: - // the identity of its type, the user type naming, the object shape and - // deep copies of the meta and validation expressions. Two snapshots of - // the same attribute are equal if and only if no reachable state was - // rewritten in between. - attrState struct { - Type uintptr - Primitive expr.Kind - TypeName string - UID string - Identifier string - Views []string - UTAttr uintptr - Fields []string - Description string - Meta expr.MetaExpr - Validation *expr.ValidationExpr - DefaultValue any - } - - // visitKey identifies a visited pointer during the design walk. The type - // disambiguates a struct from its first field which share the address. - visitKey struct { - ptr uintptr - typ reflect.Type - } -) - -// TestGeneratorsTreatDesignAsReadOnly is the design purity invariant: once -// eval finalization ran and codegen.NormalizeRoot applied the only sanctioned -// post-finalization rewrite, running every generator ("gen" and "example") -// must leave the design expression tree bit for bit unchanged. The fixtures -// cover alias chains, result views, websocket streaming, SSE with anonymous -// object payloads and results (the NormalizeRoot wrapping case), mixed -// HTTP+JSON-RPC transports and gRPC unions and streaming. +// TestGeneratorsTreatDesignAsReadOnly audits the persistent design state after +// generation construction applies the sanctioned normalization. Running every +// generator ("gen" and "example") must leave the prepared semantic design +// unchanged after each callback and completed render. The fixtures cover alias +// chains, result views, websocket streaming, SSE with anonymous object payloads +// and results, mixed HTTP+JSON-RPC transports, and gRPC unions and streaming. // -// Process global state is deliberately out of the snapshot: -// - expr.GeneratedResultTypes is appended to by expr.Dup when generators -// duplicate generated result types; it is a separate eval root, not part -// of the design tree (known purity hole, documented in expr/dup.go). -// - the example randomizer seen-value cache lives on the API expression -// example generator and is legitimately filled by example and OpenAPI -// generation; the walk skips it. +// The production lifecycle snapshot owns this audit. Dormant eval.DSLFunc +// closure captures and process-global state outside the prepared roots are not +// evaluated design input and remain outside this assertion. func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { cases := []struct { Name string @@ -77,154 +40,39 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - codegen.NormalizeRoot(root) - before := snapshotDesign(root) for _, cmd := range []string{"gen", "example"} { _, err := executeGeneration("gen", []eval.Root{root}, cmd, newDefaultRegistry()) require.NoError(t, err) } - - after := snapshotDesign(root) - assert.Len(t, after, len(before), "attributes appeared in or disappeared from the design") - for att, b := range before { - a, ok := after[att] - if !assert.True(t, ok, "attribute %q (%p) disappeared from the design", b.Description, att) { - continue - } - assert.Equal(t, b, a, "attribute %q (%p) was mutated by a generator", b.Description, att) - } }) } } -// TestPreparedRootsDetectPostPrepareMutation proves that the exact design -// snapshot used by the purity boundary rejects a plugin that changes an -// expression during planning, after the only mutable lifecycle phase closed. -func TestPreparedRootsDetectPostPrepareMutation(t *testing.T) { +// TestPreparedRootsRejectAttributeMutation proves that generation stops when +// a core planner changes an attribute after the mutable lifecycle phase. +func TestPreparedRootsRejectAttributeMutation(t *testing.T) { root := expr.RunDSL(t, httpdata.AliasTypeDSL) - codegen.NormalizeRoot(root) registry := newRegistry() - var ( - prepared map[*expr.AttributeExpr]attrState - target *expr.AttributeExpr + target := root.Types[0].Attribute() + followingRan := false + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "attribute-mutator", Plan: func(_ *Plan) error { + target.Description = "changed after preparation" + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + followingRan = true + return nil + }} + }, ) - registry.registerPlugin("mutation", "test", pluginNormal, func() Plugin { - return Plugin{Prepare: func(_ string, _ []eval.Root) error { - prepared = snapshotDesign(root) - for target = range prepared { - break - } - return nil - }} - }) - registry.addCommand("test", func() coreGenerator { - return coreGenerator{Plan: func(_ *Plan) error { - target.Description = "changed after preparation" - return nil - }} - }) _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) - require.NoError(t, err) - require.NotEqual(t, prepared, snapshotDesign(root), "purity snapshot accepted a planning mutation") -} - -// snapshotDesign walks every expression reachable from the root via exported -// fields and captures the state of each attribute expression encountered. -func snapshotDesign(root *expr.RootExpr) map[*expr.AttributeExpr]attrState { - atts := make(map[*expr.AttributeExpr]attrState) - visited := make(map[visitKey]struct{}) - exampleGenType := reflect.TypeOf((*expr.ExampleGenerator)(nil)) - var walk func(v reflect.Value) - walk = func(v reflect.Value) { - switch v.Kind() { - case reflect.Pointer: - if v.IsNil() { - return - } - key := visitKey{ptr: v.Pointer(), typ: v.Type()} - if _, ok := visited[key]; ok { - return - } - visited[key] = struct{}{} - if v.Type() == exampleGenType { - // The example generator carries the randomizer seen-value - // cache which generation legitimately fills; it is not part - // of the design. - return - } - if v.CanInterface() { - if att, ok := v.Interface().(*expr.AttributeExpr); ok { - atts[att] = snapshotAttribute(att) - } - } - walk(v.Elem()) - case reflect.Interface: - if v.IsNil() { - return - } - walk(v.Elem()) - case reflect.Struct: - for i := range v.NumField() { - if v.Type().Field(i).PkgPath != "" { - continue // unexported - } - walk(v.Field(i)) - } - case reflect.Slice, reflect.Array: - for i := range v.Len() { - walk(v.Index(i)) - } - case reflect.Map: - iter := v.MapRange() - for iter.Next() { - walk(iter.Key()) - walk(iter.Value()) - } - } - } - walk(reflect.ValueOf(root)) - return atts -} - -// snapshotAttribute captures the mutable state of att. Meta and validation -// are deep copied so in-place writes are detected; the type is captured by -// identity together with the user type name, attribute and shape so renames, -// attribute swaps and field changes are detected too. -func snapshotAttribute(att *expr.AttributeExpr) attrState { - s := attrState{ - Description: att.Description, - DefaultValue: att.DefaultValue, - } - if att.Meta != nil { - s.Meta = att.Meta.Dup() - } - if att.Validation != nil { - s.Validation = att.Validation.Dup() - } - switch dt := att.Type.(type) { - case nil: - case expr.Primitive: - s.Primitive = dt.Kind() - case expr.UserType: - s.Type = reflect.ValueOf(att.Type).Pointer() - s.TypeName = dt.Name() - s.UID = dt.ID() - s.UTAttr = reflect.ValueOf(dt.Attribute()).Pointer() - if rt, ok := dt.(*expr.ResultTypeExpr); ok { - s.Identifier = rt.Identifier - for _, v := range rt.Views { - s.Views = append(s.Views, v.Name) - } - } - default: - s.Type = reflect.ValueOf(att.Type).Pointer() - } - if obj := expr.AsObject(att.Type); obj != nil { - for _, nat := range *obj { - s.Fields = append(s.Fields, nat.Name) - } - } - return s + require.ErrorContains(t, err, `core "attribute-mutator" plan mutated prepared design`) + require.False(t, followingRan) } diff --git a/codegen/generator/registry_test.go b/codegen/generator/registry_test.go index 7b8431362a..3bd7194e54 100644 --- a/codegen/generator/registry_test.go +++ b/codegen/generator/registry_test.go @@ -27,12 +27,12 @@ func testRegistry(command string, factories ...generatorFactory) *registry { } // testRegistryFromGenfuncs creates one isolated command from fixture callbacks. -func testRegistryFromGenfuncs(command string, callbacks []testGenfunc) *registry { +func testRegistryFromGenfuncs(callbacks []testGenfunc) *registry { factories := make([]generatorFactory, len(callbacks)) for i, callback := range callbacks { factories[i] = testGenerator(callback.Plan, callback.Generate) } - return testRegistry(command, factories...) + return testRegistry("gen", factories...) } // testRenderOnly adapts a root-based rendering fixture into a test callback. @@ -46,7 +46,7 @@ func testRenderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) // a fresh run factory. Retained subsystem plans replace this adapter in Tasks 7–10. func testGenerator(plan func(*codegen.Generation) error, generate func(*codegen.Generation) ([]*codegen.File, error)) generatorFactory { return func() coreGenerator { - generator := coreGenerator{} + generator := coreGenerator{name: "test"} if plan != nil { generator.Plan = func(retained *Plan) error { return plan(retained.Generation()) @@ -60,11 +60,3 @@ func testGenerator(plan func(*codegen.Generation) error, generate func(*codegen. return generator } } - -// testRenderGenerator adapts a legacy render-only test callback without adding -// a production lifecycle path. -func testRenderGenerator(generate func(string, []eval.Root) ([]*codegen.File, error)) generatorFactory { - return testGenerator(nil, func(generation *codegen.Generation) ([]*codegen.File, error) { - return generate(generation.GenPkg(), generation.Roots()) - }) -} diff --git a/codegen/generator/run_examples.go b/codegen/generator/run_examples.go new file mode 100644 index 0000000000..be3d75a51d --- /dev/null +++ b/codegen/generator/run_examples.go @@ -0,0 +1,21 @@ +// This file creates the mutable example state owned by one generation plan. +// Evaluated API roots retain only immutable factories, so repeated and +// concurrent runs never share consumed streams or recursion caches. +package generator + +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// newExampleGenerators creates one fresh mutable generator for every Goa +// design root participating in a run. +func newExampleGenerators(roots []eval.Root) map[*expr.RootExpr]*expr.ExampleGenerator { + generators := make(map[*expr.RootExpr]*expr.ExampleGenerator) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + generators[design] = expr.NewExampleGenerator(design.API.RandomizerFactory) + } + } + return generators +} diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 97752dbd42..0c037e5201 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -9,15 +9,15 @@ import ( "goa.design/goa/v3/expr" ) -// Service iterates through the roots and returns the files needed to render -// the service code. It returns an error if the roots slice does not include -// a goa design. -func Service(generation *codegen.Generation) ([]*codegen.File, error) { +// serviceFiles returns the service files described by plan's frozen package +// declarations and run-owned example state. +func serviceFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File + generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) analyses := make([]*service.ServicesData, len(designRoots)) for i, r := range designRoots { - services, err := service.NewServicesData(r, generation) + services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) if err != nil { return nil, err } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index b1dfb29287..47e6559c27 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -20,9 +20,9 @@ import ( // TestRelocatedUnionPackageNamesCompile verifies that two services and their // HTTP and gRPC transports compile against distinct unions in one shared package. func TestRelocatedUnionPackageNamesCompile(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) root := func() { @@ -111,9 +111,9 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { // HTTP and gRPC response policy binds to the equivalent error value declared by // the endpoint method instead of retaining the API declaration object. func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -142,9 +142,9 @@ func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { // TestNestedTransportMetadataOwnsRecursiveImports verifies conversion helpers // import a custom field type nested inside a relocated service declaration. func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -183,9 +183,9 @@ func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { // natural name collides with a fixed runtime import is declared and referenced // with the same generation-owned qualifier in every transport. func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -230,9 +230,9 @@ func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { // imports the relocated effective error referenced by generated HTTP and gRPC // encoders even though the method does not redeclare it. func TestInheritedTransportErrorsOwnImports(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -264,7 +264,7 @@ func TestInheritedTransportErrorsOwnImports(t *testing.T) { // TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch // aliases with one natural name but different primitive shapes remain distinct. func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) codegen.RunDSL(t, func() { first := dsl.Type("FirstValue", func() { @@ -302,7 +302,7 @@ func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { // TestServiceUnionFamilyNamesAvoidExactDeclarations verifies that union // constants and constructors cannot collide with exact DSL type names. func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) codegen.RunDSL(t, func() { kind := dsl.Type("ValueKindText", dsl.String) @@ -332,9 +332,9 @@ func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { // TestServiceFilesOwnTheirImports verifies that imports used by one service do // not leak into another service file generated from the same design root. func TestServiceFilesOwnTheirImports(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -381,9 +381,9 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { // result declarations never relocate the request/response wrappers consumed by // the raw HTTP body path. func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -441,7 +441,7 @@ func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { // reference generated packages with the same Go package name without emitting // duplicate import aliases or ambiguous qualified references. func TestServiceReferencesUseImportPathAliases(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) codegen.RunDSL(t, func() { dsl.API("path-owned aliases", func() {}) @@ -482,9 +482,9 @@ func TestServiceReferencesUseImportPathAliases(t *testing.T) { // JSON-RPC files qualify two same-basename service packages with the aliases // frozen by the shared generation. func TestTransportReferencesUseImportPathAliases(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -580,7 +580,7 @@ func generatedTreeSource(t *testing.T, root string) string { // expand a named branch definition and import packages used only where that // named type itself is declared. func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) codegen.RunDSL(t, func() { dsl.API("named branch imports", func() {}) @@ -616,7 +616,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { // object wrappers collide only with declarations emitted in the same service // package, never with a nested declaration relocated elsewhere. func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) t.Run("relocated name does not collide", func(t *testing.T) { codegen.RunDSL(t, func() { @@ -662,9 +662,9 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { }) t.Run("local name collides", func(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { dsl.API("local wrapper names", func() {}) @@ -711,7 +711,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { // used by two relocated declarations stay in their respective declaration // files and do not leak into the service file that references their package. func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{{Plan: planServiceData, Generate: Service}}) + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) codegen.RunDSL(t, func() { dsl.API("nested file-owned imports", func() {}) @@ -778,10 +778,10 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("gen", []eval.Root{root}) + generation := mustTestGeneration(t, "gen", []eval.Root{root}) require.NoError(t, planTransportData(generation)) require.NoError(t, generation.Freeze()) - files, err := Transport(generation) + files, err := testTransportFiles(generation) require.NoError(t, err) var header strings.Builder @@ -801,9 +801,9 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { // files resolve relocated streaming declarations through the frozen service // packages while their event and frame bodies remain transport-owned. func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { @@ -894,10 +894,10 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { codegen.RunDSL(t, relocatedDifferentUnionRoot()), codegen.RunDSL(t, relocatedTopLevelValueRoot()), } - generation := codegen.NewGeneration("goa.design/goa/example", roots) + generation := mustTestGeneration(t, "goa.design/goa/example", roots) require.NoError(t, planServiceData(generation)) require.NoError(t, generation.Freeze()) - files, err := Service(generation) + files, err := testServiceFiles(generation) require.NoError(t, err) var generated strings.Builder @@ -931,10 +931,10 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { // root analysis emits one shared relocated union for every referencing service. func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { root := codegen.RunDSL(t, sharedRelocatedUnionRoot()) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, servicecodegen.Plan(root, generation)) require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation) + services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) files := servicecodegen.Files("generated.local/gen", []*servicecodegen.ServicesData{services}) dir := t.TempDir() @@ -976,14 +976,14 @@ func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, servicecodegen.Plan(root, generation)) require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation) + services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) dir := t.TempDir() - files, err := Service(generation) + files, err := testServiceFiles(generation) require.NoError(t, err) files = append(files, servicecodegen.ExampleServiceFiles(generation.GenPkg(), root, services)...) for _, file := range files { @@ -1010,14 +1010,14 @@ func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { }) } }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, servicecodegen.Plan(root, generation)) require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation) + services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) dir := t.TempDir() - files, err := Service(generation) + files, err := testServiceFiles(generation) require.NoError(t, err) files = append(files, servicecodegen.ExampleInterceptorsFiles(generation.GenPkg(), root, services)...) for _, file := range files { @@ -1039,9 +1039,9 @@ func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { // TestTransportStaticAliasesCompileWithHttpAndPathServices verifies transport // imports retain their literal qualifiers beside conflicting service names. func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { - registry := testRegistryFromGenfuncs("gen", []testGenfunc{ - {Plan: planServiceData, Generate: Service}, - {Plan: planTransportData, Generate: Transport}, + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, }) codegen.RunDSL(t, func() { diff --git a/codegen/generator/test_helpers_test.go b/codegen/generator/test_helpers_test.go new file mode 100644 index 0000000000..01415c4002 --- /dev/null +++ b/codegen/generator/test_helpers_test.go @@ -0,0 +1,49 @@ +// This file provides strict construction helpers for generator lifecycle tests +// whose package roots and planning claims are deliberately valid. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// mustTestGeneration creates one generation or fails the calling test. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *codegen.Generation { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// testServiceFiles adapts the private plan-owned service assembler for package tests. +func testServiceFiles(generation *codegen.Generation) ([]*codegen.File, error) { + return serviceFiles(testPlan(generation)) +} + +// testTransportFiles adapts the private plan-owned transport assembler for package tests. +func testTransportFiles(generation *codegen.Generation) ([]*codegen.File, error) { + return transportFiles(testPlan(generation)) +} + +// testOpenAPIFiles adapts the private plan-owned OpenAPI assembler for package tests. +func testOpenAPIFiles(generation *codegen.Generation) ([]*codegen.File, error) { + return openAPIFiles(testPlan(generation)) +} + +// assembleExampleFilesForTest adapts the private plan-owned example assembler +// for package tests. +func assembleExampleFilesForTest(generation *codegen.Generation) ([]*codegen.File, error) { + return exampleFiles(testPlan(generation)) +} + +// testPlan creates the run-only state needed by a focused assembler test. +func testPlan(generation *codegen.Generation) *Plan { + return &Plan{ + generation: generation, + examples: newExampleGenerators(generation.Roots()), + } +} diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 2fa36bf7ad..4ffaf75bd8 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -11,13 +11,14 @@ import ( jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Transport iterates through the roots and returns the files needed to render -// the transport code. -func Transport(generation *codegen.Generation) ([]*codegen.File, error) { +// transportFiles returns HTTP, gRPC, and JSON-RPC files described by plan's +// frozen package declarations and run-owned example state. +func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File + generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { - services, err := service.NewServicesData(r, generation) + services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) if err != nil { return nil, err } diff --git a/codegen/import_aliases_test.go b/codegen/import_aliases_test.go index 69eccc3aa9..0b902eb7c9 100644 --- a/codegen/import_aliases_test.go +++ b/codegen/import_aliases_test.go @@ -13,7 +13,7 @@ import ( // design metadata regardless of planning order. func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { freeze := func(reverse bool) map[string]string { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) declare := []func() error{ func() error { return generation.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) @@ -52,7 +52,7 @@ func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { // path has one identity and uses its highest-priority requested spelling. func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { freeze := func(reverse bool) string { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) declare := []func() error{ func() error { return generation.RequireImport(NewImport("json", "encoding/json")) @@ -82,7 +82,7 @@ func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { // generated-package preferences for one path use deterministic spelling. func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { freeze := func(first, second string) string { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) require.NoError(t, generation.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) require.NoError(t, generation.Freeze()) @@ -95,7 +95,7 @@ func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { // TestImportAliasRejectsIncompatibleFixedRequirements verifies that static // templates cannot request two different mandatory spellings for one path. func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.RequireImport(NewImport("json", "encoding/json"))) require.ErrorContains( t, @@ -107,7 +107,7 @@ func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { // TestImportAliasRejectsFixedQualifierCollision verifies that two static // packages cannot both require the same qualifier. func TestImportAliasRejectsFixedQualifierCollision(t *testing.T) { - generation := NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/first"))) require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/second"))) require.ErrorContains(t, generation.Freeze(), "required by both") diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go index 7e7ce8d742..71bbdb2f0c 100644 --- a/codegen/name_declaration.go +++ b/codegen/name_declaration.go @@ -5,6 +5,7 @@ package codegen import ( "fmt" + "reflect" "strings" ) @@ -13,28 +14,33 @@ type ( // Types, functions, constants, and variables still share one package namespace. PackageNameKind uint8 - // PackageNameOrder supplies deterministic ordering for preferred names in - // one subsystem-owned declaration family. Implementations compare only - // values with the same PackageNameFamily. + // PackageNameOrder supplies a deterministic total order for preferred names + // in one subsystem-owned declaration family. Implementations must be named, + // non-pointer value types whose fields recursively contain immutable values. + // The package catalog compares only values of the same concrete type. PackageNameOrder interface { - PackageNameFamily() string + // ComparePackageName compares two values of the same concrete type. It + // must return a negative value when the receiver sorts first, zero only + // when both values contain identical stable ordering facts, and a positive + // value when the receiver sorts last. Its sign must be antisymmetric, and + // its less-than relation must be transitive. ComparePackageName(PackageNameOrder) int } // NameDeclaration records one package-level Go identifier. Its final name is // unavailable until the owning generation freezes. NameDeclaration struct { - kind PackageNameKind - preferred string - final string - packagePath string - exact bool - order PackageNameOrder - base *NameDeclaration - prefix string - suffix string - hashes []Hasher - frozen bool + kind PackageNameKind + preferred string + final string + owner *GeneratedPackage + exact bool + order PackageNameOrder + base *NameDeclaration + prefix string + suffix string + hashes []Hasher + frozen bool } ) @@ -60,11 +66,10 @@ func NewExactName(kind PackageNameKind, preferred string) *NameDeclaration { } // NewPreferredName creates a compiler-owned declaration whose preferred Go -// identifier may receive a deterministic numeric suffix. +// identifier may receive a deterministic numeric suffix. order must be a +// named, non-pointer value whose fields recursively contain immutable values; +// the owning package validates that constraint when it accepts the record. func NewPreferredName(kind PackageNameKind, preferred string, order PackageNameOrder) *NameDeclaration { - if order == nil { - panic("preferred package name requires stable ordering") - } return &NameDeclaration{ kind: kind, preferred: Goify(preferred, true), @@ -81,22 +86,11 @@ func (d *NameDeclaration) Name() string { return d.final } -// PreferredName returns the unsuffixed Go identifier requested during planning. -func (d *NameDeclaration) PreferredName() string { - return d.preferredName() -} - // Kind returns the declaration category used for collision diagnostics. func (d *NameDeclaration) Kind() PackageNameKind { return d.kind } -// PackagePath returns the generated import path that owns the declaration. It -// is empty until a generated package accepts the record. -func (d *NameDeclaration) PackagePath() string { - return d.packagePath -} - // String returns the declaration category used in planning errors. func (k PackageNameKind) String() string { switch k { @@ -119,9 +113,6 @@ func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffi if base == nil { panic("dependent package name requires a base declaration") } - if order == nil { - panic("dependent package name requires stable ordering") - } return &NameDeclaration{ kind: kind, order: order, @@ -131,6 +122,80 @@ func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffi } } +// comparePackageNames orders independent records without consulting discovery +// order. Equal ordering facts for distinct records are a planning error. +func comparePackageNames(left, right *NameDeclaration) int { + leftType := reflect.TypeOf(left.order) + rightType := reflect.TypeOf(right.order) + if compared := strings.Compare(leftType.PkgPath(), rightType.PkgPath()); compared != 0 { + return compared + } + if compared := strings.Compare(leftType.Name(), rightType.Name()); compared != 0 { + return compared + } + return left.order.ComparePackageName(right.order) +} + +// validateNameDeclaration rejects records that cannot identify a package-level +// Go declaration before the owning package changes its declaration catalog. +func validateNameDeclaration(declaration *NameDeclaration) error { + if !declaration.kind.valid() { + return fmt.Errorf("invalid package name kind %d", declaration.kind) + } + if declaration.preferredName() == "" { + return fmt.Errorf("package name must not be empty") + } + return nil +} + +// validatePackageNameOrder rejects ordering values whose identity or contents +// can change after collection. A named value type gives independent generators +// a stable family identity without coordinating through caller-chosen strings. +func validatePackageNameOrder(order PackageNameOrder) error { + if order == nil { + return fmt.Errorf("package name order must be a stable concrete named value type") + } + typeOf := reflect.TypeOf(order) + if typeOf.Name() == "" || typeOf.PkgPath() == "" || !isStablePackageNameOrderType(typeOf) { + return fmt.Errorf("package name order %T must be a stable concrete named value type", order) + } + return nil +} + +// isStablePackageNameOrderType reports whether values of typeOf contain only +// immutable value fields suitable for deterministic comparison after freeze. +func isStablePackageNameOrderType(typeOf reflect.Type) bool { + switch typeOf.Kind() { + case reflect.Array: + return isStablePackageNameOrderType(typeOf.Elem()) + case reflect.Struct: + for i := range typeOf.NumField() { + if !isStablePackageNameOrderType(typeOf.Field(i).Type) { + return false + } + } + return true + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, + reflect.String: + return true + default: + return false + } +} + +// packagePath returns the generated import path that owns the declaration. +// Access before package collection is an internal planning bug. +func (d *NameDeclaration) packagePath() string { + if d.owner == nil { + panic(fmt.Sprintf("package name %q has no generated package owner", d.preferredName())) + } + return d.owner.path +} + // preferredName returns the requested name, using the base declaration's // frozen spelling for linked declaration families such as union constructors. func (d *NameDeclaration) preferredName() string { @@ -144,11 +209,12 @@ func (d *NameDeclaration) preferredName() string { return d.prefix + base + d.suffix } -// comparePackageNames orders independent records without consulting discovery -// order. Equal ordering facts for distinct records are a planning error. -func comparePackageNames(left, right *NameDeclaration) int { - if compared := strings.Compare(left.order.PackageNameFamily(), right.order.PackageNameFamily()); compared != 0 { - return compared +// valid reports whether the category is represented by this catalog. +func (k PackageNameKind) valid() bool { + switch k { + case NameType, NameFunction, NameConstant, NameVariable: + return true + default: + return false } - return left.order.ComparePackageName(right.order) } diff --git a/codegen/normalize.go b/codegen/normalize.go index 2a6dfa0515..723c7e0612 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -1,48 +1,79 @@ // This file performs the one allowed post-evaluation design mutation. It gives -// raw method object shapes stable semantic user-type wrappers while leaving Go -// declaration naming to the generated service package catalog. +// raw method object shapes stable semantic user-type wrappers and records the +// exact wrapper objects so later planning never infers compiler provenance from +// a user-controlled string. package codegen -import "goa.design/goa/v3/expr" +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) -// NormalizeRoot wraps raw object payload, result, and streaming attributes in -// synthesized user types. The wrappers carry their natural preferred names and -// stable semantic identifiers; package planning later resolves Go collisions -// against declarations that are actually emitted in the service package. -// -// NormalizeRoot is idempotent and must run after prepare plugins and before -// generators read the design expression tree. -func NormalizeRoot(root *expr.RootExpr) { +// normalizeRoots wraps raw method objects in every Goa design root and returns +// the exact compiler-created declarations with their closed method roles. +func normalizeRoots(roots []eval.Root) map[expr.UserType]MethodTypeIdentity { + normalized := make(map[expr.UserType]MethodTypeIdentity) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + normalizeRoot(design, normalized) + } + } + return normalized +} + +// normalizeRoot records every wrapper created for one design root. +func normalizeRoot(root *expr.RootExpr, normalized map[expr.UserType]MethodTypeIdentity) { for _, service := range root.Services { - normalizeService(service) + normalizeService(service, normalized) } } -// normalizeService creates semantic wrappers for the raw object attributes of -// one service without consulting or mutating any Go name scope. -func normalizeService(service *expr.ServiceExpr) { +// normalizeService creates semantic wrappers for one service without +// consulting or mutating any Go name scope. +func normalizeService(service *expr.ServiceExpr, normalized map[expr.UserType]MethodTypeIdentity) { for _, method := range service.Methods { - normalizeMethodAttribute(method.Payload, NewMethodPayloadIdentity(service.Name, method.Name)) - normalizeMethodAttribute(method.StreamingPayload, NewMethodStreamingPayloadIdentity(service.Name, method.Name)) - normalizeMethodAttribute(method.Result, NewMethodResultIdentity(service.Name, method.Name)) + normalizeMethodAttribute(method.Payload, newMethodTypeIdentity( + method.Name, + methodPayloadTypeKind, + expr.MethodPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.StreamingPayload, newMethodTypeIdentity( + method.Name, + methodStreamingPayloadTypeKind, + expr.MethodStreamingPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.Result, newMethodTypeIdentity( + method.Name, + methodResultTypeKind, + expr.MethodResultExampleIdentity(method), + ), normalized) if method.HasMixedResults() { - normalizeMethodAttribute(method.StreamingResult, NewMethodStreamingResultIdentity(service.Name, method.Name)) + normalizeMethodAttribute(method.StreamingResult, newMethodTypeIdentity( + method.Name, + methodStreamingResultTypeKind, + expr.MethodStreamingResultExampleIdentity(method), + ), normalized) } } } -// normalizeMethodAttribute gives a raw method object its semantic identity. -// Existing named and non-object method types remain unchanged. -func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity) { +// normalizeMethodAttribute records typed provenance only for the wrapper it +// creates. Existing named and non-object method types remain authored values. +func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity, normalized map[expr.UserType]MethodTypeIdentity) { if attribute == nil { return } - if _, ok := attribute.Type.(*expr.Object); !ok { + if userType, ok := attribute.Type.(expr.UserType); ok { + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(userType) + if generated && exampleIdentity == identity.exampleIdentity { + normalized[userType.Origin()] = identity.bind(userType) + } return } - attribute.Type = &expr.UserTypeExpr{ - AttributeExpr: expr.DupAtt(attribute), - TypeName: identity.Name(), - UID: identity.UID(), + if _, ok := attribute.Type.(*expr.Object); !ok { + return } + wrapper := expr.NewGeneratedUserType(identity.Name(), expr.DupAtt(attribute), identity.exampleIdentity) + attribute.Type = wrapper + normalized[wrapper.Origin()] = identity.bind(wrapper) } diff --git a/codegen/service/convert.go b/codegen/service/convert.go index d0ab1225f0..58dba41c83 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "reflect" + "slices" "strconv" "strings" @@ -43,20 +44,26 @@ func ConvertFiles(root *expr.RootExpr, service *expr.ServiceExpr, services *Serv return nil, nil } - // Group conversions and creations by target package path - allPaths := make(map[string]struct{}) - conversionsByPath := groupByConvertPath(conversions, service, allPaths) - creationsByPath := groupByConvertPath(creations, service, allPaths) + // Group conversions and creations by the package claimed during planning. + allPackages := make(map[*codegen.GeneratedPackage]struct{}) + conversionsByPackage := groupByConvertPackage(conversions, service, services, allPackages) + creationsByPackage := groupByConvertPackage(creations, service, services, allPackages) - // Generate a file for each path - var files []*codegen.File - for path := range allPaths { + // Generate one file for each owning package. + owners := make([]*codegen.GeneratedPackage, 0, len(allPackages)) + for owner := range allPackages { + owners = append(owners, owner) + } + slices.SortFunc(owners, func(left, right *codegen.GeneratedPackage) int { + return strings.Compare(left.ImportPath(), right.ImportPath()) + }) + files := make([]*codegen.File, 0, len(owners)) + for _, owner := range owners { file, err := generateConvertFileForPath( - path, - conversionsByPath[path], - creationsByPath[path], + owner, + conversionsByPackage[owner], + creationsByPackage[owner], service, - svc, services, ) if err != nil { @@ -102,55 +109,33 @@ func typeMapMatchesService(c *expr.TypeMap, service *expr.ServiceExpr, svc *Data return false } -// groupByConvertPath groups the type maps by the convert.go file path derived -// from their user type location, defaulting to the service package. It -// records every path in paths so the caller can iterate the union of -// conversion and creation paths. -func groupByConvertPath(maps []*expr.TypeMap, service *expr.ServiceExpr, paths map[string]struct{}) map[string][]*expr.TypeMap { - byPath := make(map[string][]*expr.TypeMap) - for _, c := range maps { - var path string - if loc := codegen.UserTypeLocation(c.User); loc != nil { - path = filepath.Join(codegen.Gendir, filepath.Dir(loc.FilePath), "convert.go") - } else { - path = filepath.Join(codegen.Gendir, codegen.SnakeCase(service.Name), "convert.go") - } - byPath[path] = append(byPath[path], c) - paths[path] = struct{}{} - } - return byPath +// groupByConvertPackage groups type maps by the exact generated package that +// planning assigned to their service type. The owner supplies both the import +// identity and output directory used during rendering. +func groupByConvertPackage(maps []*expr.TypeMap, service *expr.ServiceExpr, services *ServicesData, packages map[*codegen.GeneratedPackage]struct{}) map[*codegen.GeneratedPackage][]*expr.TypeMap { + byPackage := make(map[*codegen.GeneratedPackage][]*expr.TypeMap) + for _, typeMap := range maps { + location := codegen.UserTypeLocation(typeMap.User) + owner := services.generation.Package(generatedPackagePath(services.generation.GenPkg(), service, location)) + byPackage[owner] = append(byPackage[owner], typeMap) + packages[owner] = struct{}{} + } + return byPackage } // generateConvertFileForPath generates a single convert.go file for the given path // containing the specified conversions and creations func generateConvertFileForPath( - convertPath string, + owner *codegen.GeneratedPackage, conversions []*expr.TypeMap, creations []*expr.TypeMap, service *expr.ServiceExpr, - svc *Data, services *ServicesData, ) (*codegen.File, error) { if len(conversions) == 0 && len(creations) == 0 { return nil, nil } - // Determine package name from path - var convertPkgName string - if len(conversions) > 0 { - if loc := codegen.UserTypeLocation(conversions[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } else if len(creations) > 0 { - if loc := codegen.UserTypeLocation(creations[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } - // Collect the complete external package paths referenced by this file. externalPaths := make(map[string]struct{}) for _, c := range conversions { @@ -172,15 +157,11 @@ func generateConvertFileForPath( paths = append(paths, importPath) } - outputPath := servicePackagePath(services.generation.GenPkg(), service) - first := append(append([]*expr.TypeMap(nil), conversions...), creations...)[0] - if loc := codegen.UserTypeLocation(first.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) - } + outputPath := owner.ImportPath() sections := []*codegen.SectionTemplate{ codegen.Header( service.Name+" service type conversion functions", - convertPkgName, + codegen.Goify(path.Base(outputPath), false), services.fileImports(outputPath, paths), ), } @@ -203,10 +184,6 @@ func generateConvertFileForPath( } tgtPkg := services.aliases.name(pkgImport) - outputPath := servicePackagePath(services.generation.GenPkg(), service) - if loc := codegen.UserTypeLocation(c.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) - } srcAtt := &expr.AttributeExpr{Type: c.User} srcResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(srcAtt) srcCtx := &codegen.AttributeContext{ @@ -258,10 +235,6 @@ func generateConvertFileForPath( srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) tgtAtt := &expr.AttributeExpr{Type: c.User} - outputPath := servicePackagePath(services.generation.GenPkg(), service) - if loc := codegen.UserTypeLocation(c.User); loc != nil { - outputPath = generatedPackagePath(services.generation.GenPkg(), service, loc) - } tgtResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(tgtAtt) tgtCtx := &codegen.AttributeContext{ UseDefault: true, @@ -307,7 +280,10 @@ func generateConvertFileForPath( }) } - return &codegen.File{Path: convertPath, SectionTemplates: sections}, nil + return &codegen.File{ + Path: filepath.Join(owner.OutputDirectory(), "convert.go"), + SectionTemplates: sections, + }, nil } func commonPath(sep byte, paths ...string) string { diff --git a/codegen/service/convert_test.go b/codegen/service/convert_test.go index a90dae6dd0..c4080b501c 100644 --- a/codegen/service/convert_test.go +++ b/codegen/service/convert_test.go @@ -1,3 +1,5 @@ +// This file verifies service conversion paths, generated helper declarations, +// and the exact package names used by both definitions and references. package service import ( @@ -318,6 +320,26 @@ func TestConvertFiles(t *testing.T) { "gen/models/convert.go": 3, // header + convert-to + create-from sections }, }, + { + "noncanonical-location-uses-owned-package", + func() { + filter := dsl.Type("FilterConfig", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.CreateFrom(testdata.TestFilterConfig{}) + dsl.ConvertTo(testdata.TestFilterConfig{}) + dsl.Attribute("name", dsl.String) + dsl.Attribute("enabled", dsl.Boolean) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "enabled", "value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(filter) }) + }) + }, + map[string]int{ + "gen/types/convert.go": 3, + }, + }, } for _, c := range cases { diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index 5319d53a93..aa5b89c220 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -89,7 +89,7 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe return r.qualify(owner, declaration.Name()) case *expr.Union: owner := r.owner(att) - declaration, err := r.generation.GeneratedPackage(owner).Union(actual) + declaration, err := r.generation.Package(owner).Union(actual) if err != nil { panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.service.Name, owner, err)) } @@ -235,7 +235,7 @@ func (*declarationResolver) IsSumType() bool { // Scope returns the frozen name scope owned by the resolver's current package. func (r *declarationResolver) Scope() *codegen.NameScope { - return r.generation.GeneratedPackage(r.currentPath).Scope() + return r.generation.Package(r.currentPath).Scope() } // owner returns the import path that owns att. View projections stay in the @@ -252,7 +252,7 @@ func (r *declarationResolver) owner(att *expr.AttributeExpr) string { // userType selects an exact, generated union branch, or rebuilt view record. func (r *declarationResolver) userType(owner string, userType expr.UserType) *codegen.TypeDeclaration { - generatedPackage := r.generation.GeneratedPackage(owner) + generatedPackage := r.generation.Package(owner) if identity, ok := r.derived[userType.Origin()]; ok { declaration, err := generatedPackage.DerivedType(identity) if err != nil { diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index 75798155a6..1acbad3697 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -31,8 +31,8 @@ func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { }) relocated.Attribute().AddMeta("struct:pkg:path", "types") - generation := codegen.NewGeneration("generated.local/gen", nil) - types := generation.GeneratedPackage("generated.local/gen/types") + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") _, err := types.DeclareUserType(relocated) require.NoError(t, err) _, err = types.DeclareUserType(resolverUserType("ValueText", expr.Int)) @@ -104,14 +104,14 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( }) container.Attribute().AddMeta("struct:pkg:path", "types") - generation := codegen.NewGeneration("generated.local/gen", nil) - servicePackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) localDeclaration, err := servicePackage.DeclareUserType(local) require.NoError(t, err) - errorsPackage := generation.GeneratedPackage("generated.local/gen/errors") + errorsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/errors") _, err = errorsPackage.DeclareUserType(relocated) require.NoError(t, err) - typesPackage := generation.GeneratedPackage("generated.local/gen/types") + typesPackage := mustClaimTestPackage(t, generation, "generated.local/gen/types") _, err = typesPackage.DeclareUserType(container) require.NoError(t, err) require.NoError(t, generation.Freeze()) @@ -151,8 +151,8 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( // fails immediately instead of allocating a missing declaration. func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { service := &expr.ServiceExpr{Name: "Missing"} - generation := codegen.NewGeneration("generated.local/gen", nil) - generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) + generation := mustTestGeneration(t, "generated.local/gen", nil) + mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) require.NoError(t, generation.Freeze()) resolver := newServiceResolver( generation, @@ -213,7 +213,7 @@ func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T // service analysis for the package paths exercised by a focused resolver test. func aliasesForTest(t *testing.T, paths ...string) *importAliases { t.Helper() - generation := codegen.NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) for _, importPath := range paths { require.NoError(t, generation.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) } diff --git a/codegen/service/example_generator_test.go b/codegen/service/example_generator_test.go new file mode 100644 index 0000000000..9de44669a4 --- /dev/null +++ b/codegen/service/example_generator_test.go @@ -0,0 +1,75 @@ +// This file verifies that service analysis retains the exact mutable example +// generator owned by its generation run. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestServicesDataRetainsRunExampleGenerator(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + }) + }) + }) + root.API.RandomizerFactory = expr.NewDeterministicRandomizerFactory() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + + services, err := NewServicesData(root, generation, examples) + + require.NoError(t, err) + attribute := &expr.AttributeExpr{Type: expr.String} + method := root.Services[0].Methods[0] + owner := expr.MethodResultExampleIdentity(method) + require.Equal(t, "abc123", services.Example(attribute, owner)) + require.Equal(t, "abc123", services.FieldExample(attribute, attribute, "value", owner)) +} + +func TestRepeatedServiceAnalysisKeepsAnonymousExamplesStable(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Primitive", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + }) + dsl.Method("Array", func() { + dsl.Payload(dsl.ArrayOf(dsl.String)) + dsl.Result(dsl.ArrayOf(dsl.Int)) + }) + dsl.Method("Map", func() { + dsl.Payload(dsl.MapOf(dsl.String, dsl.Int)) + dsl.Result(dsl.MapOf(dsl.Int, dsl.String)) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + + first, err := NewServicesData(root, generation, examples) + require.NoError(t, err) + second, err := NewServicesData(root, generation, examples) + require.NoError(t, err) + require.Len(t, first.Get("Values").Methods, 3) + require.Len(t, second.Get("Values").Methods, 3) + for index, firstMethod := range first.Get("Values").Methods { + secondMethod := second.Get("Values").Methods[index] + require.Equal(t, firstMethod.PayloadEx, secondMethod.PayloadEx, firstMethod.Name+" payload") + require.Equal(t, firstMethod.ResultEx, secondMethod.ResultEx, firstMethod.Name+" result") + } +} diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 38664e2ffa..e0114f31dc 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -7,7 +7,6 @@ package service import ( "fmt" "path" - "path/filepath" "slices" "strings" @@ -27,15 +26,8 @@ type ( // plannedUserType identifies one user type emitted in one generated package. // The same expression may be copied into two packages through Extend. plannedUserType struct { - userType expr.UserType - packagePath string - } - - // methodTypeCandidate identifies one normalized method role and the typed - // declaration identity allocated for it. - methodTypeCandidate struct { - attribute *expr.AttributeExpr - identity codegen.MethodTypeIdentity + userType expr.UserType + owner *codegen.GeneratedPackage } // unionBranch identifies a generated user type that exists only to name one @@ -54,10 +46,8 @@ type ( // generatedPackageData owns the render data emitted into one Go package. generatedPackageData struct { - outputPath string - packageName string - types map[*codegen.TypeDeclaration]*generatedTypeData - unions map[codegen.UnionTypeID]*UnionTypeData + types map[*codegen.TypeDeclaration]*generatedTypeData + unions map[codegen.UnionTypeID]*UnionTypeData } // generatedTypeData owns one relocated user-type declaration and optional @@ -78,19 +68,18 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { if !generation.HasRoot(root) { return rootMembershipError(root) } - if err := planImports(root, generation); err != nil { - return err - } inputs := planningInputs(root) rootTypes := newRootTypeSet(root) - methodTypes, err := planMethodTypes(root, generation) - if err != nil { - return err - } for _, service := range root.Services { // The service package record makes NewServicesData a render-only contract: // its scope is unavailable until the generation freezes. - generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) + if _, err := generation.ClaimPackage(servicePackagePath(generation.GenPkg(), service)); err != nil { + return err + } + } + methodTypes, err := planMethodTypes(root, generation) + if err != nil { + return err } seenTypes := make(map[plannedUserType]struct{}) @@ -106,7 +95,10 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { return err } } - return planViews(root, generation, rootTypes) + if err := planViews(root, generation, rootTypes); err != nil { + return err + } + return planImports(root, inputs, generation) } // rootMembershipError reports an attempt to plan or analyze a design root @@ -115,35 +107,36 @@ func rootMembershipError(root *expr.RootExpr) error { return fmt.Errorf("service root %p does not belong to the generation", root) } -// planMethodTypes declares the semantic wrappers created by NormalizeRoot as -// derived service-package declarations. Exact user types in the same package +// planMethodTypes declares the semantic wrappers created when NewGeneration +// takes ownership of raw method objects. Exact user types in the same package // are planned separately and therefore keep their authored names. func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { planned := make(map[expr.UserType]codegen.DerivedTypeID) for _, service := range root.Services { - generatedPackage := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) + generatedPackage := generation.Package(servicePackagePath(generation.GenPkg(), service)) for _, method := range service.Methods { - attributes := []methodTypeCandidate{ - {method.Payload, codegen.NewMethodPayloadIdentity(service.Name, method.Name)}, - {method.StreamingPayload, codegen.NewMethodStreamingPayloadIdentity(service.Name, method.Name)}, - {method.Result, codegen.NewMethodResultIdentity(service.Name, method.Name)}, + attributes := []*expr.AttributeExpr{ + method.Payload, + method.StreamingPayload, + method.Result, } if method.HasMixedResults() { - attributes = append(attributes, methodTypeCandidate{ - attribute: method.StreamingResult, - identity: codegen.NewMethodStreamingResultIdentity(service.Name, method.Name), - }) + attributes = append(attributes, method.StreamingResult) } - for _, candidate := range attributes { - userType, ok := candidate.attribute.Type.(expr.UserType) - if !ok || !candidate.identity.Matches(userType) { + for _, attribute := range attributes { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + identity, ok := generation.NormalizedMethodType(userType) + if !ok { continue } - _, identity, err := generatedPackage.DeclareMethodType(candidate.identity, userType) + _, derived, err := generatedPackage.DeclareMethodType(identity, userType) if err != nil { return nil, err } - planned[userType.Origin()] = identity + planned[userType.Origin()] = derived } } } @@ -204,15 +197,16 @@ func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, loc if typeLocation == nil { typeLocation = location } - key := plannedUserType{ - userType: declaredType, - packagePath: generatedPackagePath(generation.GenPkg(), service, typeLocation), + owner, err := claimGeneratedPackage(generation, service, typeLocation) + if err != nil { + return err } + key := plannedUserType{userType: declaredType, owner: owner} if _, ok := seen[key]; ok { return nil } seen[key] = struct{}{} - if _, err := generation.GeneratedPackage(key.packagePath).DeclareUserType(declaredType); err != nil { + if _, err := owner.DeclareUserType(declaredType); err != nil { return err } return recurse(actual.Attribute(), typeLocation) @@ -261,10 +255,11 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati if typeLocation == nil { typeLocation = location } - key := plannedUserType{ - userType: declaredType, - packagePath: generatedPackagePath(generation.GenPkg(), service, typeLocation), + owner, err := claimGeneratedPackage(generation, service, typeLocation) + if err != nil { + return err } + key := plannedUserType{userType: declaredType, owner: owner} if _, ok := seen[key]; ok { return nil } @@ -284,8 +279,10 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati } return recurse(actual.ElemType, location) case *expr.Union: - packagePath := generatedPackagePath(generation.GenPkg(), service, location) - generatedPackage := generation.GeneratedPackage(packagePath) + generatedPackage, err := claimGeneratedPackage(generation, service, location) + if err != nil { + return err + } if _, err := generatedPackage.DeclareUnion(actual); err != nil { return err } @@ -313,7 +310,10 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *rootTypeSet) error { for _, service := range root.Services { viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" - views := generation.GeneratedPackage(viewsPath) + views, err := generation.ClaimPackage(viewsPath) + if err != nil { + return err + } seenProjected := make(map[expr.UserType]expr.UserType) derived := make(map[expr.UserType]codegen.DerivedTypeID) var projectedRoots []*expr.AttributeExpr @@ -321,7 +321,7 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r if !hasResultType(method.Result) { continue } - projected, source := projectedResultRoot(service, method) + projected, source := projectedResultRoot(generation, method) pairs := projectTypePairs(projected, source, seenProjected) for _, pair := range pairs { identity := codegen.NewProjectedTypeID(pair.source) @@ -334,7 +334,7 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r projectedRoots = append(projectedRoots, projected) if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { - serviceTypes := generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)) + serviceTypes := generation.Package(servicePackagePath(generation.GenPkg(), service)) if _, err := serviceTypes.Type(rootTypes.canonical(resultType)); err != nil { return err } @@ -455,8 +455,23 @@ func (s *rootTypeSet) contains(userType expr.UserType) bool { return ok } -// generatedPackagePath returns the actual import path of the package selected -// by location, or the service package when location is nil. +// claimGeneratedPackage preserves the relative path spelling supplied by +// design metadata so Generation can reject two claims that resolve to one +// output package. An absolute path violates the metadata contract instead of +// selecting a package beneath the generated module by string concatenation. +func claimGeneratedPackage(generation *codegen.Generation, service *expr.ServiceExpr, location *codegen.Location) (*codegen.GeneratedPackage, error) { + if location == nil { + return generation.ClaimPackage(servicePackagePath(generation.GenPkg(), service)) + } + if path.IsAbs(location.RelImportPath) { + return nil, fmt.Errorf("generated package location %q must be relative", location.RelImportPath) + } + claim := strings.TrimSuffix(generation.GenPkg(), "/") + "/" + location.RelImportPath + return generation.ClaimPackage(claim) +} + +// generatedPackagePath returns the canonical import path selected by location, +// or the service package when location is nil. func generatedPackagePath(genpkg string, service *expr.ServiceExpr, location *codegen.Location) string { if location != nil { return path.Join(genpkg, location.RelImportPath) @@ -474,22 +489,15 @@ func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { // by location, creating that owner on first use. func (d *ServicesData) generatedPackage(service *expr.ServiceExpr, location *codegen.Location) *generatedPackageData { importPath := generatedPackagePath(d.generation.GenPkg(), service, location) - if generatedPackage, ok := d.packages[importPath]; ok { + owner := d.generation.Package(importPath) + if generatedPackage, ok := d.packages[owner]; ok { return generatedPackage } - outputPath := filepath.Join(codegen.Gendir, codegen.SnakeCase(service.Name)) - packageName := strings.ToLower(codegen.Goify(service.Name, false)) - if location != nil { - outputPath = filepath.Join(codegen.Gendir, filepath.FromSlash(location.RelImportPath)) - packageName = location.PackageName() - } generatedPackage := &generatedPackageData{ - outputPath: outputPath, - packageName: packageName, - types: make(map[*codegen.TypeDeclaration]*generatedTypeData), - unions: make(map[codegen.UnionTypeID]*UnionTypeData), + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[codegen.UnionTypeID]*UnionTypeData), } - d.packages[importPath] = generatedPackage + d.packages[owner] = generatedPackage return generatedPackage } @@ -574,7 +582,7 @@ func (d *ServicesData) registerMethodType(service *expr.ServiceExpr, attribute * return nil } userType := attribute.Type.(expr.UserType) - declaration, err := d.generation.GeneratedPackage( + declaration, err := d.generation.Package( generatedPackagePath(d.generation.GenPkg(), service, location), ).UserType(d.rootTypes.canonical(userType)) if err != nil { diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 2cabb2431d..0c399e4d69 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -83,9 +83,9 @@ func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*imp return &importAliases{generation: generation}, nil } -// planImports registers every fixed and design-selected package path reachable -// from root in the generation-wide alias catalog. -func planImports(root *expr.RootExpr, generation *codegen.Generation) error { +// planImports registers every fixed package and every external or generated +// package referenced by declarations selected for emission from root. +func planImports(root *expr.RootExpr, inputs []plannedAttribute, generation *codegen.Generation) error { fixed := []*codegen.ImportSpec{ codegen.SimpleImport("bytes"), codegen.SimpleImport("context"), @@ -114,23 +114,11 @@ func planImports(root *expr.RootExpr, generation *codegen.Generation) error { } } seen := make(map[expr.UserType]struct{}) - for _, userType := range root.Types { - if err := planAttributeImports(&expr.AttributeExpr{Type: userType}, generation, seen); err != nil { + for _, input := range inputs { + if err := planAttributeImports(input.attribute, generation, seen); err != nil { return err } } - for _, resultType := range root.ResultTypes { - if err := planAttributeImports(&expr.AttributeExpr{Type: resultType}, generation, seen); err != nil { - return err - } - } - for _, service := range root.Services { - for _, attribute := range serviceReferenceAttributes(service) { - if err := planAttributeImports(attribute, generation, seen); err != nil { - return err - } - } - } for _, typeMap := range append(append([]*expr.TypeMap(nil), root.Conversions...), root.Creations...) { importPath, alias, err := getExternalTypeInfo(typeMap.External) if err != nil { @@ -157,9 +145,10 @@ func planAttributeImports(attribute *expr.AttributeExpr, generation *codegen.Gen switch actual := attribute.Type.(type) { case expr.UserType: if location := codegen.UserTypeLocation(actual); location != nil { + owner := generation.Package(path.Join(generation.GenPkg(), location.RelImportPath)) if err := generation.DeclareImport(codegen.NewImport( - location.PackageName(), - path.Join(generation.GenPkg(), location.RelImportPath), + strings.ToLower(codegen.Goify(path.Base(owner.ImportPath()), false)), + owner.ImportPath(), )); err != nil { return err } @@ -275,7 +264,7 @@ func (c *importCollector) addLocation(location *codegen.Location) { if location == nil { return } - importPath := path.Join(c.genpkg, location.RelImportPath) + importPath := c.aliases.generation.Package(path.Join(c.genpkg, location.RelImportPath)).ImportPath() if importPath != c.outputPackage { c.paths[importPath] = struct{}{} } diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index 5bce793d3a..cab360a910 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -31,10 +31,10 @@ func TestPlanRejectsUnregisteredRoot(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.ErrorContains(t, Plan(root, generation), "does not belong") require.NoError(t, generation.Freeze()) - _, err := NewServicesData(root, generation) + _, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.ErrorContains(t, err, "does not belong") } @@ -53,7 +53,7 @@ func TestPlanUsesCopiedGenerationRoots(t *testing.T) { }) }) roots := []eval.Root{first} - generation := codegen.NewGeneration("generated.local/gen", roots) + generation := mustTestGeneration(t, "generated.local/gen", roots) roots[0] = second returnedRoots := generation.Roots() returnedRoots[0] = second @@ -64,9 +64,9 @@ func TestPlanUsesCopiedGenerationRoots(t *testing.T) { roots[0] = nil returnedRoots = generation.Roots() returnedRoots[0] = second - _, err := NewServicesData(first, generation) + _, err := NewServicesData(first, generation, expr.NewExampleGenerator(first.API.RandomizerFactory)) require.NoError(t, err) - _, err = NewServicesData(second, generation) + _, err = NewServicesData(second, generation, expr.NewExampleGenerator(second.API.RandomizerFactory)) require.ErrorContains(t, err, "does not belong") } @@ -74,7 +74,7 @@ func TestPlanUsesCopiedGenerationRoots(t *testing.T) { // retain their canonical qualifier when metadata prefers another spelling for // the same complete package path. func TestImportAliasesUsePathAsIdentity(t *testing.T) { - generation := codegen.NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("jason", "encoding/json"))) require.NoError(t, generation.Freeze()) @@ -88,7 +88,7 @@ func TestImportAliasesUsePathAsIdentity(t *testing.T) { // spellings for one path produce the same frozen qualifier in either order. func TestImportAliasPreferenceIsOrderIndependent(t *testing.T) { freeze := func(first, second string) string { - generation := codegen.NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.DeclareImport(codegen.NewImport(first, "example.com/value"))) require.NoError(t, generation.DeclareImport(codegen.NewImport(second, "example.com/value"))) require.NoError(t, generation.Freeze()) @@ -118,13 +118,13 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { } firstRoot := rootWithPreference("First", "FirstPayload", "zeta") secondRoot := rootWithPreference("Second", "SecondPayload", "alpha") - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{firstRoot, secondRoot}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{firstRoot, secondRoot}) require.NoError(t, Plan(firstRoot, generation)) require.NoError(t, Plan(secondRoot, generation)) require.NoError(t, generation.Freeze()) - first, err := NewServicesData(firstRoot, generation) + first, err := NewServicesData(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) require.NoError(t, err) - second, err := NewServicesData(secondRoot, generation) + second, err := NewServicesData(secondRoot, generation, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) require.NoError(t, err) require.Equal(t, "alpha", first.aliases.name("example.com/shared/value")) require.Equal(t, first.aliases.name("example.com/shared/value"), second.aliases.name("example.com/shared/value")) @@ -155,7 +155,7 @@ func TestImportAliasesReserveFixedJSON(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, Plan(root, generation)) require.NoError(t, generation.Freeze()) aliases, err := newImportAliases(root, generation) @@ -301,7 +301,7 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { // a union field type and the import declaration come from the same frozen path // binding when encoding/json already owns the preferred json name. func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { - generation := codegen.NewGeneration("generated.local/gen", nil) + generation := mustTestGeneration(t, "generated.local/gen", nil) require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) @@ -316,7 +316,7 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { Attribute: branch, }}, } - generatedPackage := generation.GeneratedPackage("generated.local/gen/values") + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") _, err := generatedPackage.DeclareUnion(union) require.NoError(t, err) require.NoError(t, generation.Freeze()) diff --git a/codegen/service/service.go b/codegen/service/service.go index 6a80f08b34..2e2a626ad4 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -4,8 +4,11 @@ package service import ( "fmt" + "path" "path/filepath" + "slices" "sort" + "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -247,18 +250,22 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F return nil } aliases := analyses[0].aliases - packagePaths := make([]string, 0, len(packages)) - for packagePath := range packages { - packagePaths = append(packagePaths, packagePath) + packageOwners := make([]*codegen.GeneratedPackage, 0, len(packages)) + for owner := range packages { + packageOwners = append(packageOwners, owner) } - sort.Strings(packagePaths) + slices.SortFunc(packageOwners, func(left, right *codegen.GeneratedPackage) int { + return strings.Compare(left.ImportPath(), right.ImportPath()) + }) var files []*codegen.File - for _, packagePath := range packagePaths { - generatedPackage := packages[packagePath] + for _, owner := range packageOwners { + packagePath := owner.ImportPath() + packageName := codegen.Goify(path.Base(packagePath), false) + generatedPackage := packages[owner] typesByFile := make(map[string][]*generatedTypeData) for _, generatedType := range generatedPackage.types { - filePath := filepath.Join(codegen.Gendir, generatedType.location.FilePath) + filePath := filepath.Join(owner.OutputDirectory(), filepath.Base(generatedType.location.FilePath)) typesByFile[filePath] = append(typesByFile[filePath], generatedType) } filePaths := make([]string, 0, len(typesByFile)) @@ -277,7 +284,7 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F } imports := collector.imports() sections := []*codegen.SectionTemplate{ - codegen.Header("User types", generatedPackage.packageName, imports), + codegen.Header("User types", packageName, imports), } for _, generatedType := range generatedTypes { sections = append(sections, generatedType.section) @@ -308,7 +315,7 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F } imports := collector.imports() sections := []*codegen.SectionTemplate{ - codegen.Header("Union types", generatedPackage.packageName, imports), + codegen.Header("Union types", packageName, imports), } for _, union := range unions { sections = append(sections, &codegen.SectionTemplate{ @@ -318,7 +325,7 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F }) } files = append(files, &codegen.File{ - Path: filepath.Join(generatedPackage.outputPath, "unions.go"), + Path: filepath.Join(owner.OutputDirectory(), "unions.go"), SectionTemplates: sections, }) } @@ -328,19 +335,17 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F // aggregateGeneratedPackages selects one render section per canonical package // declaration across all analyzed roots without mutating generation state. -func aggregateGeneratedPackages(analyses []*ServicesData) map[string]*generatedPackageData { - packages := make(map[string]*generatedPackageData) +func aggregateGeneratedPackages(analyses []*ServicesData) map[*codegen.GeneratedPackage]*generatedPackageData { + packages := make(map[*codegen.GeneratedPackage]*generatedPackageData) for _, services := range analyses { - for packagePath, analyzedPackage := range services.packages { - generatedPackage, ok := packages[packagePath] + for owner, analyzedPackage := range services.packages { + generatedPackage, ok := packages[owner] if !ok { generatedPackage = &generatedPackageData{ - outputPath: analyzedPackage.outputPath, - packageName: analyzedPackage.packageName, - types: make(map[*codegen.TypeDeclaration]*generatedTypeData), - unions: make(map[codegen.UnionTypeID]*UnionTypeData), + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[codegen.UnionTypeID]*UnionTypeData), } - packages[packagePath] = generatedPackage + packages[owner] = generatedPackage } for declaration, generatedType := range analyzedPackage.types { if _, exists := generatedPackage.types[declaration]; !exists { diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index e6e90c21a3..57ad005285 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -40,8 +40,9 @@ type ( Services map[string]*Data generation *codegen.Generation + examples *expr.ExampleGenerator aliases *importAliases - packages map[string]*generatedPackageData + packages map[*codegen.GeneratedPackage]*generatedPackageData rootTypes *rootTypeSet } @@ -694,7 +695,7 @@ type ( // NewServicesData analyzes root using declarations frozen by generation. // Call Plan for every participating root and freeze generation first. -func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*ServicesData, error) { +func NewServicesData(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*ServicesData, error) { aliases, err := newImportAliases(root, generation) if err != nil { return nil, err @@ -703,12 +704,13 @@ func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*Serv Root: root, Services: make(map[string]*Data), generation: generation, + examples: examples, aliases: aliases, - packages: make(map[string]*generatedPackageData), + packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), rootTypes: newRootTypeSet(root), } for _, service := range root.Services { - generation.GeneratedPackage(servicePackagePath(generation.GenPkg(), service)).Scope() + generation.Package(servicePackagePath(generation.GenPkg(), service)).Scope() analyzed, err := data.analyze(service) if err != nil { return nil, err @@ -718,6 +720,21 @@ func NewServicesData(root *expr.RootExpr, generation *codegen.Generation) (*Serv return data, nil } +// Example computes attribute's example below the explicit semantic owner. +func (d *ServicesData) Example(attribute *expr.AttributeExpr, owner expr.ExampleIdentity) any { + return attribute.Example(d.examples.At(owner)) +} + +// FieldExample computes attribute's example using the same stable field +// identity as the corresponding field in parent. Named user types own their +// fields globally; anonymous parents keep the caller-supplied owner. +func (d *ServicesData) FieldExample(attribute, parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) any { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) + } + return attribute.Example(d.examples.At(owner).Member(name)) +} + // Get retrieves the analyzed data for the service with the given name. It // returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *Data { @@ -869,11 +886,11 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { projTypes []*ProjectedTypeData viewedRTs []*ViewedResultTypeData ) - servicePackage := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg(), service)) + servicePackage := d.generation.Package(servicePackagePath(d.generation.GenPkg(), service)) scope := servicePackage.Scope().Fork() scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - viewScope := d.generation.GeneratedPackage( + viewScope := d.generation.Package( servicePackagePath(d.generation.GenPkg(), service) + "/views", ).Scope().Fork() pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") @@ -951,10 +968,10 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { } // Collect projected types if hasResultType(m.Result) { - projected, result := projectedResultRoot(service, m) + projected, result := projectedResultRoot(d.generation, m) pairs := projectTypePairs(projected, result, seenProjected) removeMeta(projected) - views := d.generation.GeneratedPackage(servicePackagePath(d.generation.GenPkg(), service) + "/views") + views := d.generation.Package(servicePackagePath(d.generation.GenPkg(), service) + "/views") for _, pair := range pairs { identity := codegen.NewProjectedTypeID(pair.source) viewDerived[pair.projected.Origin()] = identity @@ -987,16 +1004,16 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { // A function to record method user types so that forced types are not // collected twice. Raw object method types are wrapped into synthesized - // user types by codegen.NormalizeRoot before any generator runs: analyze - // reads the design and never mutates it, so a raw object here means the - // root was not normalized. + // user types when codegen.NewGeneration takes ownership of the evaluated + // roots: analyze reads the design and never mutates it, so a raw object here + // means the caller skipped generation construction. recordMethodType := func(m *expr.MethodExpr, att *expr.AttributeExpr) { if att == nil || att.Type == expr.Empty { return } if _, ok := att.Type.(*expr.Object); ok { panic(fmt.Sprintf( - "service %q method %q declares a raw object type: codegen.NormalizeRoot must run after eval finalization and before the generators read the design", + "service %q method %q declares a raw object type: codegen.NewGeneration must own the finalized design before generators read it", service.Name, m.Name)) // bug } if ut, ok := att.Type.(expr.UserType); ok { @@ -1071,7 +1088,7 @@ func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { } projected := seenProj[rt.Origin()] projAtt := &expr.AttributeExpr{Type: projected.Type} - viewedDeclaration, err := d.generation.GeneratedPackage( + viewedDeclaration, err := d.generation.Package( servicePackagePath(d.generation.GenPkg(), service) + "/views", ).DerivedType(codegen.NewViewedResultTypeID(rt)) if err != nil { @@ -1371,7 +1388,7 @@ func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr. } key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(dt)} if _, ok := unions[key]; !ok { - generatedPackage := d.generation.GeneratedPackage(packagePath) + generatedPackage := d.generation.Package(packagePath) declaration, err := generatedPackage.Union(dt) if err != nil { return err @@ -1566,7 +1583,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", payloadName, m.Service.Name, m.Name) } - payloadEx = m.Payload.Example(d.Root.API.ExampleGenerator) + payloadEx = m.Payload.Example(d.examples.At(expr.MethodPayloadExampleIdentity(m))) } if m.Result.Type != expr.Empty { resultLoc = codegen.UserTypeLocation(m.Result.Type) @@ -1576,7 +1593,7 @@ func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameSc resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", rname, m.Service.Name, m.Name) } - resultEx = m.Result.Example(d.Root.API.ExampleGenerator) + resultEx = m.Result.Example(d.examples.At(expr.MethodResultExampleIdentity(m))) } if len(m.Errors) > 0 { errors = make([]*ErrorInitData, len(m.Errors)) @@ -1702,7 +1719,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", srname, m.Service.Name, m.Name) } - data.StreamingResultEx = m.StreamingResult.Example(d.Root.API.ExampleGenerator) + data.StreamingResultEx = m.StreamingResult.Example(d.examples.At(expr.MethodStreamingResultExampleIdentity(m))) } if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { @@ -1713,7 +1730,7 @@ func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vnam spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", spayloadName, m.Service.Name, m.Name) } - spayloadEx = m.StreamingPayload.Example(d.Root.API.ExampleGenerator) + spayloadEx = m.StreamingPayload.Example(d.examples.At(expr.MethodStreamingPayloadExampleIdentity(m))) } // For JSON-RPC WebSocket: // - Client streaming (no result streaming): no endpoint struct needed, just payload @@ -2082,13 +2099,13 @@ func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserT } // projectedResultRoot returns the root attribute used to collect projected -// view types for m.Result. NormalizeRoot synthesizes user types for raw object -// method results before service analysis; projected view collection keeps the -// pre-normalization shape by traversing those synthetic wrappers' attributes -// directly instead of generating view-local types for the wrappers themselves. -func projectedResultRoot(service *expr.ServiceExpr, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - identity := codegen.NewMethodResultIdentity(service.Name, m.Name) - if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok && identity.Matches(ut) { +// view types for m.Result. Compiler-created method wrappers retain their exact +// provenance in generation, so authored types with matching text stay intact. +func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { + if _, normalized := generation.NormalizedMethodType(ut); !normalized { + return expr.DupAtt(m.Result), m.Result + } return expr.DupAtt(ut.Attribute()), ut.Attribute() } return expr.DupAtt(m.Result), m.Result diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index 0ec5aa1409..e1a5d4088a 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -14,8 +14,8 @@ import ( func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - generation := codegen.NewGeneration("gen", nil) - pkg := generation.GeneratedPackage("gen/service") + generation := mustTestGeneration(t, "gen", nil) + pkg := mustClaimTestPackage(t, generation, "gen/service") _, err := pkg.DeclareUnion(union) require.NoError(t, err) require.NoError(t, generation.Freeze()) diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index bda4e60176..44c6602590 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -67,10 +67,10 @@ func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *codegen.Location) map[string]string { t.Helper() service := &expr.ServiceExpr{Name: "test"} - generation := codegen.NewGeneration("generated.local/gen", nil) - generatedPackage := generation.GeneratedPackage( - generatedPackagePath(generation.GenPkg(), service, loc), - ) + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, + generatedPackagePath(generation.GenPkg(), service, loc)) + object := att.Type.(*expr.Object) for _, named := range *object { _, err := generatedPackage.DeclareUnion(named.Attribute.Type.(*expr.Union)) @@ -84,7 +84,7 @@ func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *co services := &ServicesData{ generation: generation, aliases: aliasesForTest(t, generatedPackagePath(generation.GenPkg(), service, loc)), - packages: make(map[string]*generatedPackageData), + packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), } seen := make(map[expr.UserType]struct{}) unionByHash := make(map[unionDataKey]*UnionTypeData) diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 17b127db7e..356d446bf5 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -46,13 +46,13 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { }) }) - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) require.NoError(t, Plan(root, generation)) require.Panics(t, func() { - _, _ = NewServicesData(root, generation) + _, _ = NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) }) require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation) + services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) first := services.Get("First") @@ -68,7 +68,7 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { require.Equal(t, "Value", first.unions[0].Name) require.Equal(t, "ValueKind", first.unions[0].KindName) - _, err = generation.GeneratedPackage("goa.design/goa/example/types").DeclareUserType(shared) + _, err = generation.Package("goa.design/goa/example/types").DeclareUserType(shared) require.ErrorContains(t, err, "frozen") } @@ -92,18 +92,123 @@ func TestPlanOwnsNormalizedMethodNames(t *testing.T) { }) }) }) - codegen.NormalizeRoot(root) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) require.NoError(t, Plan(root, generation)) require.NoError(t, generation.Freeze()) service := root.Service("Values") wrapper := service.Method("Use").Payload.Type.(expr.UserType) - declaration, err := generation.GeneratedPackage("generated.local/gen/values").Type(wrapper) + declaration, err := generation.Package("generated.local/gen/values").Type(wrapper) require.NoError(t, err) require.Equal(t, "UsePayload2", declaration.Name()) } +// TestPlanPreservesGeneratedPackageClaims verifies that service planning +// rejects distinct metadata spellings before path normalization can merge +// their declarations into one output package. +func TestPlanPreservesGeneratedPackageClaims(t *testing.T) { + tests := []struct { + name string + firstPath string + secondPath string + contains string + }{ + {"normalized collision", "types", "domain/../types", "normalize to import path"}, + {"portable collision", "Types", "types", "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", test.firstPath) + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", test.secondPath) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { dsl.Payload(first) }) + dsl.Method("Second", func() { dsl.Payload(second) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + err := Plan(root, generation) + require.ErrorContains(t, err, test.contains) + }) + } +} + +// TestPlanRejectsInvalidGeneratedPackageLocations verifies that relative Goa +// metadata cannot escape its generated module or use filesystem separators in +// a Go import path. +func TestPlanRejectsInvalidGeneratedPackageLocations(t *testing.T) { + tests := []struct { + name string + location string + }{ + {"absolute", "/outside"}, + {"escape", "../outside"}, + {"backslash", `domain\types`}, + {"colon", "domain:types"}, + {"space", "domain types"}, + {"control", "domain\x00types"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", test.location) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.Error(t, Plan(root, generation)) + }) + } +} + +// TestPlanIgnoresUnusedRelocatedTypes verifies that a type excluded from +// service output does not needlessly claim a package or contribute imports. +func TestPlanIgnoresUnusedRelocatedTypes(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Type("Unused", func() { + dsl.Meta("struct:pkg:path", "unused") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.NoError(t, Plan(root, generation)) + require.NoError(t, generation.Freeze()) +} + +// TestFilesUseCanonicalOwnedOutputDirectory verifies that a lone noncanonical +// metadata spelling emits the declaration beneath its owned canonical package. +func TestFilesUseCanonicalOwnedOutputDirectory(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + services := mustServicesData(t, root) + + require.NotNil(t, findFile(Files("goa.design/goa/example", []*ServicesData{services}), + filepath.Join("gen", "types", "value.go"))) +} + // TestServicesDataUsesRebuiltViewDeclarations verifies that planning and // rendering can rebuild view expressions while sharing frozen declarations. func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { @@ -123,16 +228,16 @@ func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { }) }) - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) require.NoError(t, Plan(root, generation)) - views := generation.GeneratedPackage("goa.design/goa/example/values/views") + views := mustClaimTestPackage(t, generation, "goa.design/goa/example/values/views") plannedProjected, err := views.DerivedType(codegen.NewProjectedTypeID(result)) require.NoError(t, err) plannedViewed, err := views.DerivedType(codegen.NewViewedResultTypeID(result)) require.NoError(t, err) require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation) + services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) service := services.Get("Values") require.Len(t, service.projectedTypes, 1) @@ -226,12 +331,12 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { }) }) - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) require.NoError(t, Plan(firstRoot, generation)) require.NoError(t, Plan(secondRoot, generation)) firstUnion := expr.AsObject(firstType).Attribute("Value").Type.(*expr.Union) secondUnion := expr.AsObject(secondType).Attribute("Value").Type.(*expr.Union) - generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text") require.NoError(t, err) secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text") @@ -239,9 +344,9 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { require.Same(t, firstBranch, secondBranch) require.NoError(t, generation.Freeze()) - firstServices, err := NewServicesData(firstRoot, generation) + firstServices, err := NewServicesData(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) require.NoError(t, err) - secondServices, err := NewServicesData(secondRoot, generation) + secondServices, err := NewServicesData(secondRoot, generation, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) require.NoError(t, err) files := Files("goa.design/goa/example", []*ServicesData{firstServices, secondServices}) @@ -276,10 +381,10 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T }) }) - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) require.NoError(t, Plan(root, generation)) union := expr.AsObject(container).Attribute("Value").Type.(*expr.Union) - generatedPackage := generation.GeneratedPackage("goa.design/goa/example/types") + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") exactDeclaration, err := generatedPackage.UserType(exact) require.NoError(t, err) branchDeclaration, err := generatedPackage.UnionBranchType(union, "text") @@ -289,7 +394,7 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T require.NoError(t, generation.Freeze()) require.Equal(t, "ValueText", exactDeclaration.Name()) require.Equal(t, "ValueText2", branchDeclaration.Name()) - services, err := NewServicesData(root, generation) + services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) typeFile := findFile( Files("goa.design/goa/example", []*ServicesData{services}), @@ -488,10 +593,10 @@ func unionFieldType(code, owner string) string { // tests and returns the frozen render analysis. func mustServicesData(t *testing.T, root *expr.RootExpr) *ServicesData { t.Helper() - generation := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) require.NoError(t, Plan(root, generation)) require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation) + services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) return services } diff --git a/codegen/service/test_helpers_test.go b/codegen/service/test_helpers_test.go new file mode 100644 index 0000000000..785304947e --- /dev/null +++ b/codegen/service/test_helpers_test.go @@ -0,0 +1,28 @@ +// This file provides strict construction helpers for service code-generation +// tests whose package roots and planner claims are deliberately valid. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// mustTestGeneration creates one generation or fails the calling test. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *codegen.Generation { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims one valid planner path or fails the calling test. +func mustClaimTestPackage(t *testing.T, generation *codegen.Generation, path string) *codegen.GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} diff --git a/codegen/service/testing.go b/codegen/service/testing.go index 4b8749f269..76b8f13393 100644 --- a/codegen/service/testing.go +++ b/codegen/service/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated service DSL fixtures. Generation construction, +// rather than the fixture, performs the final raw-method normalization step. package service import ( @@ -5,7 +7,6 @@ import ( "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -24,14 +25,12 @@ func initDSL(t *testing.T) *expr.RootExpr { return root } -// runDSL returns the DSL root resulting from running the given DSL. The root -// is normalized like the production Generate flow does before the generators -// read the design. +// runDSL evaluates the given DSL and returns its root. Test generation helpers +// normalize the root when they construct the generation. func runDSL(t *testing.T, dsl func()) *expr.RootExpr { root := initDSL(t) require.True(t, eval.Execute(dsl, nil)) require.NoError(t, eval.RunDSL()) - codegen.NormalizeRoot(root) return root } diff --git a/codegen/testing.go b/codegen/testing.go index 67c1b8ee8b..b35632728b 100644 --- a/codegen/testing.go +++ b/codegen/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated Goa designs and renders sections for codegen +// tests without performing generation-owned normalization ahead of the test. package codegen import ( @@ -24,9 +26,6 @@ func RunDSL(t *testing.T, dsl func()) *expr.RootExpr { expr.Root.API.Servers = []*expr.ServerExpr{expr.Root.API.DefaultServer()} require.True(t, eval.Execute(dsl, nil), eval.Context.Error()) require.NoError(t, eval.RunDSL()) - // Apply the sanctioned post-finalization rewrite the production Generate - // flow runs before the generators read the design. - NormalizeRoot(expr.Root) return expr.Root } diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index fe87e3b45a..c3c170e21e 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -126,7 +126,7 @@ catalog construction into retained HTTP, JSON-RPC, protobuf, and gRPC plans. - Produces: `generator.Plugin`, `PluginFactory`, fresh core factories, and private-field `generator.Plan` - Preserves: `Generation`, import-path bindings, `TypeDeclaration`, `UnionDeclaration`, and typed declaration identities -- [ ] **Step 1: Add declaration and lifecycle RED tests** +- [x] **Step 1: Add declaration and lifecycle RED tests** Add table-driven tests proving one package namespace catches cross-kind collisions, exact names reject, preferred names suffix in stable typed order, @@ -152,7 +152,7 @@ Expected: FAIL because names are still type-family-specific, plugins are registered as callback instances in `codegen`, and `Generators` is mutable process-global run state. -- [ ] **Step 2: Implement the common declaration owner** +- [x] **Step 2: Implement the common declaration owner** Add private preferred/final state and a package-level symbol kind to `NameDeclaration`. Make exact and preferred declaration APIs return the same @@ -165,7 +165,7 @@ imported toolchain, and later subsystem records. Remove duplicate name fields as each owner migrates. Canonicalize output paths during collection and reject different package owners that converge after normalization. -- [ ] **Step 3: Move orchestration and plugin registration into generator** +- [x] **Step 3: Move orchestration and plugin registration into generator** Implement the approved public surface: @@ -186,21 +186,32 @@ func (p *Plan) Generation() *codegen.Generation ``` Store immutable factory descriptors and instantiate fresh plugins and core -generators before each run. Make normalization part of preparation and close -root mutation before constructing `Generation`. Delete `Genfunc`, the public +generators before each run. Make `Generation` construction the final +preparation operation: normalize raw method objects there, snapshot the design +immediately afterward, and reject every later mutation. Delete `Genfunc`, the public replaceable `Generators` variable, `renderOnly`, and the callback registry in `codegen/plugin.go`. Tests install an isolated registry or command factory through a private test seam, not a mutable production global. -- [ ] **Step 4: Finish mechanical identity and example cleanup** +- [x] **Step 4: Finish mechanical identity and example cleanup** Audit every cycle-only walk and key it by `UserType.Origin()`. Keep semantic -`ID()` only where it intentionally seeds example generation, OpenAPI examples, -or a public semantic identifier. Remove render-time example scopes that own -package-level names; leave local argument and field scopes local. Add focused -counterexamples with equal semantic IDs and different origins. - -- [ ] **Step 5: Verify and commit Task 6** +`ID()` only where it identifies a named user type or a public semantic +identifier. Give every generated example a kind-tagged identity derived from +its exact owning expression: user type, method payload/result/error, HTTP +request/success/error body, object member, array element, map key/value, or +union branch. Reject unanchored draws and remove delimiter-joined paths, +caller-supplied response ordinals, and shared sequential collection streams. +Give independently mapped HTTP and JSON-RPC body types distinct stable semantic +IDs derived from their exact typed body owners so the recursive example cache +cannot return one transport's body for the other. Preserve authored type IDs +and expression hash behavior. +Remove render-time example scopes that own package-level names; leave local +argument and field scopes local. Add focused cross-kind, delimiter, response +reordering, dual-transport order, repeated-analysis, and concurrent-run +counterexamples. + +- [x] **Step 5: Verify and commit Task 6** Run: @@ -516,10 +527,14 @@ declaration identity. - [ ] **Step 2: Retain OpenAPI and example analysis** Build typed OpenAPI plans from prepared expressions and typed example plans -from exact service/transport plans. Preserve OpenAPI semantic example rebasing -where `ID()` intentionally selects deterministic example data. Collect every -example and CLI package-level constructor, variable, and helper through the -owning package catalog. +from exact service/transport plans. The example plan owns its server +composition data; delete the process-global `codegen/example.Servers` map. +Retain one private JSON Schema registry per OpenAPI plan; delete the exported +mutable `Definitions` map and process-global definition-name state. The +returned specification owns its definition map and schema values, so a later +build cannot mutate it. Use the typed example identities established in Task +6. Collect every example and CLI package-level constructor, variable, and +helper through the owning package catalog. - [ ] **Step 3: Make the core plan the only command execution model** @@ -533,8 +548,11 @@ callback-shaped lifecycle tests. Run each command twice and concurrently with different roots. Assert byte- identical output per input, no cross-run state, no late declarations, and no -unselected files. Compile full HTTP/gRPC/JSON-RPC examples and validate both -OpenAPI versions. +unselected files. Build disjoint example servers and OpenAPI specifications +sequentially and behind a start barrier; assert no server or schema from one +design appears in the other and the first returned result remains unchanged +after the second build. Run both concurrency tests with the race detector. +Compile full HTTP/gRPC/JSON-RPC examples and validate both OpenAPI versions. - [ ] **Step 5: Verify and commit Task 10** diff --git a/dsl/api.go b/dsl/api.go index 9a47d14414..6dd9c9084a 100644 --- a/dsl/api.go +++ b/dsl/api.go @@ -1,3 +1,5 @@ +// This file defines the API-level DSL, including immutable configuration for +// the example streams created separately by each generation run. package dsl import ( @@ -157,8 +159,9 @@ func License(fn func()) { // // Randomizer must appear in an API expression. // -// Randomizer takes a single argument which is an implementation of -// expr.Randomizer. +// Randomizer takes a single argument which is an immutable +// expr.RandomizerFactory. The factory creates a fresh value stream for each +// code generation run. // // The default randomizer uses the API name as the seed, to get consistent // random examples. @@ -166,7 +169,7 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewFakerRandomizer("different seed")) +// Randomizer(expr.NewFakerRandomizerFactory("different seed")) // }) // // There's also a deterministic randomizer which will only generate one example @@ -175,11 +178,15 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewDeterministicRandomizer()) +// Randomizer(expr.NewDeterministicRandomizerFactory()) // }) -func Randomizer(randomizer expr.Randomizer) { +func Randomizer(factory expr.RandomizerFactory) { if s, ok := eval.Current().(*expr.APIExpr); ok { - s.ExampleGenerator = &expr.ExampleGenerator{Randomizer: randomizer} + if factory == nil { + eval.ReportError("Randomizer requires a non-nil randomizer factory") + return + } + s.RandomizerFactory = factory return } eval.IncompatibleDSL() diff --git a/dsl/randomizer_test.go b/dsl/randomizer_test.go new file mode 100644 index 0000000000..e425183d88 --- /dev/null +++ b/dsl/randomizer_test.go @@ -0,0 +1,37 @@ +// This file verifies that the API DSL stores immutable example factory +// configuration instead of a mutable random stream. +package dsl + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestRandomizerStoresFactory(t *testing.T) { + factory := expr.NewDeterministicRandomizerFactory() + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(factory) + }, api) + + require.Empty(t, eval.Context.Errors) + require.Equal(t, factory, api.RandomizerFactory) +} + +func TestRandomizerRejectsNilFactory(t *testing.T) { + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(nil) + }, api) + + require.Len(t, eval.Context.Errors, 1) + require.Contains(t, eval.Context.Errors[0].Error(), "non-nil randomizer factory") +} diff --git a/expr/api.go b/expr/api.go index 98afdcc4ed..b67dcd1149 100644 --- a/expr/api.go +++ b/expr/api.go @@ -1,3 +1,5 @@ +// This file defines the evaluated API expression and the immutable example +// randomizer configuration shared by independent code generation runs. package expr import ( @@ -47,8 +49,9 @@ type ( // JSONRPC contains the JSON-RPC specific API level expressions. JSONRPC *JSONRPCExpr - // random generator used to build examples for the API types. - ExampleGenerator *ExampleGenerator + // RandomizerFactory is the immutable configuration used to create a + // fresh example value stream for each code generation run. + RandomizerFactory RandomizerFactory } // ContactExpr contains the API contact information. @@ -116,12 +119,12 @@ type ( // NewAPIExpr initializes an API expression. func NewAPIExpr(name string, dsl func()) *APIExpr { return &APIExpr{ - Name: name, - HTTP: new(HTTPExpr), - GRPC: new(GRPCExpr), - JSONRPC: new(JSONRPCExpr), - DSLFunc: dsl, - ExampleGenerator: NewRandom(name), + Name: name, + HTTP: new(HTTPExpr), + GRPC: new(GRPCExpr), + JSONRPC: new(JSONRPCExpr), + DSLFunc: dsl, + RandomizerFactory: NewFakerRandomizerFactory(name), } } diff --git a/expr/example.go b/expr/example.go index a2f1e32cc9..3ef10bf35a 100644 --- a/expr/example.go +++ b/expr/example.go @@ -1,3 +1,5 @@ +// This file generates JSON-compatible examples from evaluated attributes using +// streams anchored to exact semantic design owners. package expr import ( @@ -18,6 +20,13 @@ const ( // isn't such a value then Example computes a random value for the attribute // using the given random value producer. func (a *AttributeExpr) Example(r *ExampleGenerator) any { + if r.factory == nil { + return nil + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before drawing a value") + } + if ex := a.ExtractUserExamples(); len(ex) > 0 { // Return the last item in the slice so that examples can be overridden // in the DSL. Overridden examples are always appended to the UserExamples @@ -25,10 +34,6 @@ func (a *AttributeExpr) Example(r *ExampleGenerator) any { return ex[len(ex)-1].Value } - if r.Randomizer == nil { - return nil - } - value, ok := a.Meta.Last("openapi:example") if !ok { value, ok = a.Meta.Last("swagger:example") @@ -157,15 +162,15 @@ func byLength(a *AttributeExpr, r *ExampleGenerator) any { case MapKind: raw := make(map[any]any) m := dt.(*Map) - for range count { - raw[m.KeyType.Example(r)] = m.ElemType.Example(r) + for i := range count { + raw[m.KeyType.Example(r.MapKey(i))] = m.ElemType.Example(r.MapValue(i)) } return m.MakeMap(raw) case ArrayKind: raw := make([]any, count) ar := dt.(*Array) for i := range count { - raw[i] = ar.ElemType.Example(r) + raw[i] = ar.ElemType.Example(r.ArrayElement(i)) } return ar.MakeSlice(raw) default: diff --git a/expr/example_identity.go b/expr/example_identity.go new file mode 100644 index 0000000000..097b68d30b --- /dev/null +++ b/expr/example_identity.go @@ -0,0 +1,274 @@ +// This file defines stable, typed identities for the example values emitted +// from evaluated design expressions. +package expr + +import ( + "encoding/base64" + "encoding/binary" +) + +type ( + // ExampleIdentity identifies one semantic example stream. Its representation + // is opaque so callers cannot manufacture identities by joining names. + ExampleIdentity struct { + seed string + } + + exampleIdentityKind byte +) + +const ( + userTypeExampleKind exampleIdentityKind = iota + 1 + methodPayloadExampleKind + methodResultExampleKind + methodStreamingPayloadExampleKind + methodStreamingResultExampleKind + methodErrorExampleKind + httpRequestBodyExampleKind + httpResponseBodyExampleKind + httpErrorResponseBodyExampleKind + jsonRPCRequestBodyExampleKind + jsonRPCResponseBodyExampleKind + jsonRPCErrorResponseBodyExampleKind + grpcRequestMessageExampleKind + grpcResponseMessageExampleKind + grpcStreamingRequestMessageExampleKind + grpcStreamingResponseMessageExampleKind + grpcErrorMessageExampleKind + grpcArrayWrapperExampleKind + grpcMapWrapperExampleKind + memberExampleKind + arrayElementExampleKind + mapKeyExampleKind + mapValueExampleKind + unionMemberExampleKind +) + +// UserTypeExampleIdentity returns the example identity owned by typ. +func UserTypeExampleIdentity(typ UserType) ExampleIdentity { + if identity, ok := GeneratedUserTypeExampleIdentity(typ); ok { + return identity + } + return newExampleIdentity(userTypeExampleKind, []byte(typ.ID())) +} + +// GeneratedUserTypeExampleIdentity returns the exact semantic owner retained +// by a synthesized user type. The second result is false for authored types. +func GeneratedUserTypeExampleIdentity(typ UserType) (ExampleIdentity, bool) { + var identity ExampleIdentity + switch generated := typ.(type) { + case *UserTypeExpr: + identity = generated.exampleIdentity + case *ResultTypeExpr: + identity = generated.UserTypeExpr.exampleIdentity + } + return identity, identity.seed != "" +} + +// MethodPayloadExampleIdentity returns the payload example identity owned by +// method. +func MethodPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodPayloadExampleKind, method) +} + +// MethodResultExampleIdentity returns the result example identity owned by +// method. +func MethodResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodResultExampleKind, method) +} + +// MethodStreamingPayloadExampleIdentity returns the streaming payload example +// identity owned by method. +func MethodStreamingPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingPayloadExampleKind, method) +} + +// MethodStreamingResultExampleIdentity returns the streaming result example +// identity owned by method. +func MethodStreamingResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingResultExampleKind, method) +} + +// MethodErrorExampleIdentity returns the example identity owned by err in +// method. +func MethodErrorExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + methodErrorExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// RequestBodyExampleIdentity returns the request body example identity owned +// by endpoint. HTTP and JSON-RPC mappings receive distinct identities. +func RequestBodyExampleIdentity(endpoint *HTTPEndpointExpr) ExampleIdentity { + kind := httpRequestBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCRequestBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + ) +} + +// ResponseBodyExampleIdentity returns the successful response body example +// identity owned by response in endpoint. HTTP and JSON-RPC mappings receive +// distinct identities. Endpoint validation makes each successful status code +// unique. +func ResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPResponseExpr) ExampleIdentity { + kind := httpResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + exampleIdentityInt(response.StatusCode), + ) +} + +// ErrorResponseBodyExampleIdentity returns the error response body example +// identity owned by response in endpoint. HTTP and JSON-RPC mappings receive +// distinct identities. Error names distinguish errors that intentionally +// share an HTTP status. +func ErrorResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPErrorExpr) ExampleIdentity { + kind := httpErrorResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCErrorResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + []byte(response.Name), + exampleIdentityInt(response.Response.StatusCode), + ) +} + +// GRPCRequestMessageExampleIdentity returns the gRPC request message example +// identity owned by method. +func GRPCRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcRequestMessageExampleKind, method) +} + +// GRPCResponseMessageExampleIdentity returns the gRPC response message example +// identity owned by method. +func GRPCResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcResponseMessageExampleKind, method) +} + +// GRPCStreamingRequestMessageExampleIdentity returns the gRPC streaming +// request message example identity owned by method. +func GRPCStreamingRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingRequestMessageExampleKind, method) +} + +// GRPCStreamingResponseMessageExampleIdentity returns the gRPC streaming +// response message example identity owned by method. +func GRPCStreamingResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingResponseMessageExampleKind, method) +} + +// GRPCErrorMessageExampleIdentity returns the gRPC error message example +// identity owned by err in method. +func GRPCErrorMessageExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + grpcErrorMessageExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// GRPCArrayWrapperExampleIdentity returns the stable gRPC wrapper identity for +// an authored array alias shared across message fields. +func GRPCArrayWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsArray(typ) { + panic("gRPC array wrapper identity requires an array user type") + } + return newExampleIdentity(grpcArrayWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// GRPCMapWrapperExampleIdentity returns the stable gRPC wrapper identity for +// an authored map alias shared across message fields. +func GRPCMapWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsMap(typ) { + panic("gRPC map wrapper identity requires a map user type") + } + return newExampleIdentity(grpcMapWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// Seed returns the complete stable seed material custom randomizer factories +// use to create the stream for this identity. +func (i ExampleIdentity) Seed() string { + return base64.RawURLEncoding.EncodeToString([]byte(i.seed)) +} + +// Member returns the identity of the named object member below i. +func (i ExampleIdentity) Member(name string) ExampleIdentity { + return i.append(memberExampleKind, []byte(name)) +} + +// ArrayElement returns the identity of the indexed array element below i. +func (i ExampleIdentity) ArrayElement(index int) ExampleIdentity { + return i.append(arrayElementExampleKind, exampleIdentityInt(index)) +} + +// MapKey returns the identity of the indexed map key below i. +func (i ExampleIdentity) MapKey(index int) ExampleIdentity { + return i.append(mapKeyExampleKind, exampleIdentityInt(index)) +} + +// MapValue returns the identity of the indexed map value below i. +func (i ExampleIdentity) MapValue(index int) ExampleIdentity { + return i.append(mapValueExampleKind, exampleIdentityInt(index)) +} + +// UnionMember returns the identity of the named union member below i. +func (i ExampleIdentity) UnionMember(name string) ExampleIdentity { + return i.append(unionMemberExampleKind, []byte(name)) +} + +// newExampleIdentity serializes one typed segment with independently framed +// components so punctuation and component boundaries cannot collide. +func newExampleIdentity(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + return ExampleIdentity{seed: string(appendExampleIdentitySegment(nil, kind, components...))} +} + +// methodExampleIdentity derives a method-owned identity from the evaluated +// service and method names rather than accepting caller-supplied components. +func methodExampleIdentity(kind exampleIdentityKind, method *MethodExpr) ExampleIdentity { + return newExampleIdentity(kind, []byte(method.Service.Name), []byte(method.Name)) +} + +// exampleIdentityInt returns a stable fixed-width encoding of value. +func exampleIdentityInt(value int) []byte { + return binary.BigEndian.AppendUint64(nil, uint64(value)) +} + +// appendExampleIdentitySegment writes the segment kind, component count, and +// byte length of each component before its data. +func appendExampleIdentitySegment(seed []byte, kind exampleIdentityKind, components ...[]byte) []byte { + seed = append(seed, byte(kind)) + seed = binary.BigEndian.AppendUint64(seed, uint64(len(components))) + for _, component := range components { + seed = binary.BigEndian.AppendUint64(seed, uint64(len(component))) + seed = append(seed, component...) + } + return seed +} + +// append adds one structural segment without exposing the serialized form to +// callers. +func (i ExampleIdentity) append(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + if i.seed == "" { + panic("example identity must have a semantic owner before structural descent") + } + seed := append([]byte(nil), i.seed...) + seed = appendExampleIdentitySegment(seed, kind, components...) + return ExampleIdentity{seed: string(seed)} +} diff --git a/expr/example_stability_test.go b/expr/example_stability_test.go index 95e4d17016..0391e5f98e 100644 --- a/expr/example_stability_test.go +++ b/expr/example_stability_test.go @@ -1,3 +1,5 @@ +// This file verifies that typed example owners make values independent of +// unrelated draws while preserving member-local values in composite examples. package expr_test import ( @@ -27,11 +29,16 @@ func TestExampleOrderIndependence(t *testing.T) { } // Reference value computed on a fresh generator. - ref := newUT("Stable").Example(expr.NewRandom("test")) + stable := newUT("Stable") + ref := stable.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(stable), + )) require.NotNil(t, ref) // Same value after unrelated draws were consumed from the generator. - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "draw")), + ) noise := make([]any, 0, 14) for range 7 { noise = append(noise, r.Int(), r.String()) @@ -40,8 +47,11 @@ func TestExampleOrderIndependence(t *testing.T) { require.Equal(t, ref, newUT("Stable").Example(r)) // Same value after another type's example was computed first. - r = expr.NewRandom("test") - newUT("Other").Example(r) + r = expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "other")), + ) + other := newUT("Other") + other.Example(r.At(expr.UserTypeExampleIdentity(other))) require.Equal(t, ref, newUT("Stable").Example(r)) } @@ -57,8 +67,9 @@ func TestExampleFieldLocality(t *testing.T) { {Name: "b", Attribute: &expr.AttributeExpr{Type: expr.Int}}, {Name: "c", Attribute: &expr.AttributeExpr{Type: expr.Boolean}}, } - exSmall := small.Example(expr.NewRandom("test")).(map[string]any) - exLarge := large.Example(expr.NewRandom("test")).(map[string]any) + owner := expr.MethodPayloadExampleIdentity(exampleMethod("locality", "object")) + exSmall := small.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) + exLarge := large.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) require.Equal(t, exSmall["a"], exLarge["a"]) require.Equal(t, exSmall["b"], exLarge["b"]) } @@ -77,9 +88,8 @@ func TestExampleFieldAnchor(t *testing.T) { }, }, } - parent := &expr.AttributeExpr{Type: ut} - - composite := ut.Example(expr.NewRandom("test")).(map[string]any) - standalone := field.Example(expr.NewRandom("test").Field(parent, "id")) + identity := expr.UserTypeExampleIdentity(ut) + composite := ut.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity)).(map[string]any) + standalone := field.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity).Member("id")) require.Equal(t, composite["id"], standalone) } diff --git a/expr/example_test.go b/expr/example_test.go index 9efbeb9e06..57541d486b 100644 --- a/expr/example_test.go +++ b/expr/example_test.go @@ -1,3 +1,5 @@ +// This file exercises attribute example generation across validation rules and +// confirms every configured generator is anchored to its owning expression. package expr_test import ( @@ -21,11 +23,13 @@ func TestByPattern(t *testing.T) { {"max-len", "foo[a-z]+", 9}, {"max-len-2", "^/api/example/[0-9]+$", 19}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { val := &expr.ValidationExpr{Pattern: k.Pattern} att := expr.AttributeExpr{Validation: val} + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("pattern", k.Name)), + ) example := att.Example(r).(string) @@ -42,7 +46,9 @@ func TestByPattern(t *testing.T) { func TestByFormatUUID(t *testing.T) { val := &expr.ValidationExpr{Format: expr.FormatUUID} att := expr.AttributeExpr{Validation: val} - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("format", "uuid")), + ) example := att.Example(r).(string) if !regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`).MatchString(example) { t.Errorf("got %s, expected a match with `[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}`", example) @@ -68,12 +74,15 @@ func TestExample(t *testing.T) { {"openapi-generate-false-array-example", testdata.OpenAPIGenerateFalseArrayExampleDSL, map[string]any{"items": []map[string]any{{"name": "example"}}}, ""}, {"overriding-hidden-examples", testdata.OverridingHiddenExamplesDSL, "example", ""}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { if k.Error == "" { expr.RunDSL(t, k.DSL) - example := expr.Root.Services[0].Methods[0].Payload.Example(r) + method := expr.Root.Services[0].Methods[0] + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + example := method.Payload.Example(r) if !reflect.DeepEqual(example, k.Expected) { t.Errorf("invalid example: got %v, expected %v", example, k.Expected) } @@ -92,14 +101,15 @@ func TestExample(t *testing.T) { // can generate examples correctly. Previously, this would panic because the // code checked a.Type.Kind() instead of the underlying type's kind. func TestByLengthWithAliasType(t *testing.T) { - r := expr.NewRandom("test") - // Create an alias type based on String with length validation // We need to use the DSL package properly root := expr.RunDSL(t, testdata.AliasLengthValidationDSL) aliasType := root.UserType("ValidatedString") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate a string example // The key test is that byLength handles alias types correctly by unaliasing @@ -119,12 +129,13 @@ func TestByLengthWithAliasType(t *testing.T) { // TestByLengthWithAliasArray tests that alias array types with length // validations generate examples correctly. func TestByLengthWithAliasArray(t *testing.T) { - r := expr.NewRandom("test") - root := expr.RunDSL(t, testdata.AliasArrayLengthValidationDSL) aliasType := root.UserType("StringArray") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate an array example example := att.Example(r) diff --git a/expr/http_body_types.go b/expr/http_body_types.go index 933e1c4029..1bf50486a1 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -3,67 +3,11 @@ package expr import ( - "encoding/json" - "fmt" "net/http" "strings" "unicode" ) -// UnionToObject returns an object adequate to serialize the given union type in -// HTTP requests and responses. The object has two fields for the discriminator -// and value, with names determined by the union's Meta tags (defaulting to -// "Type" and "Value"). The discriminator field indicates the name of the union -// type, and the value field contains the JSON encoded union value. -func UnionToObject(att *AttributeExpr) *AttributeExpr { - example := att.Example(Root.API.ExampleGenerator) - js, err := json.Marshal(example) - if err != nil { - js = []byte("null") - } - union := AsUnion(att.Type) - values := union.Values - typeKey := union.GetTypeKey() - valueKey := union.GetValueKey() - - names := make([]any, len(values)) - vals := make([]string, len(values)) - bases := make([]DataType, len(values)) - for i, nat := range values { - names[i] = nat.Name - vals[i] = fmt.Sprintf("- %q", nat.Name) - bases[i] = nat.Attribute.Type - } - obj := Object([]*NamedAttributeExpr{ - {Name: typeKey, Attribute: &AttributeExpr{ - Type: String, - Description: "Union type name, one of:\n" + strings.Join(vals, "\n"), - Validation: &ValidationExpr{Values: names}, - Meta: MetaExpr{ - "struct:tag:form": {typeKey}, - "struct:tag:json": {typeKey}, - "struct:tag:xml": {typeKey}, - }, - }}, - {Name: valueKey, Attribute: &AttributeExpr{ - Type: String, - Description: "JSON encoded union value", - UserExamples: []*ExampleExpr{{Value: string(js)}}, - Bases: bases, // For OpenAPI generation - Meta: MetaExpr{ - "struct:tag:form": {valueKey}, - "struct:tag:json": {valueKey}, - "struct:tag:xml": {valueKey}, - }, - }}, - }) - return &AttributeExpr{ - Type: &obj, - Description: att.Description, - Validation: &ValidationExpr{Required: []string{typeKey, valueKey}}, - } -} - // defaultRequestHeaderAttributes returns a map keyed by the names of the // payload attributes that should come from the request HTTP headers by default. // This includes mapping done for certain authorization schemes (basic auth, @@ -135,10 +79,13 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { name = concat(a.Name(), "Request", "Body") ) if a.Body != nil { + if a.Body.Type == Empty { + return a.Body + } a.Body = DupAtt(a.Body) renameType(a.Body, name) - if ut, ok := a.Body.Type.(*UserTypeExpr); ok { - ut.UID = a.Service.Name() + "#" + name + if ut, ok := a.Body.Type.(UserType); ok { + a.Body.Type = generatedUserType(ut, RequestBodyExampleIdentity(a)) } return a.Body } @@ -151,7 +98,6 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { bodyOnly = headers.IsEmpty() && params.IsEmpty() && cookies.IsEmpty() && a.MapQueryParams == nil ) - // 1. If Payload is not an object then check whether there are // 2. If Payload is not an object then check whether there are // params, cookies or headers defined and if so return empty type // (payload encoded in request params or headers) otherwise return @@ -187,11 +133,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { // 5. Build computed user type att := body.Attribute() - ut := &UserTypeExpr{ - AttributeExpr: att, - TypeName: name, - UID: a.Service.Name() + "#" + a.Name(), - } + ut := NewGeneratedUserType(name, att, RequestBodyExampleIdentity(a)) if t, ok := payload.Type.(UserType); ok { copyOpenAPITypeMeta(t, ut) } @@ -223,11 +165,11 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { } } RemovePkgPath(dupped) - ut := &UserTypeExpr{ - AttributeExpr: dupped, - TypeName: concat(e.Name(), "Streaming", "Body"), - UID: e.Service.Name() + "#" + e.Name() + "StreamingBody", - } + ut := NewGeneratedUserType( + concat(e.Name(), "Streaming", "Body"), + dupped, + MethodStreamingPayloadExampleIdentity(e.MethodExpr), + ) return &AttributeExpr{ Type: ut, @@ -247,7 +189,8 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp suffix = http.StatusText(resp.StatusCode) } name = a.Name() + suffix - return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, a.Service) + identity := ResponseBodyExampleIdentity(a, resp) + return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, identity) } // httpErrorResponseBody returns an attribute describing the response body of a @@ -257,10 +200,11 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp // parameters. func httpErrorResponseBody(e *HTTPEndpointExpr, v *HTTPErrorExpr) *AttributeExpr { name := e.Name() + "_" + v.ErrorExpr.Name - return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, e.Service) + identity := ErrorResponseBodyExampleIdentity(e, v) + return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, identity) } -func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, svc *HTTPServiceExpr) *AttributeExpr { +func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, identity ExampleIdentity) *AttributeExpr { name = concat(name, "Response", "Body") if attr == nil || attr.Type == Empty { return &AttributeExpr{Type: Empty} @@ -279,11 +223,8 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE } att := DupAtt(resp.Body) renameType(att, name) - if ut, ok := att.Type.(*UserTypeExpr); ok { - ut.UID = svc.Name() + "#" + name - } - if rt, ok := att.Type.(*ResultTypeExpr); ok { - rt.UID = svc.Name() + "#" + name + if ut, ok := att.Type.(UserType); ok { + att.Type = generatedUserType(ut, identity) } return att } @@ -324,15 +265,10 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE if bodyAtt.Description == "" { bodyAtt.Description = attr.Description } - userType := &UserTypeExpr{ - AttributeExpr: bodyAtt, - TypeName: name, - UID: concat(svc.Name(), "#", name), - } + userType := NewGeneratedUserType(name, bodyAtt, identity) if t, ok := attr.Type.(UserType); ok { // Remember original type name for example to generate friendly - // OpenAPI specs. userType.AddMeta("name:original", t.Name()) copyOpenAPITypeMeta(t, userType) } @@ -374,6 +310,21 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE } } +// generatedUserType preserves result-type behavior while giving a computed +// transport type a fresh declaration origin and exact example owner. +func generatedUserType(typ UserType, identity ExampleIdentity) UserType { + generated := NewGeneratedUserType(typ.Name(), typ.Attribute(), identity) + if result, ok := typ.(*ResultTypeExpr); ok { + result.UserTypeExpr = generated + result.origin = nil + for _, view := range result.Views { + view.Parent = result + } + return result + } + return generated +} + // concat concatenates the given strings with "smart(?) casing". // The concatenation algorithm is: // diff --git a/expr/http_body_types_test.go b/expr/http_body_types_test.go index db72618b1f..8c4d5426bf 100644 --- a/expr/http_body_types_test.go +++ b/expr/http_body_types_test.go @@ -73,6 +73,78 @@ func TestHTTPStreamingBodyValidation(t *testing.T) { } } +func TestComputedBodyExamplesDistinguishHTTPAndJSONRPC(t *testing.T) { + service := &ServiceExpr{Name: "Service"} + method := &MethodExpr{Name: "Method", Service: service} + httpEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "HTTPBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "http", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + jsonRPCEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Meta: MetaExpr{"jsonrpc": {}}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "JSONRPCBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "jsonrpc", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + httpBody := httpRequestBody(httpEndpoint) + jsonRPCBody := httpRequestBody(jsonRPCEndpoint) + require.NotEqual(t, httpBody.Type.(UserType).ID(), jsonRPCBody.Type.(UserType).ID()) + + cases := []struct { + Name string + First *HTTPEndpointExpr + FirstBody *AttributeExpr + Second *HTTPEndpointExpr + SecondBody *AttributeExpr + }{ + { + Name: "HTTP then JSON-RPC", + First: httpEndpoint, + FirstBody: httpBody, + Second: jsonRPCEndpoint, + SecondBody: jsonRPCBody, + }, + { + Name: "JSON-RPC then HTTP", + First: jsonRPCEndpoint, + FirstBody: jsonRPCBody, + Second: httpEndpoint, + SecondBody: httpBody, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + generator := NewExampleGenerator(NewFakerRandomizerFactory("test")) + first := c.FirstBody.Example(generator.At(RequestBodyExampleIdentity(c.First))).(map[string]any) + second := c.SecondBody.Example(generator.At(RequestBodyExampleIdentity(c.Second))).(map[string]any) + + require.Contains(t, first, transportBodyField(c.First)) + require.NotContains(t, first, transportBodyField(c.Second)) + require.Contains(t, second, transportBodyField(c.Second)) + require.NotContains(t, second, transportBodyField(c.First)) + }) + } +} + +// transportBodyField returns the field unique to the endpoint's body mapping. +func transportBodyField(endpoint *HTTPEndpointExpr) string { + if endpoint.IsJSONRPC() { + return "jsonrpc" + } + return "http" +} + func TestRemovePkgPathDistinguishesEqualUIDOrigins(t *testing.T) { first := &UserTypeExpr{ AttributeExpr: &AttributeExpr{ diff --git a/expr/method.go b/expr/method.go index 8b00d53ff5..85697ebbf1 100644 --- a/expr/method.go +++ b/expr/method.go @@ -1,3 +1,5 @@ +// This file defines service methods and finalizes their payload, result, +// streaming, error, security, and interceptor contracts. package expr import ( @@ -400,6 +402,10 @@ func (m *MethodExpr) Finalize() { } } for _, e := range m.Errors { + if _, authored := e.Type.(UserType); !authored { + e.finalizeMethodType(m) + continue + } e.Finalize() } diff --git a/expr/project_test.go b/expr/project_test.go index dd663a88e1..0aac79f6a9 100644 --- a/expr/project_test.go +++ b/expr/project_test.go @@ -1,3 +1,5 @@ +// This file verifies result-type projections preserve view shape, field +// metadata, recursion, and synthesized example ownership. package expr import ( @@ -10,7 +12,12 @@ import ( ) var ( - testrand = NewRandom("test") + testrand = NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "project", + Service: &ServiceExpr{Name: "test"}, + }), + ) simpleResult = resultType("a", String, "b", Int, view("default", "a", String, "b", Int), view("link", "a", String)) simpleResultDefault = resultType("a", String, "b", Int) @@ -82,6 +89,21 @@ func TestProject(t *testing.T) { } } +func TestProjectPreservesGeneratedExampleIdentity(t *testing.T) { + source := resultType("value", String, view("default", "value", String)) + owner := MethodResultExampleIdentity(&MethodExpr{ + Name: "read", + Service: &ServiceExpr{Name: "values"}, + }) + source.UserTypeExpr = NewGeneratedUserType(source.TypeName, source.AttributeExpr, owner) + + projected, err := Project(source, DefaultView) + require.NoError(t, err) + projectedOwner, ok := GeneratedUserTypeExampleIdentity(projected) + require.True(t, ok) + require.Equal(t, owner, projectedOwner) +} + // TestProjectDoesNotAliasFieldAttributes verifies that fields sharing a type // share the projected type but never the AttributeExpr wrapping it, so that // per-field metadata such as descriptions does not leak across fields. diff --git a/expr/random.go b/expr/random.go index 5555883cd8..bf8ee59aa1 100644 --- a/expr/random.go +++ b/expr/random.go @@ -1,3 +1,5 @@ +// This file defines immutable example-randomizer configuration and the +// mutable value streams owned by one code generation run. package expr import ( @@ -7,286 +9,341 @@ import ( "math/rand" "net" "strings" - "sync" "github.com/manveru/faker" ) -// Randomizer generates consistent random values of different types given a seed. -// -// The random values should be consistent in that given the same seed the same -// random values get generated. -// -// Setting the randomizer to nil disables example generation. -type Randomizer interface { - // ArrayLength decides how long an example array will be - ArrayLength() int - // Int generates an integer example - Int() int - // Int32 generates an int32 example - Int32() int32 - // Int64 generates an int64 example - Int64() int64 - // String generates a string example - String() string - // Bool generates a bool example - Bool() bool - // Float32 generates a float32 example - Float32() float32 - // Float64 generates a float64 example - Float64() float64 - // UInt generates a uint example - UInt() uint - // UInt32 generates a uint example - UInt32() uint32 - // UInt64 generates a uint example - UInt64() uint64 - // Name generates a human name example - Name() string - // Email generates an example email address - Email() string - // Hostname generates an example hostname - Hostname() string - // IPv4Address generates an example IPv4 address - IPv4Address() net.IP - // IPv6Address generates an example IPv6 address - IPv6Address() net.IP - // URL generates an example URL - URL() string - // Characters generates a n-character string example - Characters(n int) string - // UUID generates a random v4 UUID - UUID() string -} +type ( + // Randomizer generates values used in generated examples. Implementations + // must return the same sequence when constructed from the same configuration + // and identity. + Randomizer interface { + // ArrayLength decides how long an example array will be. + ArrayLength() int + // Int generates an integer example. + Int() int + // Int32 generates an int32 example. + Int32() int32 + // Int64 generates an int64 example. + Int64() int64 + // String generates a string example. + String() string + // Bool generates a bool example. + Bool() bool + // Float32 generates a float32 example. + Float32() float32 + // Float64 generates a float64 example. + Float64() float64 + // UInt generates a uint example. + UInt() uint + // UInt32 generates a uint32 example. + UInt32() uint32 + // UInt64 generates a uint64 example. + UInt64() uint64 + // Name generates a human name example. + Name() string + // Email generates an email address example. + Email() string + // Hostname generates a hostname example. + Hostname() string + // IPv4Address generates an IPv4 address example. + IPv4Address() net.IP + // IPv6Address generates an IPv6 address example. + IPv6Address() net.IP + // URL generates a URL example. + URL() string + // Characters generates a string containing n characters. + Characters(n int) string + // UUID generates a random version 4 UUID. + UUID() string + } -// NewRandom returns a random value generator seeded from the given string -// value, using the faker library to generate random but realistic values. -func NewRandom(seed string) *ExampleGenerator { - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, + // RandomizerFactory is immutable example configuration. NewRandomizer must + // create a new mutable stream for every call. identity identifies a stable + // design location so separate runs produce identical examples without + // sharing consumed stream state. + RandomizerFactory interface { + // NewRandomizer creates an independent value stream for identity. + NewRandomizer(identity ExampleIdentity) Randomizer } -} -// ExampleGenerator generates examples from a value stream seeded by a design -// identity. Example computations derive child generators at stable design -// boundaries (user type IDs, object field names, array indices) via Derived -// so that an example is a pure function of the design: it does not change -// when unrelated parts of the design change or when code generators evaluate -// attributes in a different order. -type ExampleGenerator struct { - Randomizer - // seed identifies the design element this generator draws values for; - // generators derived from it extend the seed via Derived. It is empty - // for generators built around a caller-supplied Randomizer, which - // cannot re-seed and therefore never derive. - seed string - // root points to the generator this one was derived from so that all - // derived generators share the root's seen cache. It is nil on roots. - root *ExampleGenerator - seen map[string]*any - mu sync.RWMutex -} + // exampleRandomizer hides the mutable stream field while promoting its + // value methods to ExampleGenerator. + exampleRandomizer interface { + Randomizer + } -// Derived returns a generator whose value stream is seeded from this -// generator's seed extended with the given identity, independent of how many -// values were drawn so far. Derived generators share the root generator's -// seen values so a user type keeps a single example wherever it appears. -// Generators that cannot re-seed (disabled example generation or a -// caller-supplied Randomizer) return themselves. -func (r *ExampleGenerator) Derived(id string) *ExampleGenerator { - return r.reseeded(r.seed + "/" + id) -} + // ExampleGenerator generates examples from one run-owned value stream. + // Derived generators use stable design identities and share only this run's + // recursion cache, so unrelated analysis order does not change examples. + // One planning thread owns each generator; concurrent runs use distinct + // generators. + ExampleGenerator struct { + exampleRandomizer + factory RandomizerFactory + identity ExampleIdentity + // root points to the generator this one was derived from so that all + // derived generators share the root's seen cache. It is nil on roots. + root *ExampleGenerator + seen map[UserType]*any + } -// Rebased returns a generator whose value stream is seeded from the root -// design seed and the given absolute identity, discarding the current -// derivation path. It anchors examples of design elements that own a global -// identity — user type IDs in particular — so the computed value is the same -// no matter where in the design the element is reached from. Generators that -// cannot re-seed (disabled example generation or a caller-supplied -// Randomizer) return themselves. -func (r *ExampleGenerator) Rebased(id string) *ExampleGenerator { - return r.reseeded(r.store().seed + ":" + id) -} + // fakerRandomizer implements Randomizer using the faker library. + fakerRandomizer struct { + faker *faker.Faker + rand *rand.Rand + } -// PreviouslySeen returns the previously seen value for a given ID -func (r *ExampleGenerator) PreviouslySeen(typeID string) (*any, bool) { - s := r.store() - s.mu.RLock() - defer s.mu.RUnlock() - if s.seen == nil { - return nil, false + // deterministicRandomizer returns fixed values for every requested kind. + deterministicRandomizer struct{} + + // fakerRandomizerFactory retains only the seed configured by the API DSL. + fakerRandomizerFactory struct { + seed string } - val, haveSeen := s.seen[typeID] - return val, haveSeen + + // deterministicRandomizerFactory carries no mutable run state. + deterministicRandomizerFactory struct{} +) + +// NewExampleGenerator creates an unanchored mutable run object with an empty +// recursion cache. Call At before requesting an example value. +func NewExampleGenerator(factory RandomizerFactory) *ExampleGenerator { + return &ExampleGenerator{factory: factory} } -// HaveSeen stores the seen value in the randomizer, for reuse later -func (r *ExampleGenerator) HaveSeen(typeID string, val *any) { - s := r.store() - s.mu.Lock() - defer s.mu.Unlock() - if s.seen == nil { - s.seen = make(map[string]*any) - } +// NewFakerRandomizerFactory returns immutable configuration that creates +// independent faker streams rooted at seed. +func NewFakerRandomizerFactory(seed string) RandomizerFactory { + return fakerRandomizerFactory{seed: seed} +} - s.seen[typeID] = val +// NewDeterministicRandomizerFactory returns immutable configuration that +// creates independent streams of fixed values. +func NewDeterministicRandomizerFactory() RandomizerFactory { + return deterministicRandomizerFactory{} } -// Field returns a generator anchored to the identity of the named field of -// the given parent attribute: the parent type identity extended with the -// field name when the parent is a user type, the field name alone otherwise. -// Code generators use it when they compute the example of one element -// extracted from a payload or result (transport params, headers, cookies, -// metadata) so the standalone example matches the corresponding field value -// in the parent type's composite example and stays stable across generator -// changes. -func (r *ExampleGenerator) Field(parent *AttributeExpr, name string) *ExampleGenerator { - if ut, ok := parent.Type.(UserType); ok { - return r.Rebased(ut.ID()).Derived(name) +// At returns a generator whose stream is anchored to identity. Anchored +// generators share this run's recursion cache but never consumed stream state. +func (r *ExampleGenerator) At(identity ExampleIdentity) *ExampleGenerator { + root := r.store() + if root.factory == nil { + return r + } + if identity.seed == "" { + panic("example identity is not initialized") + } + return &ExampleGenerator{ + exampleRandomizer: root.factory.NewRandomizer(identity), + factory: root.factory, + identity: identity, + root: root, } - return r.Rebased(name) } -// store returns the generator owning the seen cache and the root design -// seed: the generator this one was derived from, or the generator itself -// when it is a root. -func (r *ExampleGenerator) store() *ExampleGenerator { - if r.root != nil { - return r.root +// Member returns a generator for the named object member below the current +// anchored identity. +func (r *ExampleGenerator) Member(name string) *ExampleGenerator { + if r.factory == nil { + return r } - return r + return r.structural(r.identity.Member(name)) } -// reseeded returns a generator drawing from a fresh value stream seeded with -// the given seed and sharing this generator's root state. -func (r *ExampleGenerator) reseeded(seed string) *ExampleGenerator { - if r.Randomizer == nil || r.store().seed == "" { +// ArrayElement returns a generator for the indexed array element below the +// current anchored identity. +func (r *ExampleGenerator) ArrayElement(index int) *ExampleGenerator { + if r.factory == nil { return r } - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, - root: r.store(), - } + return r.structural(r.identity.ArrayElement(index)) } -// NewFakerRandomizer creates a randomizer that uses the faker library to -// generate fake but reasonable values. -func NewFakerRandomizer(seed string) Randomizer { - hasher := md5.New() - hasher.Write([]byte(seed)) - sint := int64(binary.BigEndian.Uint64(hasher.Sum(nil))) - source := rand.NewSource(sint) - ran := rand.New(source) - faker := &faker.Faker{ - Language: "end", - Dict: faker.Dict["en"], - Rand: ran, +// MapKey returns a generator for the indexed map key below the current +// anchored identity. +func (r *ExampleGenerator) MapKey(index int) *ExampleGenerator { + if r.factory == nil { + return r } + return r.structural(r.identity.MapKey(index)) +} - return &FakerRandomizer{ - Seed: seed, - faker: faker, - rand: ran, +// MapValue returns a generator for the indexed map value below the current +// anchored identity. +func (r *ExampleGenerator) MapValue(index int) *ExampleGenerator { + if r.factory == nil { + return r } + return r.structural(r.identity.MapValue(index)) } -// FakerRandomizer implements the Random interface, using the Faker library. -type FakerRandomizer struct { - Seed string - faker *faker.Faker - rand *rand.Rand +// UnionMember returns a generator for the named union member below the current +// anchored identity. +func (r *ExampleGenerator) UnionMember(name string) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.UnionMember(name)) } -func (r *FakerRandomizer) ArrayLength() int { +func (r *fakerRandomizer) ArrayLength() int { return r.Int()%3 + 2 } -func (r *FakerRandomizer) Int() int { +func (r *fakerRandomizer) Int() int { return r.rand.Int() } -func (r *FakerRandomizer) Int32() int32 { +func (r *fakerRandomizer) Int32() int32 { return r.rand.Int31() } -func (r *FakerRandomizer) Int64() int64 { +func (r *fakerRandomizer) Int64() int64 { return r.rand.Int63() } -func (r *FakerRandomizer) String() string { +func (r *fakerRandomizer) String() string { return r.faker.Sentence(2, false) } -func (r *FakerRandomizer) Bool() bool { +func (r *fakerRandomizer) Bool() bool { return r.rand.Int()%2 == 0 } -func (r *FakerRandomizer) Float32() float32 { +func (r *fakerRandomizer) Float32() float32 { return r.rand.Float32() } -func (r *FakerRandomizer) Float64() float64 { +func (r *fakerRandomizer) Float64() float64 { return r.rand.Float64() } -func (r *FakerRandomizer) UInt() uint { +func (r *fakerRandomizer) UInt() uint { return uint(r.UInt64()) } -func (r *FakerRandomizer) UInt32() uint32 { +func (r *fakerRandomizer) UInt32() uint32 { return r.rand.Uint32() } -func (r *FakerRandomizer) UInt64() uint64 { +func (r *fakerRandomizer) UInt64() uint64 { return r.rand.Uint64() } -func (r *FakerRandomizer) Email() string { +func (r *fakerRandomizer) Email() string { return r.faker.Email() } -func (r *FakerRandomizer) Hostname() string { +func (r *fakerRandomizer) Hostname() string { return r.faker.DomainName() + "." + r.faker.DomainSuffix() } -func (r *FakerRandomizer) IPv4Address() net.IP { +func (r *fakerRandomizer) IPv4Address() net.IP { return r.faker.IPv4Address() } -func (r *FakerRandomizer) IPv6Address() net.IP { +func (r *fakerRandomizer) IPv6Address() net.IP { return r.faker.IPv6Address() } -func (r *FakerRandomizer) URL() string { +func (r *fakerRandomizer) URL() string { return r.faker.URL() } -func (r *FakerRandomizer) Characters(n int) string { +func (r *fakerRandomizer) Characters(n int) string { return r.faker.Characters(n) } -func (r *FakerRandomizer) UUID() string { +func (r *fakerRandomizer) UUID() string { uuid := make([]byte, 16) r.rand.Read(uuid) uuid[6] = (uuid[6] & 0x0f) | 0x40 uuid[8] = (uuid[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) } -func (r *FakerRandomizer) Name() string { +func (r *fakerRandomizer) Name() string { return r.faker.Name() } -// NewDeterministicRandomizer builds a Randomizer that will return hard-coded -// values, removing all randomness from example generation. -func NewDeterministicRandomizer() Randomizer { - return &DeterministicRandomizer{} +func (deterministicRandomizer) ArrayLength() int { return 1 } +func (deterministicRandomizer) Int() int { return 1 } +func (deterministicRandomizer) Int32() int32 { return 1 } +func (deterministicRandomizer) Int64() int64 { return 1 } +func (deterministicRandomizer) String() string { return "abc123" } +func (deterministicRandomizer) Bool() bool { return false } +func (deterministicRandomizer) Float32() float32 { return 1 } +func (deterministicRandomizer) Float64() float64 { return 1 } +func (deterministicRandomizer) UInt() uint { return 1 } +func (deterministicRandomizer) UInt32() uint32 { return 1 } +func (deterministicRandomizer) UInt64() uint64 { return 1 } +func (deterministicRandomizer) Name() string { return "Alice" } +func (deterministicRandomizer) Email() string { return "alice@example.com" } +func (deterministicRandomizer) Hostname() string { return "example.com" } +func (deterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } +func (deterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } +func (deterministicRandomizer) URL() string { return "https://example.com/foo" } +func (deterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } +func (deterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// NewRandomizer creates an independent faker stream for identity. +func (f fakerRandomizerFactory) NewRandomizer(identity ExampleIdentity) Randomizer { + return newFakerRandomizer(f.seed + identity.Seed()) +} + +// NewRandomizer creates an independent deterministic stream. identity does +// not affect fixed deterministic values. +func (deterministicRandomizerFactory) NewRandomizer(ExampleIdentity) Randomizer { + return newDeterministicRandomizer() +} + +// newFakerRandomizer creates a mutable faker stream from exact seed material. +func newFakerRandomizer(seed string) Randomizer { + hasher := md5.New() + hasher.Write([]byte(seed)) + sint := int64(binary.BigEndian.Uint64(hasher.Sum(nil))) + source := rand.NewSource(sint) + ran := rand.New(source) + faker := &faker.Faker{ + Language: "end", + Dict: faker.Dict["en"], + Rand: ran, + } + + return &fakerRandomizer{ + faker: faker, + rand: ran, + } +} + +// newDeterministicRandomizer builds a stream that returns fixed values. +func newDeterministicRandomizer() Randomizer { + return &deterministicRandomizer{} } -// DeterministicRandomizer returns hard-coded values, removing all randomness -// from example generation -type DeterministicRandomizer struct{} +// previouslySeen returns the value already being built for typ in this run. +// Declaration origins, rather than authored string IDs, distinguish graph +// nodes while still breaking recursive cycles through copied types. +func (r *ExampleGenerator) previouslySeen(typ UserType) (*any, bool) { + s := r.store() + if s.seen == nil { + return nil, false + } + val, haveSeen := s.seen[typ.Origin()] + return val, haveSeen +} + +// haveSeen records the value being built for typ so recursive descent can +// reuse it before construction finishes. +func (r *ExampleGenerator) haveSeen(typ UserType, val *any) { + s := r.store() + if s.seen == nil { + s.seen = make(map[UserType]*any) + } + + s.seen[typ.Origin()] = val +} -func (DeterministicRandomizer) ArrayLength() int { return 1 } -func (DeterministicRandomizer) Int() int { return 1 } -func (DeterministicRandomizer) Int32() int32 { return 1 } -func (DeterministicRandomizer) Int64() int64 { return 1 } -func (DeterministicRandomizer) String() string { return "abc123" } -func (DeterministicRandomizer) Bool() bool { return false } -func (DeterministicRandomizer) Float32() float32 { return 1 } -func (DeterministicRandomizer) Float64() float64 { return 1 } -func (DeterministicRandomizer) UInt() uint { return 1 } -func (DeterministicRandomizer) UInt32() uint32 { return 1 } -func (DeterministicRandomizer) UInt64() uint64 { return 1 } -func (DeterministicRandomizer) Name() string { return "Alice" } -func (DeterministicRandomizer) Email() string { return "alice@example.com" } -func (DeterministicRandomizer) Hostname() string { return "example.com" } -func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } -func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } -func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } -func (DeterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } -func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } +// store returns the generator owning the seen cache and factory: the generator +// this one was derived from, or the generator itself when it is a root. +func (r *ExampleGenerator) store() *ExampleGenerator { + if r.root != nil { + return r.root + } + return r +} + +// structural returns a generator drawing from the structural identity and +// sharing this generator's run-local recursion cache. +func (r *ExampleGenerator) structural(identity ExampleIdentity) *ExampleGenerator { + if r.factory == nil { + return r + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before structural descent") + } + return r.At(identity) +} diff --git a/expr/random_factory_test.go b/expr/random_factory_test.go new file mode 100644 index 0000000000..48e675289b --- /dev/null +++ b/expr/random_factory_test.go @@ -0,0 +1,231 @@ +// This file verifies that immutable example configuration creates independent +// mutable value streams for each code generation run. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // customRandomizerFactory exercises the public factory contract without + // depending on Goa's built-in factory implementations. + customRandomizerFactory struct { + seed string + } + + recordingRandomizerFactory struct { + identities *[]expr.ExampleIdentity + } +) + +// NewRandomizer creates an independent seeded stream for identity. +func (f customRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + if identity.Seed() == "" { + panic("custom randomizer received an empty identity") + } + return expr.NewFakerRandomizerFactory(f.seed).NewRandomizer(identity) +} + +// NewRandomizer records the exact owner selected by example traversal and +// delegates value generation to Goa's deterministic factory. +func (f recordingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + *f.identities = append(*f.identities, identity) + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestRandomizerFactoriesCreateIndependentStreams(t *testing.T) { + cases := []struct { + Name string + Factory expr.RandomizerFactory + }{ + {"faker", expr.NewFakerRandomizerFactory("seed")}, + {"deterministic", expr.NewDeterministicRandomizerFactory()}, + {"custom", customRandomizerFactory{seed: "seed"}}, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(c.Factory).At(identity) + second := expr.NewExampleGenerator(c.Factory).At(identity) + + require.NotSame(t, first, second) + require.Equal(t, first.String(), second.String()) + require.Equal(t, first.Int(), second.Int()) + }) + } +} + +func TestRandomizerFactoriesPreserveDerivedExampleStability(t *testing.T) { + factory := expr.NewFakerRandomizerFactory("seed") + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(factory).At(identity) + second := expr.NewExampleGenerator(factory).At(identity) + + require.Equal(t, first.Member("payload").String(), second.Member("payload").String()) + require.Equal(t, first.ArrayElement(0).Int(), second.ArrayElement(0).Int()) +} + +func TestExampleIdentitiesFrameComponents(t *testing.T) { + for _, delimiter := range []string{".", "/", ":"} { + t.Run(delimiter, func(t *testing.T) { + left := expr.MethodPayloadExampleIdentity(exampleMethod("a"+delimiter+"b", "c")) + right := expr.MethodPayloadExampleIdentity(exampleMethod("a", "b"+delimiter+"c")) + + require.NotEqual(t, left.Seed(), right.Seed()) + }) + } +} + +func TestExampleIdentitiesDistinguishSemanticAndStructuralKinds(t *testing.T) { + method := exampleMethod("service", "method") + payload := expr.MethodPayloadExampleIdentity(method) + result := expr.MethodResultExampleIdentity(method) + + require.NotEqual(t, payload.Seed(), result.Seed()) + require.NotEqual(t, payload.Member("0").Seed(), payload.ArrayElement(0).Seed()) + require.NotEqual(t, payload.Member("value").Seed(), payload.UnionMember("value").Seed()) + require.NotEqual(t, payload.MapKey(0).Seed(), payload.MapValue(0).Seed()) + errorIdentity := expr.MethodErrorExampleIdentity(method, &expr.ErrorExpr{Name: "failure"}) + require.NotEqual(t, result.Member("failure").Seed(), errorIdentity.Seed()) +} + +func TestHTTPResponseIdentitiesIgnoreTraversalOrderAndDistinguishErrors(t *testing.T) { + method := exampleMethod("service", "method") + endpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + ok := &expr.HTTPResponseExpr{StatusCode: expr.StatusOK} + created := &expr.HTTPResponseExpr{StatusCode: expr.StatusCreated} + responses := []*expr.HTTPResponseExpr{ok, created} + before := map[int]string{ + ok.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed(), + created.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed(), + } + + responses[0], responses[1] = responses[1], responses[0] + require.Equal(t, before[created.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed()) + require.Equal(t, before[ok.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed()) + + firstError := &expr.HTTPErrorExpr{Name: "missing", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + secondError := &expr.HTTPErrorExpr{Name: "gone", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + require.NotEqual(t, + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, secondError).Seed(), + ) + require.NotEqual(t, + expr.ResponseBodyExampleIdentity(endpoint, &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + ) +} + +func TestHTTPBodyIdentitiesDistinguishHTTPAndJSONRPCMappings(t *testing.T) { + method := exampleMethod("service", "method") + httpEndpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + jsonRPCEndpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Meta: expr.MetaExpr{"jsonrpc": {}}, + } + + require.NotEqual(t, + expr.RequestBodyExampleIdentity(httpEndpoint).Seed(), + expr.RequestBodyExampleIdentity(jsonRPCEndpoint).Seed(), + ) +} + +func TestGRPCMessageIdentitiesDistinguishExactMethodsAndRoles(t *testing.T) { + dashed := exampleMethod("service", "foo-bar") + underscore := exampleMethod("service", "foo_bar") + errorExpr := &expr.ErrorExpr{Name: "failure"} + + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCRequestMessageExampleIdentity(underscore).Seed(), + ) + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCStreamingRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCStreamingResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + expr.GRPCErrorMessageExampleIdentity(dashed, errorExpr).Seed(), + ) +} + +func TestInlineMethodErrorsRetainMethodErrorIdentity(t *testing.T) { + root := expr.RunDSL(t, func() { + var authored = dsl.Type("AuthoredError", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("inline", dsl.String) + dsl.Error("authored", authored) + }) + }) + }) + method := root.Service("Values").Method("Read") + cases := []struct { + name string + error *expr.ErrorExpr + expected expr.ExampleIdentity + }{ + { + name: "inline", + error: method.Error("inline"), + expected: expr.MethodErrorExampleIdentity(method, method.Error("inline")), + }, + { + name: "authored", + error: method.Error("authored"), + expected: expr.UserTypeExampleIdentity(root.UserType("AuthoredError")), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var identities []expr.ExampleIdentity + generator := expr.NewExampleGenerator(recordingRandomizerFactory{identities: &identities}) + test.error.AttributeExpr.Example(generator.At(expr.MethodPayloadExampleIdentity(method))) + + require.NotEmpty(t, identities) + require.Contains(t, identities, test.expected) + }) + } +} + +func TestConfiguredExampleGeneratorRequiresIdentity(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + + require.Panics(t, func() { + attribute.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("seed"))) + }) +} + +func TestZeroExampleIdentityCannotCreateStructuralIdentity(t *testing.T) { + var identity expr.ExampleIdentity + + require.Panics(t, func() { + identity.Member("field") + }) +} + +func TestDisabledExampleGeneratorSuppressesAuthoredExamples(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{{Value: "authored"}}, + } + + require.Nil(t, attribute.Example(&expr.ExampleGenerator{})) +} + +func exampleMethod(service, method string) *expr.MethodExpr { + svc := &expr.ServiceExpr{Name: service} + return &expr.MethodExpr{Name: method, Service: svc} +} diff --git a/expr/result_type.go b/expr/result_type.go index 5188161b23..dd39c28e97 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -314,14 +314,8 @@ func projectSingle(rt *ResultTypeExpr, view string, seen map[string]UserType) (* } id := rt.projectIdentifier(view) - ut := &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: desc, - Validation: val, - }, - TypeName: typeName, - UID: id, - } + attribute := &AttributeExpr{Description: desc, Validation: val} + ut := projectedUserType(rt, typeName, id, attribute) ut.Type = Dup(v.Type) ut.UserExamples = v.UserExamples projected := &ResultTypeExpr{ @@ -363,17 +357,14 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType // Build the projected collection with the results id := rt.projectIdentifier(view) + attribute := &AttributeExpr{ + Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", + Type: &Array{ElemType: &AttributeExpr{Type: pe}}, + UserExamples: rt.UserExamples, + } proj := &ResultTypeExpr{ - Identifier: id, - UserTypeExpr: &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", - Type: &Array{ElemType: &AttributeExpr{Type: pe}}, - UserExamples: rt.UserExamples, - }, - TypeName: pe.TypeName + "Collection", - UID: id, - }, + Identifier: id, + UserTypeExpr: projectedUserType(rt, pe.TypeName+"Collection", id, attribute), Views: []*ViewExpr{{ AttributeExpr: DupAtt(pe.View(DefaultView).AttributeExpr), Name: DefaultView, @@ -390,6 +381,15 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType return proj, nil } +// projectedUserType preserves the exact example owner of a synthesized result +// type while authored projections retain their media-type-derived UID. +func projectedUserType(source UserType, name, uid string, attribute *AttributeExpr) *UserTypeExpr { + if identity, ok := GeneratedUserTypeExampleIdentity(source); ok { + return NewGeneratedUserType(name, attribute, identity) + } + return &UserTypeExpr{AttributeExpr: attribute, TypeName: name, UID: uid} +} + // projectRecursive computes the projected attribute for the field described // by at within a result type being projected with view. vat is the matching // view attribute. It always returns a fresh attribute: projected types are diff --git a/expr/service.go b/expr/service.go index 43887d95d6..5b1d0e57b3 100644 --- a/expr/service.go +++ b/expr/service.go @@ -1,3 +1,5 @@ +// This file defines service and error expressions, including the distinct +// ownership of authored errors and compiler-wrapped inline method errors. package expr import ( @@ -158,3 +160,13 @@ func (e *ErrorExpr) Finalize() { e.AttributeExpr = &AttributeExpr{Type: ut} } } + +// finalizeMethodType wraps an inline method error with the exact method-error +// owner used by service and transport example generation. +func (e *ErrorExpr) finalizeMethodType(method *MethodExpr) { + e.AttributeExpr = &AttributeExpr{Type: NewGeneratedUserType( + e.Name, + e.AttributeExpr, + MethodErrorExampleIdentity(method, e), + )} +} diff --git a/expr/types.go b/expr/types.go index d7f17657ff..74f6499139 100644 --- a/expr/types.go +++ b/expr/types.go @@ -6,7 +6,6 @@ import ( "fmt" "reflect" "sort" - "strconv" "goa.design/goa/v3/eval" ) @@ -429,7 +428,7 @@ func (a *Array) Example(r *ExampleGenerator) any { for i := range count { // Derive the element value stream from the index so elements get // distinct yet design-stable values. - res[i] = a.ElemType.Example(r.Derived(strconv.Itoa(i))) + res[i] = a.ElemType.Example(r.ArrayElement(i)) if res[i] == nil { // Handle the case of recursive data structures res[i] = make(map[string]any) @@ -549,7 +548,7 @@ func (o *Object) Example(r *ExampleGenerator) any { for _, nat := range *o { // Derive the field value stream from the field name so a field // example only changes when the field itself changes. - if v := nat.Attribute.Example(r.Derived(nat.Name)); v != nil { + if v := nat.Attribute.Example(r.Member(nat.Name)); v != nil { res[nat.Name] = v } } @@ -595,8 +594,8 @@ func (m *Map) Example(r *ExampleGenerator) any { for i := range count { // Derive per-entry value streams from the entry index so entries // get distinct yet design-stable keys and values. - k := m.KeyType.Example(r.Derived("key" + strconv.Itoa(i))) - v := m.ElemType.Example(r.Derived("val" + strconv.Itoa(i))) + k := m.KeyType.Example(r.MapKey(i)) + v := m.ElemType.Example(r.MapValue(i)) if k != nil && v != nil { pair[k] = v } @@ -681,7 +680,7 @@ func (u *Union) Example(r *ExampleGenerator) any { nat := u.Values[r.Int()%len(u.Values)] return map[string]any{ u.GetTypeKey(): nat.Name, - u.GetValueKey(): nat.Attribute.Example(r.Derived(nat.Name)), + u.GetValueKey(): nat.Attribute.Example(r.UnionMember(nat.Name)), } } diff --git a/expr/types_test.go b/expr/types_test.go index 66e6358f60..db819003bf 100644 --- a/expr/types_test.go +++ b/expr/types_test.go @@ -1,3 +1,5 @@ +// This file verifies expression type conversion, compatibility, and example +// behavior, including the tagged representation produced for union values. package expr import "testing" @@ -919,7 +921,12 @@ func TestUnionExampleAndCompatibilityUseTaggedEnvelope(t *testing.T) { }, } - example := union.Example(NewRandom("test")) + example := union.Example(NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "union", + Service: &ServiceExpr{Name: "test"}, + }), + )) envelope, ok := example.(map[string]any) if !ok { t.Fatalf("expected tagged envelope, got %T", example) diff --git a/expr/user_type.go b/expr/user_type.go index 90e5381aec..e43c4ce42a 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -7,8 +7,9 @@ type ( // ensure that the names are unique the code used to generate code can // create multiple user types that share the same name (for example because // generated in different packages). When supplied, UID is a stable semantic - // identifier used by deterministic examples and media-type behavior; Origin - // identifies copied in-memory declarations. + // identifier used by authored examples and media-type behavior; generated + // types retain a separate opaque example owner. Origin identifies copied + // in-memory declarations. UserTypeExpr struct { // The embedded attribute expression. *AttributeExpr @@ -18,9 +19,27 @@ type ( UID string // origin is the earliest declaration copied to create this type. origin UserType + // exampleIdentity is the semantic owner of a type synthesized by a + // transport generator. Authored types leave it empty and use ID. + exampleIdentity ExampleIdentity } ) +// NewGeneratedUserType creates a synthesized user type whose stable ID and +// examples are derived from identity. Code generators use this constructor so +// a copied wire type cannot accidentally inherit an authored type's identity. +func NewGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity) *UserTypeExpr { + if identity.seed == "" { + panic("generated user type requires an example identity") + } + return &UserTypeExpr{ + AttributeExpr: attribute, + TypeName: name, + UID: "generated:" + identity.Seed(), + exampleIdentity: identity, + } +} + // ID returns the unique identifier for the user type. func (u *UserTypeExpr) ID() string { if u.UID != "" { @@ -82,10 +101,11 @@ func (u *UserTypeExpr) Dup(att *AttributeExpr) UserType { return u } return &UserTypeExpr{ - AttributeExpr: att, - TypeName: u.TypeName, - UID: u.UID, - origin: u.Origin(), + AttributeExpr: att, + TypeName: u.TypeName, + UID: u.UID, + origin: u.Origin(), + exampleIdentity: u.exampleIdentity, } } @@ -104,16 +124,16 @@ func (u *UserTypeExpr) Example(r *ExampleGenerator) any { } func (u *UserTypeExpr) recExample(r *ExampleGenerator) *any { - if ex, ok := r.PreviouslySeen(u.ID()); ok { + if ex, ok := r.previouslySeen(u); ok { return ex } var ex any pex := &ex - r.HaveSeen(u.ID(), pex) + r.haveSeen(u, pex) // Anchor the value stream to the type identity so the example depends // only on the type definition, not on how many examples were computed // before it nor on which design path reached the type first. - actual := u.AttributeExpr.Example(r.Rebased(u.ID())) + actual := u.AttributeExpr.Example(r.At(UserTypeExampleIdentity(u))) *pex = actual return pex } diff --git a/expr/user_type_example_test.go b/expr/user_type_example_test.go index b9fc3b5425..282c8c1736 100644 --- a/expr/user_type_example_test.go +++ b/expr/user_type_example_test.go @@ -1,8 +1,12 @@ +// This file verifies authored and generated user types retain independent +// example ownership while recursive copies share one declaration origin. package expr_test import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -34,7 +38,9 @@ func TestUserTypeWithUserExample(t *testing.T) { // Test with both randomizers to ensure user examples always take precedence t.Run("with faker randomizer", func(t *testing.T) { - exampleGen := expr.NewRandom("test") + exampleGen := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -42,10 +48,9 @@ func TestUserTypeWithUserExample(t *testing.T) { }) t.Run("with deterministic randomizer", func(t *testing.T) { - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -81,10 +86,9 @@ func TestUserTypeFormatWithCustomExample(t *testing.T) { } // Test with deterministic randomizer (as reported in the issue) - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The bug was that this would return "https://example.com/foo" instead of the custom example example := attr.Example(exampleGen) @@ -117,10 +121,9 @@ func TestIssue3716Regression(t *testing.T) { } // When generating an example for the object - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.MethodPayloadExampleIdentity(exampleMethod("issue-3716", "object")), + ) example := obj.Example(exampleGen) objExample, ok := example.(map[string]any) @@ -162,10 +165,9 @@ func TestUserTypeWithOwnExample(t *testing.T) { } // Use deterministic randomizer - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The UserType itself should return its custom example example := urlType.Example(exampleGen) @@ -174,3 +176,58 @@ func TestUserTypeWithOwnExample(t *testing.T) { t.Errorf("UserType with custom example should return %q, got %q", customExample, example) } } + +func TestUserTypeExamplesIgnoreStringIDCollisions(t *testing.T) { + method := exampleMethod("service", "method") + owner := expr.MethodPayloadExampleIdentity(method) + generated := expr.NewGeneratedUserType("Generated", &expr.AttributeExpr{Type: &expr.Object{ + {Name: "generated", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, owner) + generatedOwner, ok := expr.GeneratedUserTypeExampleIdentity(generated) + require.True(t, ok) + require.Equal(t, owner, generatedOwner) + authored := &expr.UserTypeExpr{ + TypeName: "Authored", + UID: generated.ID(), + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "authored", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + _, ok = expr.GeneratedUserTypeExampleIdentity(authored) + require.False(t, ok) + + cases := []struct { + name string + first *expr.UserTypeExpr + firstField string + second *expr.UserTypeExpr + secondField string + }{ + { + name: "authored then generated", + first: authored, + firstField: "authored", + second: generated, + secondField: "generated", + }, + { + name: "generated then authored", + first: generated, + firstField: "generated", + second: authored, + secondField: "authored", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(expr.UserTypeExampleIdentity(test.first))).(map[string]any) + second := test.second.Example(generator.At(expr.UserTypeExampleIdentity(test.second))).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} diff --git a/go.mod b/go.mod index f8c1dd79fe..5b6c9ce6fb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.40.0 golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 google.golang.org/grpc v1.83.1 @@ -27,7 +28,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index a673195f28..992478e150 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -20,13 +20,13 @@ func TestExampleCLIFiles(t *testing.T) { DSL func() PkgPath string }{ - {"no-server", ctestdata.NoServerDSL, ""}, - {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, ""}, - {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, ""}, + {"no-server", ctestdata.NoServerDSL, "/"}, + {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, "/"}, + {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, "/"}, {"no-server-pkgpath", ctestdata.NoServerDSL, "my/pkg/path"}, {"server-hosting-service-subset-pkgpath", ctestdata.ServerHostingServiceSubsetDSL, "my/pkg/path"}, {"server-hosting-multiple-services-pkgpath", ctestdata.ServerHostingMultipleServicesDSL, "my/pkg/path"}, - {"interceptors", testdata.InterceptorsDSL, ""}, + {"interceptors", testdata.InterceptorsDSL, "/"}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/grpc/codegen/example_identity_test.go b/grpc/codegen/example_identity_test.go new file mode 100644 index 0000000000..a768e4c00d --- /dev/null +++ b/grpc/codegen/example_identity_test.go @@ -0,0 +1,12 @@ +// This file supplies exact semantic owners to protobuf-shaping unit tests. +package codegen + +import "goa.design/goa/v3/expr" + +// testGRPCMessageExampleIdentity returns a distinct request-message owner for +// the named test case without introducing production fallback identity rules. +func testGRPCMessageExampleIdentity(name string) expr.ExampleIdentity { + service := &expr.ServiceExpr{Name: "test"} + method := &expr.MethodExpr{Name: name, Service: service} + return expr.GRPCRequestMessageExampleIdentity(method) +} diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 2f48be2376..624ca0b336 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -50,7 +50,11 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { // Transform protobuf -> Go for Container target := &expr.AttributeExpr{Type: root.UserType("Container")} - source := makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) + source := makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity("anonymous-user-union"), + ) freezeProtoBufTransformMessages(sd, source) pbCtx := protoBufTypeContext("proto", sd, true) diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index caa0079f18..1c557eb81b 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies that gRPC client endpoint parsing renders from a legal +// generated package root while preserving configured interceptor wiring. package codegen import ( @@ -25,7 +27,7 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := NewServicesData(createServiceServicesForPackage(root, "")) + services := NewServicesData(createServiceServicesForPackage(root, "/")) fs := ClientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go index 11ea81809d..d600492aa3 100644 --- a/grpc/codegen/plan_test.go +++ b/grpc/codegen/plan_test.go @@ -25,11 +25,12 @@ func TestPlanReservesGeneratedGRPCPackages(t *testing.T) { }) } }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) client := services.PackageImport("generated.local/gen/grpc/foo/client") diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index f29f191257..2ec7d7aa26 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -85,41 +85,33 @@ func protoBufTypeContext(pkg string, service *ServiceData, useDefault bool) *cod // map, it wraps the given attribute with an object with a single "field" // attribute. For nested arrays/maps, the inner array/map is wrapped into a // user type. -func makeProtoBufMessage(att *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufMessage(att *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { att = expr.DupAtt(att) expr.RemovePkgPath(att) ut, isut := att.Type.(expr.UserType) switch { case att.Type == expr.Empty: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, &expr.AttributeExpr{Type: &expr.Object{}}, owner) return att case expr.IsPrimitive(att.Type): - wrapAttr(att, tname, true, sd) + wrapAttr(att, tname, true, owner) return att case isut: - if expr.IsArray(ut) { - wrapAttr(att, tname, false, sd) + if expr.IsArray(ut) || expr.IsMap(ut) { + wrapAttr(att, tname, false, owner) } case expr.IsArray(att.Type) || expr.IsMap(att.Type): - wrapAttr(att, tname, false, sd) + wrapAttr(att, tname, false, owner) case expr.IsObject(att.Type) || expr.IsUnion(att.Type): - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: expr.DupAtt(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, expr.DupAtt(att), owner) } n := "" - makeProtoBufMessageR(att, &n, sd, make(map[expr.UserType]struct{})) + makeProtoBufMessageR(att, &n, owner, make(map[expr.UserType]struct{})) return att } // makeProtoBufMessageR is the recursive implementation of makeProtoBufMessage. -func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceData, seen map[expr.UserType]struct{}) { +func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, owner expr.ExampleIdentity, seen map[expr.UserType]struct{}) { ut, isut := att.Type.(expr.UserType) // handle infinite recursions @@ -135,12 +127,12 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceDat switch { case expr.IsArray(att.Type): wrapAttr(att, "ArrayOf"+tname+ - protoBufify(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType), true, true), true, sd) + protoBufify(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType), true, true), true, owner) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) wrapAttr(att, tname+"MapOf"+ protoBufify(protoBufShapeTypeName(m.KeyType), true, true)+ - protoBufify(protoBufShapeTypeName(m.ElemType), true, true), true, sd) + protoBufify(protoBufShapeTypeName(m.ElemType), true, true), true, owner) } } @@ -148,32 +140,37 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceDat case expr.IsPrimitive(att.Type): return case isut: - if expr.IsArray(ut) { - wrapAttr(ut.Attribute(), ut.Name(), false, sd) + switch { + case expr.IsArray(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCArrayWrapperExampleIdentity(ut)) + case expr.IsMap(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCMapWrapperExampleIdentity(ut)) } - makeProtoBufMessageR(ut.Attribute(), tname, sd, seen) + makeProtoBufMessageR(ut.Attribute(), tname, owner, seen) case expr.IsArray(att.Type): ar := expr.AsArray(att.Type) - makeProtoBufMessageR(ar.ElemType, tname, sd, seen) + elementOwner := owner.ArrayElement(0) + makeProtoBufMessageR(ar.ElemType, tname, elementOwner, seen) wrap(ar.ElemType, *tname) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) - makeProtoBufMessageR(m.ElemType, tname, sd, seen) + valueOwner := owner.MapValue(0) + makeProtoBufMessageR(m.ElemType, tname, valueOwner, seen) wrap(m.ElemType, *tname) case expr.IsUnion(att.Type): for _, nat := range expr.AsUnion(att.Type).Values { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.UnionMember(nat.Name), seen) } case expr.IsObject(att.Type): for _, nat := range *(expr.AsObject(att.Type)) { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.Member(nat.Name), seen) } } } // wrapAttr makes the attribute type a user type by wrapping the given // attribute into an attribute named "field". -func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) { +func wrapAttr(att *expr.AttributeExpr, tname string, req bool, owner expr.ExampleIdentity) { wrap := func(attr *expr.AttributeExpr) *expr.AttributeExpr { res := &expr.AttributeExpr{ Type: &expr.Object{ @@ -198,15 +195,9 @@ func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) switch dt := att.Type.(type) { case expr.UserType: // Don't change the original user type. Create a copy and wrap that. - ut := expr.Dup(dt).(expr.UserType) - ut.SetAttribute(wrap(ut.Attribute())) - att.Type = ut + att.Type = expr.NewGeneratedUserType(dt.Name(), wrap(expr.DupAtt(dt.Attribute())), owner) default: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: wrap(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, wrap(att), owner) } // Validation is moved to wrapped attribute. att.Validation = nil diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index 1065eb55c8..6799512d3d 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -274,8 +274,11 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - sd := &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} - att := makeProtoBufMessage(&expr.AttributeExpr{Type: c.Type()}, "Message", sd) + att := makeProtoBufMessage( + &expr.AttributeExpr{Type: c.Type()}, + "Message", + testGRPCMessageExampleIdentity(c.Name), + ) require.True(t, isWrappedAttr(att), "expected message to be marked as a wrapper") field := unwrapAttr(att) assert.Equal(t, c.FieldKind, field.Type.Kind(), "unexpected wrapped field kind") @@ -297,10 +300,7 @@ func TestMakeProtoBufMessageDistinguishesEqualUIDOrigins(t *testing.T) { {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, }} - message := makeProtoBufMessage(body, "Request", &ServiceData{ - Name: "Service", - Scope: codegen.NewNameScope(), - }) + message := makeProtoBufMessage(body, "Request", testGRPCMessageExampleIdentity("equal-UID-origins")) object := expr.AsObject(message.Type.(expr.UserType).Attribute().Type) wireFirst := object.Attribute("first").Type.(expr.UserType) wireSecond := object.Attribute("second").Type.(expr.UserType) @@ -308,6 +308,119 @@ func TestMakeProtoBufMessageDistinguishesEqualUIDOrigins(t *testing.T) { require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireSecond})) } +func TestMakeProtoBufMessageDistinguishesNormalizedMethodNames(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + dashedMethod := &expr.MethodExpr{Name: "foo-bar", Service: service} + underscoreMethod := &expr.MethodExpr{Name: "foo_bar", Service: service} + dashedOwner := expr.GRPCRequestMessageExampleIdentity(dashedMethod) + underscoreOwner := expr.GRPCRequestMessageExampleIdentity(underscoreMethod) + dashed := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "dashed", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + dashedOwner, + ) + underscore := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "underscore", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + underscoreOwner, + ) + require.NotEqual(t, dashed.Type.(expr.UserType).ID(), underscore.Type.(expr.UserType).ID()) + + cases := []struct { + name string + first *expr.AttributeExpr + firstOwner expr.ExampleIdentity + firstField string + second *expr.AttributeExpr + secondOwner expr.ExampleIdentity + secondField string + }{ + { + name: "dashed then underscore", + first: dashed, + firstOwner: dashedOwner, + firstField: "dashed", + second: underscore, + secondOwner: underscoreOwner, + secondField: "underscore", + }, + { + name: "underscore then dashed", + first: underscore, + firstOwner: underscoreOwner, + firstField: "underscore", + second: dashed, + secondOwner: dashedOwner, + secondField: "dashed", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(test.firstOwner)).(map[string]any) + second := test.second.Example(generator.At(test.secondOwner)).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} + +func TestMakeProtoBufMessageSharesAuthoredCollectionWrapperIdentity(t *testing.T) { + arrayAlias := &expr.UserTypeExpr{ + TypeName: "Strings", + UID: "strings", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + } + mapAlias := &expr.UserTypeExpr{ + TypeName: "Labels", + UID: "labels", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }}, + } + owner := testGRPCMessageExampleIdentity("shared-collection-aliases") + build := func(fields []string) *expr.AttributeExpr { + attributes := make(expr.Object, len(fields)) + for index, name := range fields { + typ := expr.UserType(arrayAlias) + if name == "map_a" || name == "map_b" { + typ = mapAlias + } + attributes[index] = &expr.NamedAttributeExpr{ + Name: name, + Attribute: &expr.AttributeExpr{Type: typ}, + } + } + return makeProtoBufMessage( + &expr.AttributeExpr{Type: &attributes}, + "SharedCollectionsRequest", + owner, + ) + } + forward := build([]string{"array_a", "map_a", "array_b", "map_b"}) + reverse := build([]string{"map_b", "array_b", "map_a", "array_a"}) + example := func(message *expr.AttributeExpr) map[string]any { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + return message.Example(generator.At(owner)).(map[string]any) + } + + forwardExample := example(forward) + reverseExample := example(reverse) + require.Equal(t, forwardExample, reverseExample) + require.Equal(t, forwardExample["array_a"], forwardExample["array_b"]) + require.Equal(t, forwardExample["map_a"], forwardExample["map_b"]) +} + // protobufArrayTraversalType builds an authored array declaration that protobuf // conversion must wrap in a message. func protobufArrayTraversalType(name, uid string) *expr.UserTypeExpr { diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index 0086d0e38d..c6f6693daf 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -170,11 +170,19 @@ func TestProtoBufTransform(t *testing.T) { srcCtx := c.Ctx tgtCtx := c.Ctx if c.ToProto { - target = makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) + target = makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/target"), + ) freezeProtoBufTransformMessages(sd, target) tgtCtx = protoBufTypeContext("proto", sd, true) } else { - source = makeProtoBufMessage(expr.DupAtt(source), source.Type.Name(), sd) + source = makeProtoBufMessage( + expr.DupAtt(source), + source.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/source"), + ) freezeProtoBufTransformMessages(sd, source) srcCtx = protoBufTypeContext("proto", sd, true) } diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index d536934def..7b72abeae6 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -571,7 +571,9 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } errors := d.buildErrorsData(e, errorMessages, sd) // build request data - reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server") + payloadIdentity := expr.MethodPayloadExampleIdentity(e.MethodExpr) + resultIdentity := expr.MethodResultExampleIdentity(e.MethodExpr) + reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server", payloadIdentity) request := &RequestData{ Description: requestMessage.Description, Metadata: reqMD, @@ -588,7 +590,7 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { Ref: "message", TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd), TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), - Example: requestMessage.Example(d.Root.API.ExampleGenerator), + Example: d.Example(requestMessage, payloadIdentity), }) } // pass the metadata as arguments to client CLI args @@ -609,8 +611,8 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { // build response data serverResult, serverCtx := d.resultContext(e, sd, "server") clientResult, clientCtx := d.resultContext(e, sd, "client") - hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client") - trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client") + hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client", resultIdentity) + trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client", resultIdentity) response := &ResponseData{ StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), Description: e.Response.Description, @@ -678,14 +680,22 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) prepared := make([]*protobufEndpointMessages, len(serviceExpr.GRPCEndpoints)) for index, endpoint := range serviceExpr.GRPCEndpoints { useStreamEnvelope := usesStreamEnvelope(endpoint) - request := makeProtoBufMessage(endpoint.Request, protoBufify(endpoint.Name()+"_request", true, true), sd) + request := makeProtoBufMessage( + endpoint.Request, + protoBufify(endpoint.Name()+"_request", true, true), + expr.GRPCRequestMessageExampleIdentity(endpoint.MethodExpr), + ) streamingRequest := endpoint.StreamingRequest if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { name := protoBufify(endpoint.Name()+"_streaming_request", true, true) if useStreamEnvelope { name = protoBufify(endpoint.Name()+"_stream_item", true, true) } - streamingRequest = makeProtoBufMessage(endpoint.StreamingRequest, name, sd) + streamingRequest = makeProtoBufMessage( + endpoint.StreamingRequest, + name, + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), + ) } var requestEnvelope *expr.AttributeExpr if useStreamEnvelope { @@ -693,10 +703,18 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) request, streamingRequest, protoBufify(endpoint.Name()+"_streaming_request", true, true), - sd, + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), ) } - response := makeProtoBufMessage(endpoint.Response.Message, protoBufify(endpoint.Name()+"_response", true, true), sd) + responseOwner := expr.GRPCResponseMessageExampleIdentity(endpoint.MethodExpr) + if endpoint.MethodExpr.IsResultStreaming() { + responseOwner = expr.GRPCStreamingResponseMessageExampleIdentity(endpoint.MethodExpr) + } + response := makeProtoBufMessage( + endpoint.Response.Message, + protoBufify(endpoint.Name()+"_response", true, true), + responseOwner, + ) errors := make(map[string]*expr.AttributeExpr, len(endpoint.GRPCErrors)) for _, grpcError := range endpoint.GRPCErrors { if grpcError.Type == expr.ErrorResult || !expr.IsObject(grpcError.Type) { @@ -705,7 +723,7 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) errors[grpcError.Name] = makeProtoBufMessage( grpcError.Response.Message, protoBufify(endpoint.Name()+"_"+grpcError.Name+"_error", true, true), - sd, + expr.GRPCErrorMessageExampleIdentity(endpoint.MethodExpr, grpcError.ErrorExpr), ) } prepared[index] = &protobufEndpointMessages{ @@ -887,7 +905,7 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side - data := d.buildInitData(request, payload, "message", "v", svcCtx, method.Payload, false, false, sd) + data := d.buildInitData(request, payload, "message", "v", svcCtx, method.Payload, false, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr)) data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) // pass the metadata as arguments to payload constructor in server @@ -903,7 +921,7 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE } // client side - data := d.buildInitData(payload, request, "payload", "message", svcCtx, method.Payload, true, false, sd) + data := d.buildInitData(payload, request, "payload", "message", svcCtx, method.Payload, true, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr)) data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ SrcName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), @@ -938,14 +956,15 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi mdObj.Set("goa_payload", expr.DupAtt(payload)) legacyMD.Validation.AddRequired("goa_payload") } - md := d.extractMetadata(legacyMD, payload, sd, "server") + owner := expr.MethodPayloadExampleIdentity(e.MethodExpr) + md := d.extractMetadata(legacyMD, payload, sd, "server", owner) data := &LegacyDecodeData{ FuncName: fmt.Sprintf("decode%sLegacyRequest", codegen.Goify(e.Name(), true)), Metadata: md, } if expr.IsObject(payload.Type) { svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) - init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, sd.Service.Method(e.Name()).Payload, false, false, sd) + init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, sd.Service.Method(e.Name()).Payload, false, false, sd, owner) init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) init.Args = append(init.Args, initArgsFromMetadata(md, init.ReturnVarName)...) @@ -978,7 +997,7 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute } if svr { // server side - data := d.buildInitData(result, response, "result", "message", svcCtx, resultName, true, false, sd) + data := d.buildInitData(result, response, "result", "message", svcCtx, resultName, true, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr)) data.Description = fmt.Sprintf("%s builds the gRPC response type from the result of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), @@ -990,7 +1009,7 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute } // client side - data := d.buildInitData(response, result, "message", "result", svcCtx, resultName, false, false, sd) + data := d.buildInitData(response, result, "message", "result", svcCtx, resultName, false, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr)) data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) // pass the headers as arguments to result constructor in client @@ -1015,7 +1034,7 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // transformation // svcCtx is the attribute context for service type // proto if true indicates the target type is a protocol buffer type -func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, serviceTypeName string, proto, usesrc bool, sd *ServiceData) *InitData { +func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, serviceTypeName string, proto, usesrc bool, sd *ServiceData, owner expr.ExampleIdentity) *InitData { pbCtx := protoBufTypeContext(sd.PkgName, sd, false) name := "New" srcCtx := pbCtx @@ -1058,7 +1077,7 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV Ref: sourceVar, TypeName: srcCtx.Scope.Name(source, srcCtx.Pkg(source), srcCtx.Pointer, srcCtx.UseDefault), TypeRef: srcCtx.Scope.Ref(source, srcCtx.Pkg(source)), - Example: source.Example(d.Root.API.ExampleGenerator), + Example: d.Example(source, owner), }} } return &InitData{ @@ -1121,7 +1140,8 @@ func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRP } if svr { // server side - data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, errorTypeName, true, false, sd) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, errorTypeName, true, false, sd, owner) data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) data.Description = fmt.Sprintf("%s builds the gRPC error response type from the error of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) return &ConvertData{ @@ -1134,7 +1154,8 @@ func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRP } // client side - data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, errorTypeName, false, false, sd) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, errorTypeName, false, false, sd, owner) data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) data.Description = fmt.Sprintf("%s builds the error type of the %q endpoint of the %q service from the gRPC error response type.", data.Name, e.Name(), svc.Name) return &ConvertData{ @@ -1208,7 +1229,7 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SrcRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd), TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), - Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, resultName, true, true, sd), + Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, resultName, true, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr)), } } if e.MethodExpr.StreamingPayload.Type != expr.Empty { @@ -1220,7 +1241,7 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), TgtName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: recvRef, - Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, streamingPayloadName, false, true, sd), + Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, streamingPayloadName, false, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)), Validation: addValidation(streamingRequest, sd, true), } } @@ -1239,7 +1260,7 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SrcRef: sendRef, TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd), TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), - Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, streamingPayloadName, true, true, sd), + Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, streamingPayloadName, true, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)), } } if e.MethodExpr.Result.Type != expr.Empty { @@ -1251,7 +1272,7 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), TgtName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), TgtRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, resultName, false, true, sd), + Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, resultName, false, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr)), Validation: addValidation(responseMessage, sd, false), } } @@ -1289,7 +1310,7 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques // extractMetadata collects the request/response metadata from the given // metadata attribute and service type (payload/result). -func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side string) []*MetadataData { +func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side string, owner expr.ExampleIdentity) []*MetadataData { var metadata []*MetadataData codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck wire := nativeMetadataAttribute(c) @@ -1347,7 +1368,7 @@ func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *exp StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, Validate: codegen.AttributeValidationCode(wire, nil, wireCtx, required, false, varn, name), DefaultValue: wire.DefaultValue, - Example: wire.Example(d.Root.API.ExampleGenerator.Field(service, name)), + Example: d.FieldExample(wire, service, name, owner), }) return nil }) @@ -1430,7 +1451,7 @@ func usesStreamEnvelope(e *expr.GRPCEndpointExpr) bool { // makeProtoBufStreamEnvelope builds the protobuf stream envelope that carries // the initial request payload frame and subsequent stream item frames. -func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { initial := expr.DupAtt(request) initial.Meta = initial.Meta.Dup() initial.Meta["rpc:tag"] = []string{"1"} @@ -1454,7 +1475,7 @@ func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname strin }, Validation: &expr.ValidationExpr{Required: []string{"body"}}, } - return makeProtoBufMessage(envelope, tname, sd) + return makeProtoBufMessage(envelope, tname, owner) } // buildStreamEnvelopeData computes the generated Go names for the protobuf diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index ec81887a64..8a18d520fa 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -1,9 +1,9 @@ import ( + interceptors "//interceptors" + cli "/grpc/cli/test" "fmt" - cli "grpc/cli/test" "os" - interceptors "./interceptors" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testdata/client-no-server.golden b/grpc/codegen/testdata/client-no-server.golden index e9259418ae..558ad34610 100644 --- a/grpc/codegen/testdata/client-no-server.golden +++ b/grpc/codegen/testdata/client-no-server.golden @@ -1,6 +1,6 @@ import ( + cli "/grpc/cli/test_api" "fmt" - cli "grpc/cli/test_api" "os" goa "goa.design/goa/v3/pkg" diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden index dcfe3180eb..39bced6089 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden @@ -1,6 +1,6 @@ import ( + cli "/grpc/cli/single_host" "fmt" - cli "grpc/cli/single_host" "os" goa "goa.design/goa/v3/pkg" diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset.golden b/grpc/codegen/testdata/client-server-hosting-service-subset.golden index dcfe3180eb..39bced6089 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset.golden @@ -1,6 +1,6 @@ import ( + cli "/grpc/cli/single_host" "fmt" - cli "grpc/cli/single_host" "os" goa "goa.design/goa/v3/pkg" diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index b667bdc512..4612ee935a 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -6,11 +6,11 @@ package cli import ( + servicewithinterceptorsc "/grpc/service_with_interceptors/client" + servicewithinterceptors "/service_with_interceptors" "flag" "fmt" - servicewithinterceptorsc "grpc/service_with_interceptors/client" "os" - servicewithinterceptors "service_with_interceptors" goa "goa.design/goa/v3/pkg" grpc "google.golang.org/grpc" @@ -27,7 +27,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'" + "\n" + + return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"Voluptatem officia aut.\"\n }'" + "\n" + "" } @@ -161,7 +161,7 @@ func serviceWithInterceptorsMethodAUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"Voluptatem officia aut.\"\n }'") } func serviceWithInterceptorsMethodBUsage() { @@ -179,5 +179,5 @@ func serviceWithInterceptorsMethodBUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 8804614586670373312\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 3200818835133106279\n }'") } diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index f98d578542..990acc37b6 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -1,5 +1,5 @@ // This file builds gRPC code-generation analysis in tests using the same -// normalize, plan, freeze, and render lifecycle as production generation. +// generation construction, planning, freezing, and rendering as production. package codegen import ( @@ -21,11 +21,9 @@ func RunGRPCDSL(t *testing.T, dsl func()) *expr.RootExpr { return root } -// CreateGRPCServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. +// CreateGRPCServices creates a new ServicesData instance for testing. +// Generation construction normalizes the root before any planner reads it. func CreateGRPCServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) return NewServicesData(createServiceServices(root)) } @@ -38,7 +36,10 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { // createServiceServicesForPackage builds test service analysis for the exact // generated module path whose imports the test renders. func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *service.ServicesData { - generation := codegen.NewGeneration(genpkg, []eval.Root{root}) + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } if err := service.Plan(root, generation); err != nil { panic(err) } @@ -51,7 +52,7 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) if err != nil { panic(err) } diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index 6f8355e4f7..8f400b3871 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -1,12 +1,15 @@ +// This file verifies HTTP client CLI generation consumes stable, non-empty +// examples for body, parameter, header, cookie, array, and map flags. package codegen import ( "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -61,3 +64,15 @@ func TestClientCLIFiles(t *testing.T) { }) } } + +func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveFieldEmptyDSL) + services := CreateHTTPServices(root) + endpoint := services.Get("ServiceBodyPrimitiveArrayUser").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + example := endpoint.Payload.Request.PayloadInit.ClientArgs[0].Example + + require.IsType(t, []string{}, example) + require.NotEmpty(t, example) +} diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 4fb1f2d827..98eb608f77 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -1,3 +1,5 @@ +// This file renders HTTP security designs through both OpenAPI versions and +// verifies cookie API-key placement with run-owned example generation enabled. package codegen import ( @@ -38,7 +40,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) openapi.Definitions = make(map[string]*openapi.Schema) - v2Files, err := openapiv2.Files(root, openapi.DefaultPath20) + v2Files, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) v2JSON := renderOpenAPIJSON(t, v2Files) var swagger openapi2.T @@ -56,7 +58,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { } openapi.Definitions = make(map[string]*openapi.Schema) - v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30)) + v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30, expr.NewExampleGenerator(root.API.RandomizerFactory))) loader := openapi3.NewLoader() doc, err := loader.LoadFromData(v3JSON) require.NoError(t, err) diff --git a/http/codegen/openapi.go b/http/codegen/openapi.go index af0f42fb5d..fcde98d977 100644 --- a/http/codegen/openapi.go +++ b/http/codegen/openapi.go @@ -1,3 +1,7 @@ +// This file turns a prepared HTTP design into the requested OpenAPI documents. +// The generator supplies one run-owned example coordinator; each OpenAPI +// version anchors its schema and displayed values to the exact request, +// response, or error expression that owns them. package codegen import ( @@ -12,7 +16,7 @@ import ( // The "openapi:versions" API meta selects the generated specification // versions and the "openapi:path:" API meta overrides their output // paths, see openapi.Specs. -func OpenAPIFiles(root *expr.RootExpr) ([]*codegen.File, error) { +func OpenAPIFiles(root *expr.RootExpr, generator *expr.ExampleGenerator) ([]*codegen.File, error) { // Only create a OpenAPI specification if there are HTTP services. if len(root.API.HTTP.Services) == 0 { return nil, nil @@ -24,17 +28,31 @@ func OpenAPIFiles(root *expr.RootExpr) ([]*codegen.File, error) { } var files []*codegen.File for _, spec := range specs { + specGenerator := generator + if examplesDisabled(root.API.Meta) { + specGenerator = &expr.ExampleGenerator{} + } var fs []*codegen.File switch spec.Version { case openapi.Version20: - fs, err = openapiv2.Files(root, spec.Path) + fs, err = openapiv2.Files(root, spec.Path, specGenerator) if err != nil { return nil, err } default: // Version30, Version32 - fs = openapiv3.Files(root, spec.Version, spec.Path) + fs = openapiv3.Files(root, spec.Version, spec.Path, specGenerator) } files = append(files, fs...) } return files, nil } + +// examplesDisabled reports whether API metadata suppresses examples from +// generated OpenAPI documents. +func examplesDisabled(meta expr.MetaExpr) bool { + value, ok := meta.Last("openapi:example") + if !ok { + value, ok = meta.Last("swagger:example") + } + return ok && value == "false" +} diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go index 287dbda967..27076a5bde 100644 --- a/http/codegen/openapi/json_schema.go +++ b/http/codegen/openapi/json_schema.go @@ -1,3 +1,5 @@ +// This file renders shared OpenAPI JSON schemas and anchors every generated +// example to the method or concrete transport response that owns it. package openapi import ( @@ -147,9 +149,9 @@ func (s *Schema) JSON() ([]byte, error) { } // APISchema produces the API JSON hyper schema. -func APISchema(api *expr.APIExpr, r *expr.RootExpr) *Schema { +func APISchema(api *expr.APIExpr, r *expr.RootExpr, gen *expr.ExampleGenerator) *Schema { for _, res := range r.API.HTTP.Services { - GenerateServiceDefinition(api, res) + GenerateServiceDefinition(api, res, gen) } href := string(api.Servers[0].Hosts[0].URIs[0]) links := []*Link{ @@ -181,7 +183,7 @@ func APISchema(api *expr.APIExpr, r *expr.RootExpr) *Schema { // GenerateServiceDefinition produces the JSON schema corresponding to the given // service. It stores the results in Definitions. -func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { +func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr, gen *expr.ExampleGenerator) { s := NewSchema() s.Description = res.Description() s.Type = Object @@ -190,13 +192,15 @@ func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { for _, a := range res.HTTPEndpoints { var requestSchema *Schema if a.MethodExpr.Payload.Type != expr.Empty { - requestSchema = AttributeTypeSchema(api, a.MethodExpr.Payload) + payloadGenerator := gen.At(expr.MethodPayloadExampleIdentity(a.MethodExpr)) + requestSchema = AttributeTypeSchema(api, a.MethodExpr.Payload, payloadGenerator) requestSchema.Description = a.Name() + " payload" } var targetSchema *Schema var identifier string for _, resp := range a.Responses { dt := resp.Body.Type + responseGenerator := gen.At(expr.ResponseBodyExampleIdentity(a, resp)) if mt := dt.(*expr.ResultTypeExpr); mt != nil { if identifier == "" { identifier = mt.Identifier @@ -205,13 +209,13 @@ func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { } switch { case targetSchema == nil: - targetSchema = TypeSchemaWithPrefix(api, mt, a.Name()) + targetSchema = TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator) case targetSchema.AnyOf == nil: firstSchema := targetSchema targetSchema = NewSchema() - targetSchema.AnyOf = []*Schema{firstSchema, TypeSchemaWithPrefix(api, mt, a.Name())} + targetSchema.AnyOf = []*Schema{firstSchema, TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator)} default: - targetSchema.AnyOf = append(targetSchema.AnyOf, TypeSchemaWithPrefix(api, mt, a.Name())) + targetSchema.AnyOf = append(targetSchema.AnyOf, TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator)) } } } @@ -241,13 +245,13 @@ func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { // ResultTypeRef produces the JSON reference to the media type definition with // the given view. -func ResultTypeRef(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) string { - return ResultTypeRefWithPrefix(api, mt, view, "") +func ResultTypeRef(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) string { + return ResultTypeRefWithPrefix(api, mt, view, "", gen) } // ResultTypeRefWithPrefix produces the JSON reference to the media type definition with // the given view and adds the provided prefix to the type name -func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string) string { +func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string, gen *expr.ExampleGenerator) string { projected, err := expr.Project(mt, view) if err != nil { panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug @@ -280,19 +284,19 @@ func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, p } } if _, ok := Definitions[name]; !ok { - GenerateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView) + GenerateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView, gen) } return fmt.Sprintf("#/$defs/%s", name) } // TypeRef produces the JSON reference to the type definition. -func TypeRef(api *expr.APIExpr, ut *expr.UserTypeExpr) string { - return TypeRefWithPrefix(api, ut, "") +func TypeRef(api *expr.APIExpr, ut *expr.UserTypeExpr, gen *expr.ExampleGenerator) string { + return TypeRefWithPrefix(api, ut, "", gen) } // TypeRefWithPrefix produces the JSON reference to the type definition and adds the provided prefix // to the type name -func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string) string { +func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string, gen *expr.ExampleGenerator) string { typeName := ut.TypeName if prefix != "" { typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) @@ -301,32 +305,32 @@ func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string) typeName = codegen.Goify(n[0], true) } if _, ok := Definitions[typeName]; !ok { - GenerateTypeDefinitionWithName(api, ut, typeName) + GenerateTypeDefinitionWithName(api, ut, typeName, gen) } return fmt.Sprintf("#/$defs/%s", typeName) } // GenerateResultTypeDefinition produces the JSON schema corresponding to the // given media type and given view. -func GenerateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) { +func GenerateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) { if _, ok := Definitions[mt.TypeName]; ok { return } s := NewSchema() s.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) Definitions[mt.TypeName] = s - buildResultTypeSchema(api, mt, view, s) + buildResultTypeSchema(api, mt, view, s, gen) } // GenerateTypeDefinition produces the JSON schema corresponding to the given // type. -func GenerateTypeDefinition(api *expr.APIExpr, ut *expr.UserTypeExpr) { - GenerateTypeDefinitionWithName(api, ut, ut.TypeName) +func GenerateTypeDefinition(api *expr.APIExpr, ut *expr.UserTypeExpr, gen *expr.ExampleGenerator) { + GenerateTypeDefinitionWithName(api, ut, ut.TypeName, gen) } // GenerateTypeDefinitionWithName produces the JSON schema corresponding to the given // type with provided type name. -func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string) { +func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string, gen *expr.ExampleGenerator) { if _, ok := Definitions[typeName]; ok { return } @@ -334,18 +338,18 @@ func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, ty s.Title = typeName Definitions[typeName] = s - buildAttributeSchema(api, s, ut.AttributeExpr, api.ExampleGenerator.Rebased(ut.ID())) + buildAttributeSchema(api, s, ut.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(ut))) } // TypeSchema produces the JSON schema corresponding to the given data type. -func TypeSchema(api *expr.APIExpr, t expr.DataType) *Schema { - return TypeSchemaWithPrefix(api, t, "") +func TypeSchema(api *expr.APIExpr, t expr.DataType, gen *expr.ExampleGenerator) *Schema { + return TypeSchemaWithPrefix(api, t, "", gen) } // TypeSchemaWithPrefix produces the JSON schema corresponding to the given data type // and adds the provided prefix to the type name -func TypeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string) *Schema { - return typeSchemaWithGen(api, t, prefix, api.ExampleGenerator) +func TypeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *Schema { + return typeSchemaWithGen(api, t, prefix, gen) } // typeSchemaWithGen builds the JSON schema for t drawing example values from @@ -384,7 +388,7 @@ func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *e case *expr.Array: s.Type = Array s.Items = NewSchema() - buildAttributeSchema(api, s.Items, actual.ElemType, gen.Derived("0")) + buildAttributeSchema(api, s.Items, actual.ElemType, gen.ArrayElement(0)) case *expr.Object: s.Type = Object for _, nat := range *actual { @@ -392,7 +396,7 @@ func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *e continue } prop := NewSchema() - buildAttributeSchema(api, prop, nat.Attribute, gen.Derived(nat.Name)) + buildAttributeSchema(api, prop, nat.Attribute, gen.Member(nat.Name)) s.Properties[nat.Name] = prop } case *expr.Map: @@ -400,7 +404,7 @@ func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *e if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { // Use free-form objects when elements are of type "Any" additionalProperties := NewSchema() - s.AdditionalProperties = buildAttributeSchema(api, additionalProperties, actual.ElemType, gen.Derived("val0")) + s.AdditionalProperties = buildAttributeSchema(api, additionalProperties, actual.ElemType, gen.MapValue(0)) } else { s.AdditionalProperties = true } @@ -412,7 +416,7 @@ func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *e s.Type = Object for _, val := range actual.Values { - valueSchema := typeSchemaWithGen(api, val.Attribute.Type, prefix, gen.Derived(val.Name)) + valueSchema := typeSchemaWithGen(api, val.Attribute.Type, prefix, gen.UnionMember(val.Name)) initAttributeValidation(valueSchema, val.Attribute) s.AnyOf = append(s.AnyOf, &Schema{ Type: Object, @@ -428,27 +432,27 @@ func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *e } case *expr.UserTypeExpr: if expr.IsAlias(actual) { - s = typeSchemaWithGen(api, actual.Attribute().Type, prefix, gen.Rebased(actual.ID())) + s = typeSchemaWithGen(api, actual.Attribute().Type, prefix, gen.At(expr.UserTypeExampleIdentity(actual))) initAttributeValidation(s, actual.Attribute()) break } - s.Ref = TypeRefWithPrefix(api, actual, prefix) + s.Ref = TypeRefWithPrefix(api, actual, prefix, gen) case *expr.ResultTypeExpr: // Use "default" view by default - s.Ref = ResultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix) + s.Ref = ResultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix, gen) } return s } // AttributeTypeSchema produces the JSON schema corresponding to the given attribute. -func AttributeTypeSchema(api *expr.APIExpr, at *expr.AttributeExpr) *Schema { - return AttributeTypeSchemaWithPrefix(api, at, "") +func AttributeTypeSchema(api *expr.APIExpr, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *Schema { + return AttributeTypeSchemaWithPrefix(api, at, "", gen) } // AttributeTypeSchemaWithPrefix produces the JSON schema corresponding to the given attribute // and adds the provided prefix to the type name -func AttributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string) *Schema { - s := TypeSchemaWithPrefix(api, at.Type, prefix) +func AttributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string, gen *expr.ExampleGenerator) *Schema { + s := TypeSchemaWithPrefix(api, at.Type, prefix, gen) initAttributeValidation(s, at) return s } @@ -681,13 +685,13 @@ func propertiesFromDefs(definitions map[string]*Schema, path string) map[string] // buildResultTypeSchema initializes s as the JSON schema representing mt for the // given view. -func buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, s *Schema) { +func buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, s *Schema, gen *expr.ExampleGenerator) { s.Media = &Media{Type: mt.Identifier} projected, err := expr.Project(mt, view) if err != nil { panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug } - buildAttributeSchema(api, s, projected.AttributeExpr, api.ExampleGenerator.Rebased(projected.ID())) + buildAttributeSchema(api, s, projected.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(projected))) } // MustGenerate returns true if the meta indicates that a OpenAPI specification should be diff --git a/http/codegen/openapi/json_schema_union_test.go b/http/codegen/openapi/json_schema_union_test.go index cecfd4b46a..4d61207ee0 100644 --- a/http/codegen/openapi/json_schema_union_test.go +++ b/http/codegen/openapi/json_schema_union_test.go @@ -1,3 +1,5 @@ +// This file verifies shared JSON Schema rendering for unions, including that a +// typed owner keeps each discriminator paired with its generated member value. package openapi import ( @@ -10,7 +12,11 @@ import ( ) func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := AttributeTypeSchema(&expr.APIExpr{ExampleGenerator: expr.NewRandom("test")}, unionAttribute()) + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := AttributeTypeSchema(&expr.APIExpr{}, unionAttribute(), generator) require.Len(t, schema.AnyOf, 2) assertUnionSchemaBranch(t, schema.AnyOf[0], "text", Type(String)) diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index 91955703ec..b424197564 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI v2 operations and schemas from evaluated HTTP +// endpoints, using exact request and response owners for generated examples. package openapiv2 import ( @@ -15,8 +17,9 @@ import ( openapiinternal "goa.design/goa/v3/http/codegen/openapi/internal" ) -// NewV2 returns the OpenAPI v2 specification for the given API. -func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { +// NewV2 returns the OpenAPI v2 specification for the given API using examples +// from generator. +func NewV2(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerator) (*V2, error) { if root == nil { return nil, nil } @@ -74,14 +77,14 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { if !openapi.MustGenerate(fs.Meta) || !openapi.MustGenerate(fs.Service.Meta) { continue } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, generator) } for _, a := range res.HTTPEndpoints { if !openapi.MustGenerate(a.Meta) || !openapi.MustGenerate(a.MethodExpr.Meta) { continue } for _, route := range a.Routes { - buildPathFromExpr(s, root, h, route, basePath) + buildPathFromExpr(s, root, h, route, basePath, generator) } } } @@ -342,7 +345,7 @@ func itemsFromExpr(at *expr.AttributeExpr) *Items { return items } -func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string) *Response { +func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string, generator *expr.ExampleGenerator) *Response { var schema *openapi.Schema if mt, ok := r.Body.Type.(*expr.ResultTypeExpr); ok { view := expr.DefaultView @@ -350,9 +353,9 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, view = v } schema = openapi.NewSchema() - schema.Ref = openapi.ResultTypeRefWithPrefix(root.API, mt, view, typeNamePrefix) + schema.Ref = openapi.ResultTypeRefWithPrefix(root.API, mt, view, typeNamePrefix, generator) } else if r.Body.Type != expr.Empty { - schema = openapi.AttributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix) + schema = openapi.AttributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix, generator) } if schema != nil { schema.Extensions = openapi.ExtensionsFromExpr(r.Meta) @@ -435,7 +438,7 @@ func initAttributeValidations(at *expr.AttributeExpr, def any) { initValidations(at, def) } -func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr) { +func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr, generator *expr.ExampleGenerator) { for _, path := range fs.RequestPaths { wcs := expr.ExtractHTTPWildcards(path) var param []*Parameter @@ -456,7 +459,8 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer }, } if len(wcs) > 0 { - schema := openapi.TypeSchema(root.API, expr.ErrorResult) + errgen := generator.At(expr.UserTypeExampleIdentity(expr.ErrorResult)) + schema := openapi.TypeSchema(root.API, expr.ErrorResult, errgen) responses["404"] = &Response{Description: "File not found", Schema: schema} } @@ -503,7 +507,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } } -func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string) { +func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string, generator *expr.ExampleGenerator) { endpoint := route.Endpoint tagNames := openapi.TagNamesFromExpr(endpoint.Meta) @@ -521,6 +525,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr responses := make(map[string]*Response, len(endpoint.Responses)) for _, r := range endpoint.Responses { + responseGenerator := generator.At(expr.ResponseBodyExampleIdentity(endpoint, r)) if endpoint.UsesWebSocket() { // A WebSocket endpoint allows at most one successful response // definition. So it is okay to change the first successful @@ -530,7 +535,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr r.StatusCode = expr.StatusSwitchingProtocols } } - resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name()) + resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name(), responseGenerator) responses[strconv.Itoa(r.StatusCode)] = resp if r.ContentType != "" { foundCT := slices.Contains(produces, r.ContentType) @@ -540,7 +545,8 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } for _, er := range endpoint.HTTPErrors { - resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name()) + responseGenerator := generator.At(expr.ErrorResponseBodyExampleIdentity(endpoint, er)) + resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name(), responseGenerator) responses[strconv.Itoa(er.Response.StatusCode)] = resp } @@ -559,7 +565,12 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr In: in, Description: endpoint.Body.Description, Required: true, - Schema: openapi.AttributeTypeSchemaWithPrefix(root.API, endpoint.Body, codegen.Goify(endpoint.Service.Name(), true)), + Schema: openapi.AttributeTypeSchemaWithPrefix( + root.API, + endpoint.Body, + codegen.Goify(endpoint.Service.Name(), true), + generator.At(expr.RequestBodyExampleIdentity(endpoint)), + ), } params = append(params, pp) } diff --git a/http/codegen/openapi/v2/builder_test.go b/http/codegen/openapi/v2/builder_test.go index ad5114df41..b5fad4c555 100644 --- a/http/codegen/openapi/v2/builder_test.go +++ b/http/codegen/openapi/v2/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v2 construction from evaluated HTTP endpoint +// designs, including request and response example ownership. package openapiv2 import ( @@ -36,7 +38,7 @@ func TestBuildPathFromFileServer(t *testing.T) { } root := &expr.RootExpr{ API: &expr.APIExpr{ - ExampleGenerator: expr.NewRandom("test"), + RandomizerFactory: expr.NewFakerRandomizerFactory("test"), }, } fs := &expr.HTTPFileServerExpr{ @@ -47,7 +49,7 @@ func TestBuildPathFromFileServer(t *testing.T) { }, RequestPaths: []string{tc.path}, } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, expr.NewExampleGenerator(root.API.RandomizerFactory)) for actual := range s.Paths { if actual != tc.expected { t.Errorf("got %#v, expected %#v", actual, tc.expected) @@ -59,7 +61,7 @@ func TestBuildPathFromFileServer(t *testing.T) { func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) cases := map[string]struct { @@ -97,7 +99,7 @@ func TestNoSecurityOverridesAPISecurity(t *testing.T) { func TestNoSecurityOverridesServiceSecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesServiceSecurityDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) cases := map[string]struct { @@ -135,7 +137,7 @@ func TestNoSecurityOverridesServiceSecurity(t *testing.T) { func TestStreamingResponseStatusCodes(t *testing.T) { root := codegen.RunDSL(t, streamingResponseStatusDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) sseResponses := spec.Paths["/sse"].(*Path).Get.Responses @@ -322,13 +324,17 @@ func TestBuildPathFromExpr(t *testing.T) { Meta: expr.MetaExpr{}, }, } + route.Endpoint.MethodExpr.Name = "method" + route.Endpoint.Service.ServiceExpr.Name = "service" + route.Endpoint.MethodExpr.Service = route.Endpoint.Service.ServiceExpr if tc.deprecated { route.Endpoint.Meta["openapi:deprecated"] = []string{"true"} } basePath := "/" - buildPathFromExpr(s, root, h, route, basePath) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + buildPathFromExpr(s, root, h, route, basePath, generator) for _, path := range s.Paths { actual := path.(*Path).Post if len(actual.Consumes) != len(tc.expected.Consumes) { diff --git a/http/codegen/openapi/v2/files.go b/http/codegen/openapi/v2/files.go index a542fd9e49..4687f48a7c 100644 --- a/http/codegen/openapi/v2/files.go +++ b/http/codegen/openapi/v2/files.go @@ -1,3 +1,6 @@ +// This file renders a prepared HTTP design as Swagger 2.0 JSON and YAML files. +// Callers provide the run-owned example coordinator, and the builder derives +// every example stream from the HTTP expression represented in the document. package openapiv2 import ( @@ -9,8 +12,8 @@ import ( // Files returns the Swagger 2.0 specification files in JSON and YAML formats. // path is the output path of the files relative to the gen directory, without // extension. -func Files(root *expr.RootExpr, path string) ([]*codegen.File, error) { - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) +func Files(root *expr.RootExpr, path string, generator *expr.ExampleGenerator) ([]*codegen.File, error) { + spec, err := NewV2(root, root.API.Servers[0].Hosts[0], generator) if err != nil { return nil, err } diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go index 0846eaac00..29122083d1 100644 --- a/http/codegen/openapi/v2/files_test.go +++ b/http/codegen/openapi/v2/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete Swagger 2.0 documents from prepared HTTP designs +// and compares the JSON and YAML output produced with run-owned example state. package openapiv2_test import ( @@ -58,7 +60,7 @@ func TestSections(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) if err != nil { t.Fatalf("OpenAPI failed with %s", err) } @@ -115,7 +117,7 @@ func TestValidations(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err, "OpenAPI failed") require.NotEmpty(t, oFiles, "No swagger files") for i, o := range oFiles { @@ -159,7 +161,7 @@ func TestExtensions(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err, "OpenAPI failed") require.NotEmpty(t, oFiles, "No swagger files") for i, o := range oFiles { @@ -233,7 +235,7 @@ func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { }) }) - spec, err := openapiv2.NewV2(root, root.API.Servers[0].Hosts[0]) + spec, err := openapiv2.NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) path, ok := spec.Paths["/repro"] diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden index 8a2ad8deab..bdacdb36f3 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden @@ -47,7 +47,6 @@ "example": { "any": "", "any_array": [ - "", "", "", "" @@ -62,7 +61,6 @@ }, "any_array": { "example": [ - "", "", "", "" diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden index 442df6f3ea..ba2855ce06 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden @@ -73,7 +73,6 @@ definitions: - "" - "" - "" - - "" any_map: type: object example: @@ -85,6 +84,5 @@ definitions: - "" - "" - "" - - "" any_map: "": "" diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden index ca719a043f..fa511401da 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden @@ -114,6 +114,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -139,6 +142,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden index 71a2b516fd..5fadaf92f4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden @@ -107,6 +107,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/definitions/GoaFoobar' @@ -132,6 +133,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden index 39d9ee6a08..bfb691c3f4 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden @@ -23,15 +23,23 @@ "Foobar": { "example": { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, "properties": { "bar": { "example": [ + { + "string": "" + }, { "string": "" } @@ -44,9 +52,11 @@ "type": "array" }, "foo": { - "example": [], + "example": [ + "Molestiae labore nihil sunt." + ], "items": { - "example": "Eaque consequatur asperiores est.", + "example": "Molestiae labore nihil sunt.", "type": "string" }, "maxItems": 42, diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden index 47920c6325..01e2f191a9 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden @@ -57,17 +57,21 @@ definitions: $ref: '#/definitions/Bar' example: - string: "" + - string: "" minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Eaque consequatur asperiores est. - example: [] + example: Molestiae labore nihil sunt. + example: + - Molestiae labore nihil sunt. minItems: 0 maxItems: 42 example: bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. diff --git a/http/codegen/openapi/v3/builder.go b/http/codegen/openapi/v3/builder.go index d3469ff347..af0dfefc1c 100644 --- a/http/codegen/openapi/v3/builder.go +++ b/http/codegen/openapi/v3/builder.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI v3 operations from evaluated HTTP endpoints and +// preserves the exact semantic owner of every displayed example. package openapiv3 import ( @@ -29,34 +31,26 @@ const ( ) // New returns the OpenAPI specification conforming to the given version -// (openapi.Version30 or openapi.Version32) for the given API. It returns nil -// if the design does not define HTTP endpoints. -func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { +// (openapi.Version30 or openapi.Version32) for the given API using examples +// from generator. It returns nil if the design does not define HTTP endpoints. +func New(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenerator) *OpenAPI { if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { // No HTTP transport return nil } - m, ok := root.API.Meta.Last("openapi:example") - if !ok { - m, ok = root.API.Meta.Last("swagger:example") - } - if ok && m == "false" { - root.API.ExampleGenerator.Randomizer = nil - } - specVersion := OpenAPIVersion if ver == openapi.Version32 { specVersion = OpenAPIVersion32 } var ( - bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver) + bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver, generator) info = buildInfo(root.API, ver) comps = buildComponents(root, types) servers = buildServers(root.API.Servers, ver) - paths = buildPaths(root.API.HTTP, bodies, root.API, ver) + paths = buildPaths(root.API.HTTP, bodies, root.API, ver, generator) security = buildSecurityRequirements(root.API.Requirements) tags = buildTags(root.API, ver) ) @@ -131,7 +125,7 @@ func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Com // buildPaths builds the OpenAPI Paths map with key as the HTTP path string and // the value as the corresponding PathItem object. -func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version) map[string]*PathItem { +func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version, generator *expr.ExampleGenerator) map[string]*PathItem { var paths = make(map[string]*PathItem) for _, svc := range h.Services { if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { @@ -150,7 +144,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildOperation(key, r, sbod[e.Name()], api.ExampleGenerator, api.Meta, ver) + operation := buildOperation(key, r, sbod[e.Name()], generator, api.Meta, ver) path, ok := paths[key] if !ok { path = new(PathItem) @@ -254,7 +248,7 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand ct = "multipart/form-data" } mt := &MediaType{Schema: bodies.RequestBody} - initExamples(mt, e.Body, rand.Rebased(bodyExampleID(m.Service.Name, e.Name(), "request"))) + initExamples(mt, e.Body, rand.At(expr.RequestBodyExampleIdentity(e))) requestBody = &RequestBodyRef{Value: &RequestBody{ Description: requestBodyDescription(e), Required: e.Body.Type != expr.Empty, @@ -272,7 +266,9 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // The generated handler reads the Last-Event-ID header directly so // the header does not appear in the endpoint headers expression. att := expr.AsObject(m.Payload.Type).Attribute(e.SSE.RequestIDField) - ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand.Field(m.Payload, e.SSE.RequestIDField))) + owner := expr.MethodPayloadExampleIdentity(m) + identity := exampleFieldIdentity(m.Payload, e.SSE.RequestIDField, owner) + ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand, identity)) } if e.MapQueryParams != nil { name := *e.MapQueryParams @@ -299,7 +295,8 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // responses responses := make(map[string]*ResponseRef, len(e.Responses)) - for _, r := range e.Responses { + responseBodyIndexes := make(map[int]int) + for i, r := range e.Responses { var resultCT string switch { case e.UsesWebSocket(): @@ -318,7 +315,15 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand r = r.Dup() r.ContentType = "text/event-stream" } - resp := responseFromExpr(r, bodies.ResponseBodies, rand) + var body *openapi.Schema + if r.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[r.StatusCode] + body = bodies.ResponseBodies[r.StatusCode][bodyIndex] + responseBodyIndexes[r.StatusCode]++ + } + owner := expr.MethodResultExampleIdentity(m) + bodyOwner := expr.ResponseBodyExampleIdentity(e, e.Responses[i]) + resp := responseFromExpr(r, body, rand, m.Result, owner, bodyOwner) if ver == openapi.Version32 && e.UsesSSE() { setSSEContent(resp, bodies, resultCT, m.HasMixedResults()) } @@ -328,7 +333,15 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand if er.Description != "" && er.Response.Description == "" { er.Response.Description = er.Description } - resp := responseFromExpr(er.Response, bodies.ResponseBodies, rand) + var body *openapi.Schema + if er.Response.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[er.Response.StatusCode] + body = bodies.ResponseBodies[er.Response.StatusCode][bodyIndex] + responseBodyIndexes[er.Response.StatusCode]++ + } + owner := expr.MethodErrorExampleIdentity(m, er.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, er) + resp := responseFromExpr(er.Response, body, rand, er.AttributeExpr, owner, bodyOwner) desc := er.Name if resp.Description != nil { desc += ": " + *resp.Description diff --git a/http/codegen/openapi/v3/builder_test.go b/http/codegen/openapi/v3/builder_test.go index 9477a1fd23..4dffd2dc07 100644 --- a/http/codegen/openapi/v3/builder_test.go +++ b/http/codegen/openapi/v3/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 operation construction, body schemas, and +// examples produced from evaluated HTTP endpoint designs. package openapiv3 import ( @@ -94,7 +96,7 @@ func TestBuildInfo(t *testing.T) { func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) - spec := New(root, openapi.Version30) + spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) cases := map[string]struct { marshal func(any) ([]byte, error) @@ -131,7 +133,7 @@ func TestNoSecurityOverridesAPISecurity(t *testing.T) { func TestNoSecurityOverridesServiceSecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesServiceSecurityDSL) - spec := New(root, openapi.Version30) + spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) cases := map[string]struct { marshal func(any) ([]byte, error) @@ -168,7 +170,7 @@ func TestNoSecurityOverridesServiceSecurity(t *testing.T) { func TestStreamingResponseStatusCodes(t *testing.T) { root := codegen.RunDSL(t, streamingResponseStatusDSL) - spec := New(root, openapi.Version30) + spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) sseResponses := spec.Paths["/sse"].Get.Responses require.Contains(t, sseResponses, "200") @@ -334,7 +336,7 @@ func TestBuildOperation(t *testing.T) { var types map[string]*openapi.Schema { var bds map[string]map[string]*EndpointBodies - bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) if svc, ok := bds[svcName]; ok { bodies, ok = svc[c.Name] if !ok { @@ -366,7 +368,8 @@ func TestBuildOperation(t *testing.T) { return } - op := buildOperation(c.Name, route, bodies, expr.NewRandom(c.Name), root.API.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, route, bodies, generator, root.API.Meta, openapi.Version30) if op.Description != c.ExpectedDescription { t.Errorf("got description %q for method %q, expected %q", op.Description, c.Name, c.ExpectedDescription) @@ -455,7 +458,8 @@ func TestBuildOperationID(t *testing.T) { if s.Name() == svcName { for _, e := range s.HTTPEndpoints { for i, r := range e.Routes { - op := buildOperation(c.Name, r, &EndpointBodies{}, expr.NewRandom(c.Name), api.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, r, &EndpointBodies{}, generator, api.Meta, openapi.Version30) if len(c.ExpectedOperationIDs) == 0 { t.Error("no expected operation IDs") diff --git a/http/codegen/openapi/v3/files.go b/http/codegen/openapi/v3/files.go index 29cd8a0a83..ae05ddb409 100644 --- a/http/codegen/openapi/v3/files.go +++ b/http/codegen/openapi/v3/files.go @@ -1,3 +1,6 @@ +// This file renders a prepared HTTP design as OpenAPI 3 JSON and YAML files. +// Callers provide the run-owned example coordinator, and the builder derives +// every example stream from the HTTP expression represented in the document. package openapiv3 import ( @@ -10,6 +13,6 @@ import ( // version (openapi.Version30 or openapi.Version32) in JSON and YAML formats. // path is the output path of the files relative to the gen directory, without // extension. -func Files(root *expr.RootExpr, ver openapi.Version, path string) []*codegen.File { - return openapi.Files(New(root, ver), root.API.Meta, "openapi_v3", path) +func Files(root *expr.RootExpr, ver openapi.Version, path string, generator *expr.ExampleGenerator) []*codegen.File { + return openapi.Files(New(root, ver, generator), root.API.Meta, "openapi_v3", path) } diff --git a/http/codegen/openapi/v3/files_test.go b/http/codegen/openapi/v3/files_test.go index 992a3a8618..27f415135e 100644 --- a/http/codegen/openapi/v3/files_test.go +++ b/http/codegen/openapi/v3/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete OpenAPI 3.0 and 3.2 documents from prepared HTTP +// designs and compares output produced with run-owned example state. package openapiv3_test import ( @@ -81,7 +83,7 @@ func TestFiles(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) + oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30, expr.NewExampleGenerator(root.API.RandomizerFactory)) for i, o := range oFiles { tname := fmt.Sprintf("file%d", i) s := o.SectionTemplates @@ -140,7 +142,7 @@ func TestFilesV32(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32) + oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32, expr.NewExampleGenerator(root.API.RandomizerFactory)) wantPaths := []string{ filepath.Join("gen", "http", "openapi3.2.json"), filepath.Join("gen", "http", "openapi3.2.yaml"), diff --git a/http/codegen/openapi/v3/parameters.go b/http/codegen/openapi/v3/parameters.go index 1c4c64bc31..92321330de 100644 --- a/http/codegen/openapi/v3/parameters.go +++ b/http/codegen/openapi/v3/parameters.go @@ -1,3 +1,5 @@ +// This file converts HTTP parameters, headers, and cookies into OpenAPI v3 +// values whose schema and displayed examples use the same fresh identity. package openapiv3 import ( @@ -16,6 +18,7 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa res []*Parameter params = endpoint.Params wildcards = expr.ExtractHTTPWildcards(path) + owner = expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) ) codegen.WalkMappedAttr(params, func(n, pn string, required bool, at *expr.AttributeExpr) error { // nolint: errcheck in := "query" @@ -26,7 +29,8 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa if in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - res = append(res, paramFor(at, pn, in, required, rand.Field(endpoint.MethodExpr.Payload, n))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, n, owner) + res = append(res, paramFor(at, pn, in, required, rand, identity)) return nil }) return res @@ -36,13 +40,15 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa // given endpoint HTTP headers and cookies. func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator) []*Parameter { var params []*Parameter + owner := expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck if openapiinternal.IsSecurityParameter(endpoint, "header", elem) { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "header", required, rand, identity)) return nil }) expr.WalkMappedAttr(endpoint.Cookies, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -50,24 +56,34 @@ func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.Exa return nil } required := endpoint.Cookies.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "cookie", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "cookie", required, rand, identity)) return nil }) return params } +// exampleFieldGenerator anchors a detached transport field to its named user +// type or to the explicit semantic owner of an anonymous parent. +func exampleFieldIdentity(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) expr.ExampleIdentity { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) + } + return owner.Member(name) +} + // paramFor converts the given attribute into a OpenAPI spec parameter. -func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator) *Parameter { +func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator, identity expr.ExampleIdentity) *Parameter { param := &Parameter{ Name: name, In: in, Description: att.Description, AllowEmptyValue: in == "query", Required: required, - Schema: newSchemafier(rand).schemafy(att), + Schema: newSchemafier(rand.At(identity)).schemafy(att), Extensions: openapi.ExtensionsFromExpr(att.Meta), } - initExamples(param, att, rand) + initExamples(param, att, rand.At(identity)) return param } diff --git a/http/codegen/openapi/v3/parameters_test.go b/http/codegen/openapi/v3/parameters_test.go index 4b83cc9b51..7981806ac5 100644 --- a/http/codegen/openapi/v3/parameters_test.go +++ b/http/codegen/openapi/v3/parameters_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI parameters and headers render one stable example +// in both their schema and their displayed example fields. package openapiv3 import ( @@ -22,15 +24,41 @@ func TestParamForAllowEmptyValue(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + method := &expr.MethodExpr{Name: "parameter", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(test.name)) + require.NotEmpty(t, generator.At(expr.MethodResultExampleIdentity(method)).String()) + identity := expr.MethodPayloadExampleIdentity(method).Member("value") param := paramFor( &expr.AttributeExpr{Type: expr.String}, "value", test.location, false, - expr.NewRandom(test.name), + generator, + identity, ) require.Equal(t, test.want, param.AllowEmptyValue) + require.Equal(t, param.Schema.Example, param.Example) }) } } + +func TestHeaderSchemaAndDisplayedExampleShareIdentity(t *testing.T) { + field := &expr.AttributeExpr{Type: expr.String} + parent := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "request-id", Attribute: field}, + }} + headers := expr.NewMappedAttributeExpr(parent) + method := &expr.MethodExpr{Name: "headers", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("headers")) + require.NotEmpty(t, generator.At(expr.MethodPayloadExampleIdentity(method)).String()) + + actual := headersFromAttr( + headers, + parent, + expr.MethodResultExampleIdentity(method), + generator, + )["request-id"].Value + + require.Equal(t, actual.Schema.Example, actual.Example) +} diff --git a/http/codegen/openapi/v3/response.go b/http/codegen/openapi/v3/response.go index cb9de68ebb..2196c5f6d5 100644 --- a/http/codegen/openapi/v3/response.go +++ b/http/codegen/openapi/v3/response.go @@ -1,16 +1,17 @@ +// This file converts HTTP response headers and cookies into OpenAPI v3 values +// without sharing consumed example streams between schema and display fields. package openapiv3 import ( "fmt" "net/http" - "strconv" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" ) -func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator) map[string]*HeaderRef { +func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, owner expr.ExampleIdentity, rand *expr.ExampleGenerator) map[string]*HeaderRef { o := expr.AsObject(attr.Type) if len(*o) == 0 { return nil @@ -19,25 +20,24 @@ func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator expr.WalkMappedAttr(attr, func(name, elem string, hattr *expr.AttributeExpr) error { // nolint: errcheck // Anchor the header example stream to the header identity so the // example survives generator reorderings. - hrand := rand.Field(attr.AttributeExpr, name) + identity := exampleFieldIdentity(parent, name, owner) header := &Header{ Description: hattr.Description, Required: hattr.IsRequiredNoDefault(name), - Schema: newSchemafier(hrand).schemafy(hattr), - Example: openapi.Example(hattr, hrand), + Schema: newSchemafier(rand.At(identity)).schemafy(hattr), Extensions: openapi.ExtensionsFromExpr(hattr.Meta), } - initExamples(header, hattr, hrand) + initExamples(header, hattr, rand.At(identity)) headers[elem] = &HeaderRef{Value: header} return nil }) return headers } -func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema, rand *expr.ExampleGenerator) *Response { +func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr.ExampleGenerator, parent *expr.AttributeExpr, fieldOwner, bodyOwner expr.ExampleIdentity) *Response { ct := responseContentType(r) - headers := headersFromAttr(r.Headers, rand) - cookies := headersFromAttr(r.Cookies, rand) + headers := headersFromAttr(r.Headers, parent, fieldOwner, rand) + cookies := headersFromAttr(r.Cookies, parent, fieldOwner, rand) if len(cookies) > 0 { if headers == nil { headers = make(map[string]*HeaderRef) @@ -65,12 +65,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema if r.Body.Type != expr.Empty { content = make(map[string]*MediaType) content[ct] = &MediaType{ - Schema: bodies[r.StatusCode][0], + Schema: body, Extensions: openapi.ExtensionsFromExpr(r.Body.Meta), } - ep := r.Parent.(*expr.HTTPEndpointExpr) - id := bodyExampleID(ep.Service.Name(), ep.Name(), "response."+strconv.Itoa(r.StatusCode)+".0") - initExamples(content[ct], staticViewBody(r), rand.Rebased(id)) + initExamples(content[ct], staticViewBody(r), rand.At(bodyOwner)) } else if r.StatusCode != expr.StatusNoContent && isSkipResponseBodyEncodeDecode(r.Parent) { // When SkipResponseBodyEncodeDecode is declared, the response type diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden index 233c811e6f..606b253abe 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden @@ -5,20 +5,18 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "properties": { "completed": { "example": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], "items": { "description": "Setup stage.", @@ -28,7 +26,7 @@ "where", "what" ], - "example": "where", + "example": "what", "type": "string" }, "type": "array" @@ -41,7 +39,7 @@ "where", "what" ], - "example": "where", + "example": "what", "type": "string" } }, @@ -63,12 +61,11 @@ "application/json": { "example": { "completed": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "schema": { "$ref": "#/components/schemas/Setup" @@ -84,10 +81,12 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "what", + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden index 54fd84d4e6..4e6b141b14 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden @@ -21,11 +21,10 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - - where - - where - current: where + - what + - what + - what + current: what responses: "200": description: OK response. @@ -35,9 +34,11 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - what + - what + - what + - what + current: what components: schemas: Setup: @@ -48,21 +49,20 @@ components: items: type: string description: Setup stage. - example: where + example: what enum: - who - when - where - what example: - - where - - where - - where - - where + - what + - what + - what current: type: string description: Setup stage. - example: where + example: what enum: - who - when @@ -71,10 +71,9 @@ components: description: Request body for testEndpoint. example: completed: - - where - - where - - where - - where - current: where + - what + - what + - what + current: what tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/array_file0.golden b/http/codegen/openapi/v3/testdata/golden/array_file0.golden index 84fa558be7..bb1182167c 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file0.golden @@ -18,15 +18,23 @@ "Foobar": { "example": { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, "properties": { "bar": { "example": [ + { + "string": "" + }, { "string": "" } @@ -39,9 +47,11 @@ "type": "array" }, "foo": { - "example": [], + "example": [ + "Molestiae labore nihil sunt." + ], "items": { - "example": "Eaque consequatur asperiores est.", + "example": "Molestiae labore nihil sunt.", "type": "string" }, "maxItems": 42, @@ -68,54 +78,110 @@ "example": [ { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] + }, + { + "bar": [ + { + "string": "" + }, + { + "string": "" + } + ], + "foo": [ + "Molestiae labore nihil sunt." + ] } ], "schema": { "example": [ { "bar": [ + { + "string": "" + }, + { + "string": "" + } + ], + "foo": [ + "Molestiae labore nihil sunt." + ] + }, + { + "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] }, { "bar": [ + { + "string": "" + }, { "string": "" } ], - "foo": [] + "foo": [ + "Molestiae labore nihil sunt." + ] } ], "items": { diff --git a/http/codegen/openapi/v3/testdata/golden/array_file1.golden b/http/codegen/openapi/v3/testdata/golden/array_file1.golden index 4dd6cbdc17..e99822397a 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file1.golden @@ -23,23 +23,45 @@ paths: example: - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. + - bar: + - string: "" + - string: "" + foo: + - Molestiae labore nihil sunt. - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. example: - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. + - bar: + - string: "" + - string: "" + foo: + - Molestiae labore nihil sunt. - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. - bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. responses: "200": description: OK response. @@ -72,19 +94,23 @@ components: $ref: '#/components/schemas/Bar' example: - string: "" + - string: "" minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Eaque consequatur asperiores est. - example: [] + example: Molestiae labore nihil sunt. + example: + - Molestiae labore nihil sunt. minItems: 0 maxItems: 42 example: bar: - string: "" - foo: [] + - string: "" + foo: + - Molestiae labore nihil sunt. tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden index bbec695a5a..bc13e7d0aa 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden @@ -4,17 +4,17 @@ "Error": { "description": "Error response result type", "example": { - "fault": true, + "fault": false, "id": "123abc", "message": "parameter 'p' must be an integer", "name": "bad_request", "temporary": true, - "timeout": false + "timeout": true }, "properties": { "fault": { "description": "Is the error a server-side fault?", - "example": true, + "example": false, "type": "boolean" }, "id": { @@ -39,7 +39,7 @@ }, "timeout": { "description": "Is the error a timeout?", - "example": false, + "example": true, "type": "boolean" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden index d833ea63bd..1622910033 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden @@ -51,7 +51,7 @@ components: fault: type: boolean description: Is the error a server-side fault? - example: true + example: false id: type: string description: ID is a unique identifier for this particular occurrence of the problem. @@ -71,15 +71,15 @@ components: timeout: type: boolean description: Is the error a timeout? - example: false + example: true description: Error response result type example: - fault: true + fault: false id: 123abc message: parameter 'p' must be an integer name: bad_request temporary: true - timeout: false + timeout: true required: - name - id diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden index 13cba070a2..35fb96f744 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden @@ -11,21 +11,21 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 2401899298232532500, "in": "header", "name": "foo", "schema": { - "example": 5490475434297746000, + "example": 2401899298232532500, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2467648858559780400, "in": "header", "name": "bar", "schema": { - "example": 1475422799873681700, + "example": 2467648858559780400, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden index 24f74063e0..501e551175 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden @@ -17,16 +17,16 @@ paths: in: header schema: type: integer - example: 5490475434297746524 + example: 2401899298232532419 format: int64 - example: 8568805688952666114 + example: 2401899298232532419 - name: bar in: header schema: type: integer - example: 1475422799873681639 + example: 2467648858559780444 format: int64 - example: 8380651525843655561 + example: 2467648858559780444 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden index 439aa96af1..a8dcbe7f70 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "A et aut.", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "A et aut.", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden index 8b151c2e6c..9adbcf5d52 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: A et aut. + example: A et aut. components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden index 439aa96af1..a8dcbe7f70 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "A et aut.", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "A et aut.", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden index 8b151c2e6c..9adbcf5d52 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: A et aut. + example: A et aut. components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden index 3a95fa8fb2..a2ac1e19ea 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 2401899298232532500, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 2401899298232532500, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2467648858559780400, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2467648858559780400, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden index 6cf912a664..c4bc342ad4 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 2401899298232532419 format: int64 - example: 8568805688952666114 + example: 2401899298232532419 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2467648858559780444 format: int64 - example: 8380651525843655561 + example: 2467648858559780444 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden index 3a95fa8fb2..a2ac1e19ea 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 2401899298232532500, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 2401899298232532500, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2467648858559780400, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2467648858559780400, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden index 6cf912a664..c4bc342ad4 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 2401899298232532419 format: int64 - example: 8568805688952666114 + example: 2401899298232532419 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2467648858559780444 format: int64 - example: 8380651525843655561 + example: 2467648858559780444 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden index de0170e414..1950e852f7 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 6827506417626806000, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 6827506417626806000, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden index df365d17e6..3f9f3aee84 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 6827506417626806316 format: int64 - example: 5691356309313628853 + example: 6827506417626806316 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden index 8c247e2e1b..d29cf27737 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Ducimus vel." + "id": "Voluptatem deleniti." }, "properties": { "id": { - "example": "Ducimus vel.", + "example": "Voluptatem deleniti.", "type": "string" } }, @@ -67,7 +67,7 @@ "content": { "application/json": { "example": { - "id": "Ducimus vel." + "id": "Voluptatem deleniti." }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden index 53ba9572b0..0ed3d10669 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Ducimus vel. + id: Voluptatem deleniti. responses: "200": description: OK response. @@ -41,10 +41,10 @@ components: properties: id: type: string - example: Ducimus vel. + example: Voluptatem deleniti. description: Request body for SSEAllFieldsMethod. example: - id: Ducimus vel. + id: Voluptatem deleniti. SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden index ff7f14052a..57f431dacc 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden @@ -4,11 +4,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "Quis distinctio vitae ut." }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "Quis distinctio vitae ut.", "type": "string" } }, @@ -19,11 +19,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "Delectus eum inventore illum velit et." }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "Delectus eum inventore illum velit et.", "type": "string" } }, @@ -47,7 +47,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "Quis distinctio vitae ut." }, "schema": { "$ref": "#/components/schemas/Payload" @@ -62,7 +62,7 @@ "content": { "text/event-stream": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "Delectus eum inventore illum velit et." }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden index 3b5145ddb9..eefc2d65e1 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: Quis distinctio vitae ut. responses: "200": description: OK response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: Delectus eum inventore illum velit et. components: schemas: Payload: @@ -37,10 +37,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: Quis distinctio vitae ut. description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: Quis distinctio vitae ut. required: - x Result: @@ -48,9 +48,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: Delectus eum inventore illum velit et. example: - id: Tenetur aut quam ea repudiandae. + id: Delectus eum inventore illum velit et. required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden index c2465ac9ea..82cd2d5729 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "text/event-stream": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "Voluptatem non provident rem consequatur.", "schema": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "Voluptatem non provident rem consequatur.", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden index a078bc4adf..cb3dfd290b 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden @@ -19,8 +19,8 @@ paths: text/event-stream: schema: type: string - example: Adipisci necessitatibus enim voluptas asperiores corporis. - example: Adipisci necessitatibus enim voluptas asperiores corporis. + example: Voluptatem non provident rem consequatur. + example: Voluptatem non provident rem consequatur. components: {} tags: - name: SSEStringService diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden index 60ad86fea6..bb1521e0e4 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden @@ -4,11 +4,11 @@ "Notification": { "description": "Request body for testEndpoint.", "example": { - "id": "Quia velit et." + "id": "Aliquam quia quisquam ab alias atque." }, "properties": { "id": { - "example": "Quia velit et.", + "example": "Aliquam quia quisquam ab alias atque.", "type": "string" } }, @@ -30,7 +30,7 @@ "content": { "application/json": { "example": { - "id": "Quia velit et." + "id": "Aliquam quia quisquam ab alias atque." }, "schema": { "$ref": "#/components/schemas/Notification" @@ -45,7 +45,7 @@ "content": { "application/json": { "example": { - "id": "Aut soluta voluptatem nisi corrupti." + "id": "Dolor et voluptas." }, "schema": { "$ref": "#/components/schemas/Notification" diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden index 615cb69668..bf083e5d17 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Quia velit et. + id: Aliquam quia quisquam ab alias atque. responses: "200": description: OK response. @@ -29,16 +29,16 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Aut soluta voluptatem nisi corrupti. + id: Dolor et voluptas. components: schemas: Notification: description: Request body for testEndpoint. example: - id: Quia velit et. + id: Aliquam quia quisquam ab alias atque. properties: id: - example: Quia velit et. + example: Aliquam quia quisquam ab alias atque. type: string type: object x-test-include: true diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden index 6c582092dd..f3b0fd2b86 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden @@ -5,20 +5,18 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "properties": { "completed": { "example": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], "items": { "$ref": "#/components/schemas/Stage" @@ -39,7 +37,7 @@ "where", "what" ], - "example": "where", + "example": "what", "type": "string" } } @@ -58,12 +56,11 @@ "application/json": { "example": { "completed": [ - "where", - "where", - "where", - "where" + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "schema": { "$ref": "#/components/schemas/Setup" @@ -79,10 +76,12 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "what", + "what", + "what", + "what" ], - "current": "where" + "current": "what" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden index 4db3120510..e5c5c20f88 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden @@ -22,11 +22,10 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - - where - - where - current: where + - what + - what + - what + current: what responses: "200": description: OK response. @@ -36,9 +35,11 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - what + - what + - what + - what + current: what components: schemas: Setup: @@ -49,24 +50,22 @@ components: items: $ref: '#/components/schemas/Stage' example: - - where - - where - - where - - where + - what + - what + - what current: $ref: '#/components/schemas/Stage' description: Request body for testEndpoint. example: completed: - - where - - where - - where - - where - current: where + - what + - what + - what + current: what Stage: type: string description: Setup stage. - example: where + example: what enum: - who - when diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden index d0b6443840..d99d41dc94 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Ducimus vel." + "id": "Voluptatem deleniti." }, "properties": { "id": { - "example": "Ducimus vel.", + "example": "Voluptatem deleniti.", "type": "string" } }, @@ -65,11 +65,11 @@ "operationId": "SSEAllFieldsService#SSEAllFieldsMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "Corrupti possimus quas ut.", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "Corrupti possimus quas ut.", "type": "string" } } @@ -78,7 +78,7 @@ "content": { "application/json": { "example": { - "id": "Ducimus vel." + "id": "Voluptatem deleniti." }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden index de1487fe6c..344d4727e2 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. + example: Corrupti possimus quas ut. + example: Corrupti possimus quas ut. requestBody: description: Request body for SSEAllFieldsMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Ducimus vel. + id: Voluptatem deleniti. responses: "200": description: OK response. @@ -67,10 +67,10 @@ components: properties: id: type: string - example: Ducimus vel. + example: Voluptatem deleniti. description: Request body for SSEAllFieldsMethod. example: - id: Ducimus vel. + id: Voluptatem deleniti. SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden index 79a3a87fd9..fa70570716 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden @@ -3,12 +3,12 @@ "schemas": { "SSEDataFieldMethodResponseBody": { "example": { - "data": "Ipsum voluptatem quaerat quo et non sed.", + "data": "Consequuntur velit in amet et dolorem iste.", "flag": true }, "properties": { "data": { - "example": "Ipsum voluptatem quaerat quo et non sed.", + "example": "Consequuntur velit in amet et dolorem iste.", "type": "string" }, "flag": { @@ -36,7 +36,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quos qui dolore voluptas.", + "example": "Officia iure ut qui voluptas id velit.", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden index 36a166ec7c..01e8cda37e 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden @@ -23,7 +23,7 @@ paths: properties: data: type: string - example: Quos qui dolore voluptas. + example: Officia iure ut qui voluptas id velit. required: - data components: @@ -33,12 +33,12 @@ components: properties: data: type: string - example: Ipsum voluptatem quaerat quo et non sed. + example: Consequuntur velit in amet et dolorem iste. flag: type: boolean example: true example: - data: Ipsum voluptatem quaerat quo et non sed. + data: Consequuntur velit in amet et dolorem iste. flag: true tags: - name: SSEDataFieldService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden index bfffbfb0ed..91251cf44c 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Event": { "example": { - "message": "Doloribus nemo quia dolores." + "message": "Accusantium voluptatibus inventore." }, "properties": { "message": { - "example": "Doloribus nemo quia dolores.", + "example": "Accusantium voluptatibus inventore.", "type": "string" } }, @@ -19,11 +19,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "Quis distinctio vitae ut." }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "Quis distinctio vitae ut.", "type": "string" } }, @@ -34,11 +34,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "Delectus eum inventore illum velit et." }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "Delectus eum inventore illum velit et.", "type": "string" } }, @@ -62,7 +62,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "Quis distinctio vitae ut." }, "schema": { "$ref": "#/components/schemas/Payload" @@ -77,7 +77,7 @@ "content": { "application/json": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "Delectus eum inventore illum velit et." }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden index aa5911777e..384c32fb75 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden @@ -21,7 +21,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: Quis distinctio vitae ut. responses: "200": description: OK response. @@ -30,7 +30,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: Delectus eum inventore illum velit et. text/event-stream: itemSchema: type: object @@ -49,9 +49,9 @@ components: properties: message: type: string - example: Doloribus nemo quia dolores. + example: Accusantium voluptatibus inventore. example: - message: Doloribus nemo quia dolores. + message: Accusantium voluptatibus inventore. required: - message Payload: @@ -59,10 +59,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: Quis distinctio vitae ut. description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: Quis distinctio vitae ut. required: - x Result: @@ -70,9 +70,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: Delectus eum inventore illum velit et. example: - id: Tenetur aut quam ea repudiandae. + id: Delectus eum inventore illum velit et. required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden index 603a5cdc19..ce069edb9c 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden @@ -4,8 +4,8 @@ "SSEObjectMethodResponseBody": { "example": { "flag": true, - "id": "Aspernatur dolorem velit tenetur.", - "value": 3199557017606053400 + "id": "Aut non.", + "value": 8434029765132757000 }, "properties": { "flag": { @@ -13,11 +13,11 @@ "type": "boolean" }, "id": { - "example": "Aspernatur dolorem velit tenetur.", + "example": "Aut non.", "type": "string" }, "value": { - "example": 3199557017606053400, + "example": 8434029765132757000, "format": "int64", "type": "integer" } @@ -45,21 +45,21 @@ "contentMediaType": "application/json", "contentSchema": { "example": { - "flag": false, - "id": "Consequuntur dolores eos voluptatem.", - "value": 7986166745691455000 + "flag": true, + "id": "Ipsa sed perferendis rerum.", + "value": 5784889851462557000 }, "properties": { "flag": { - "example": false, + "example": true, "type": "boolean" }, "id": { - "example": "Consequuntur dolores eos voluptatem.", + "example": "Ipsa sed perferendis rerum.", "type": "string" }, "value": { - "example": 7986166745691455000, + "example": 5784889851462557000, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden index 6701eb1b08..87e2bf1f17 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden @@ -29,18 +29,18 @@ paths: properties: flag: type: boolean - example: false + example: true id: type: string - example: Consequuntur dolores eos voluptatem. + example: Ipsa sed perferendis rerum. value: type: integer - example: 7986166745691455230 + example: 5784889851462556968 format: int64 example: - flag: false - id: Consequuntur dolores eos voluptatem. - value: 7986166745691455230 + flag: true + id: Ipsa sed perferendis rerum. + value: 5784889851462556968 required: - data components: @@ -53,14 +53,14 @@ components: example: true id: type: string - example: Aspernatur dolorem velit tenetur. + example: Aut non. value: type: integer - example: 3199557017606053617 + example: 8434029765132757469 format: int64 example: flag: true - id: Aspernatur dolorem velit tenetur. - value: 3199557017606053617 + id: Aut non. + value: 8434029765132757469 tags: - name: SSEObjectService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden index ea60200bca..88556df1ce 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden @@ -4,11 +4,11 @@ "SSERequestIDMethodRequestBody": { "description": "Request body for SSERequestIDMethod.", "example": { - "id": "Est voluptas est repellat." + "id": "Fugit totam mollitia perspiciatis sit sit." }, "properties": { "id": { - "example": "Est voluptas est repellat.", + "example": "Fugit totam mollitia perspiciatis sit sit.", "type": "string" } }, @@ -27,11 +27,11 @@ "operationId": "SSERequestIDService#SSERequestIDMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "Mollitia saepe expedita quas sed maxime.", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "Mollitia saepe expedita quas sed maxime.", "type": "string" } } @@ -40,7 +40,7 @@ "content": { "application/json": { "example": { - "id": "Est voluptas est repellat." + "id": "Fugit totam mollitia perspiciatis sit sit." }, "schema": { "$ref": "#/components/schemas/SSERequestIDMethodRequestBody" @@ -57,7 +57,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "Nam odio.", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden index bbd022e6c6..7fcbc41057 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. + example: Mollitia saepe expedita quas sed maxime. + example: Mollitia saepe expedita quas sed maxime. requestBody: description: Request body for SSERequestIDMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSERequestIDMethodRequestBody' example: - id: Est voluptas est repellat. + id: Fugit totam mollitia perspiciatis sit sit. responses: "200": description: OK response. @@ -39,7 +39,7 @@ paths: properties: data: type: string - example: Quia molestias. + example: Nam odio. required: - data components: @@ -49,9 +49,9 @@ components: properties: id: type: string - example: Est voluptas est repellat. + example: Fugit totam mollitia perspiciatis sit sit. description: Request body for SSERequestIDMethod. example: - id: Est voluptas est repellat. + id: Fugit totam mollitia perspiciatis sit sit. tags: - name: SSERequestIDService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden index b23d98992c..a2ccb55bc0 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden @@ -16,7 +16,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "Ipsa libero est ipsum blanditiis.", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden index c5fd93dd0b..0c1b4511e1 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden @@ -23,7 +23,7 @@ paths: properties: data: type: string - example: Quia molestias. + example: Ipsa libero est ipsum blanditiis. required: - data components: {} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden index a7b20cdb5e..91e7f88221 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "Voluptas molestiae aliquam at." }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "Voluptas molestiae aliquam at.", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "Aut deleniti enim veritatis asperiores sit.", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "Aut deleniti enim veritatis asperiores sit.", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "Voluptas molestiae aliquam at." }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden index b556fec6fc..9a98580856 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden @@ -19,8 +19,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: Aut deleniti enim veritatis asperiores sit. + example: Aut deleniti enim veritatis asperiores sit. responses: "101": description: Switching Protocols response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: Voluptas molestiae aliquam at. components: schemas: UserType: @@ -37,8 +37,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: Voluptas molestiae aliquam at. example: - a: Odio laborum quae ut quis nostrum. + a: Voluptas molestiae aliquam at. tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden index 971bde70a4..1789fd7179 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 6827506417626806000, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 6827506417626806000, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden index e7365c3307..61d8d62f1e 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden @@ -19,9 +19,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 6827506417626806316 format: int64 - example: 5691356309313628853 + example: 6827506417626806316 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden index 3d5a4c31f7..ec2d7dc6dc 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "Voluptas molestiae aliquam at." }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "Voluptas molestiae aliquam at.", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "Aut deleniti enim veritatis asperiores sit.", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "Aut deleniti enim veritatis asperiores sit.", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "Voluptas molestiae aliquam at." }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden index 3fccfc7691..97b69d2460 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden @@ -18,8 +18,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: Aut deleniti enim veritatis asperiores sit. + example: Aut deleniti enim veritatis asperiores sit. responses: "101": description: Switching Protocols response. @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: Voluptas molestiae aliquam at. components: schemas: UserType: @@ -36,8 +36,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: Voluptas molestiae aliquam at. example: - a: Odio laborum quae ut quis nostrum. + a: Voluptas molestiae aliquam at. tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden index 18343b5c9e..cef3622ae5 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden @@ -79,7 +79,6 @@ "example": { "any": "", "any_array": [ - "", "", "", "" diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden index cb2e05152e..d00f4912cc 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden @@ -40,7 +40,6 @@ paths: - "" - "" - "" - - "" any_map: "": "" components: diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden index 12b5b2d556..81543dbb8b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden @@ -19,6 +19,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -31,6 +34,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -107,6 +113,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -132,6 +141,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -212,6 +224,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden index 14e848014a..5f2c43bcf5 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden @@ -40,6 +40,7 @@ paths: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 @@ -65,6 +66,7 @@ components: example: - string: "" - string: "" + - string: "" foo: type: string example: "" @@ -72,6 +74,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" TestEndpointRequestBody: type: object @@ -118,6 +121,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/components/schemas/GoaFoobar' @@ -143,6 +147,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden index 683e7e562a..e62a6c4b92 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden @@ -82,9 +82,6 @@ { "string": "" }, - { - "string": "" - }, { "string": "" } @@ -109,6 +106,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden index 28ea024cc9..7c33539b45 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden @@ -32,7 +32,6 @@ paths: bar: - string: "" - string: "" - - string: "" foo: "" "404": description: Not Found response. @@ -45,6 +44,7 @@ paths: - string: "" - string: "" - string: "" + - string: "" foo: "" components: schemas: diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden index e08c5e2a30..fc57572c86 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 6827506417626806000, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 6827506417626806000, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden index c736bebb0d..a824519a27 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 6827506417626806316 format: int64 - example: 5691356309313628853 + example: 6827506417626806316 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go index ce258de253..18a9a964a6 100644 --- a/http/codegen/openapi/v3/types.go +++ b/http/codegen/openapi/v3/types.go @@ -1,3 +1,5 @@ +// This file converts evaluated Goa types into OpenAPI v3 schemas while keeping +// generated examples anchored to their exact design locations. package openapiv3 import ( @@ -51,38 +53,47 @@ type ( } ) -// derived returns a schemafier drawing example values from a stream derived -// from the given identity, sharing all other state. See -// expr.ExampleGenerator.Derived. -func (sf *schemafier) derived(id string) *schemafier { +// at returns a schemafier drawing example values from the semantic owner. +func (sf *schemafier) at(identity expr.ExampleIdentity) *schemafier { c := *sf - c.rand = sf.rand.Derived(id) + c.rand = sf.rand.At(identity) return &c } -// rebased returns a schemafier whose example value stream is anchored to the -// given absolute design identity, sharing all other state. See -// expr.ExampleGenerator.Rebased. -func (sf *schemafier) rebased(id string) *schemafier { +// member returns a schemafier drawing examples for an object member below the +// current semantic owner. +func (sf *schemafier) member(name string) *schemafier { c := *sf - c.rand = sf.rand.Rebased(id) + c.rand = sf.rand.Member(name) return &c } -// bodyExampleID returns the absolute design identity anchoring the example -// streams of an endpoint request or response body. Anonymous body types -// (inline arrays, maps and primitives) have no type identity of their own so -// their examples anchor on the endpoint that owns them. -func bodyExampleID(svc, endpoint, role string) string { - return svc + "." + endpoint + "." + role +// arrayElement returns a schemafier drawing examples for one array element. +func (sf *schemafier) arrayElement(index int) *schemafier { + c := *sf + c.rand = sf.rand.ArrayElement(index) + return &c +} + +// mapValue returns a schemafier drawing examples for one map value. +func (sf *schemafier) mapValue(index int) *schemafier { + c := *sf + c.rand = sf.rand.MapValue(index) + return &c } -// fieldOf returns a schemafier whose example value stream is anchored to the -// identity of the named field of the given parent attribute, sharing all -// other state. See expr.ExampleGenerator.Field. -func (sf *schemafier) fieldOf(parent *expr.AttributeExpr, name string) *schemafier { +// unionMember returns a schemafier drawing examples for one union member. +func (sf *schemafier) unionMember(name string) *schemafier { c := *sf - c.rand = sf.rand.Field(parent, name) + c.rand = sf.rand.UnionMember(name) + return &c +} + +// field returns a schemafier for a field extracted from parent. Named user +// types retain their global field identity; anonymous parents use owner. +func (sf *schemafier) field(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) *schemafier { + c := *sf + c.rand = sf.rand.At(exampleFieldIdentity(parent, name, owner)) return &c } @@ -107,9 +118,9 @@ func newSchemafier(rand *expr.ExampleGenerator) *schemafier { // value indexed by type name. // // NOTE: entries are nil when the corresponding type is Empty. -func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { +func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version, generator *expr.ExampleGenerator) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { bodies := make(map[string]map[string]*EndpointBodies) - sf := newSchemafier(api.ExampleGenerator) + sf := newSchemafier(generator) sf.nameAliases = ver == openapi.Version32 services := openAPIGeneratedServices(api) @@ -118,13 +129,13 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, t := range resultTypes { if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, s := range api.HTTP.Services { @@ -145,9 +156,9 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp reqBody.Description = defaultRequestBodyDescription(e) } } - req := sf.rebased(bodyExampleID(s.Name(), e.Name(), "request")).schemafy(reqBody) + req := sf.at(expr.RequestBodyExampleIdentity(e)).schemafy(reqBody) if e.StreamingBody != nil { - sreq := sf.schemafy(e.StreamingBody) + sreq := sf.at(expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)).schemafy(e.StreamingBody) var note string if sreq.Ref != "" { note = sreq.Ref @@ -168,13 +179,15 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp } } res := make(map[int][]*openapi.Schema) - resps := e.Responses - for _, er := range e.HTTPErrors { - resps = append(resps, er.Response) + for _, resp := range e.Responses { + identity := expr.ResponseBodyExampleIdentity(e, resp) + js := sf.at(identity).schemafy(staticViewBody(resp)) + res[resp.StatusCode] = append(res[resp.StatusCode], js) } - for i, resp := range resps { - id := bodyExampleID(s.Name(), e.Name(), "response."+strconv.Itoa(resp.StatusCode)+"."+strconv.Itoa(i)) - js := sf.rebased(id).schemafy(staticViewBody(resp)) + for _, httpError := range e.HTTPErrors { + identity := expr.ErrorResponseBodyExampleIdentity(e, httpError) + resp := httpError.Response + js := sf.at(identity).schemafy(staticViewBody(resp)) res[resp.StatusCode] = append(res[resp.StatusCode], js) } eb := &EndpointBodies{RequestBody: req, ResponseBodies: res} @@ -200,10 +213,11 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche sse := e.SSE sr := e.MethodExpr.StreamingResult data := sr - dsf := sf + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + dsf := sf.at(owner) if sse.DataField != "" { data = expr.AsObject(sr.Type).Attribute(sse.DataField) - dsf = sf.fieldOf(sr, sse.DataField) + dsf = sf.field(sr, sse.DataField, owner) } var dataSchema *openapi.Schema switch data.Type { @@ -218,13 +232,13 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche } props := map[string]*openapi.Schema{"data": dataSchema} if sse.EventField != "" { - props["event"] = sf.fieldOf(sr, sse.EventField).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) + props["event"] = sf.field(sr, sse.EventField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) } if sse.IDField != "" { - props["id"] = sf.fieldOf(sr, sse.IDField).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) + props["id"] = sf.field(sr, sse.IDField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) } if sse.RetryField != "" { - props["retry"] = sf.fieldOf(sr, sse.RetryField).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) + props["retry"] = sf.field(sr, sse.RetryField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) } return &openapi.Schema{ Type: openapi.Object, @@ -297,7 +311,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi } case *expr.Array: s.Type = openapi.Array - s.Items = sf.derived("0").schemafy(t.ElemType) + s.Items = sf.arrayElement(0).schemafy(t.ElemType) case *expr.Object: s.Type = openapi.Object var itemNotes []string @@ -305,7 +319,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi if !openapi.MustGenerate(nat.Attribute.Meta) { continue } - s.Properties[nat.Name] = sf.derived(nat.Name).schemafy(nat.Attribute) + s.Properties[nat.Name] = sf.member(nat.Name).schemafy(nat.Attribute) } if len(itemNotes) > 0 { note = strings.Join(itemNotes, "\n") @@ -317,7 +331,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // See https://swagger.io/docs/specification/data-models/dictionaries/. s.AdditionalProperties = true } else { - s.AdditionalProperties = sf.derived("val0").schemafy(t.ElemType) + s.AdditionalProperties = sf.mapValue(0).schemafy(t.ElemType) } case *expr.Union: // Each branch owns both its discriminator literal and value schema so @@ -334,14 +348,14 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi Type: openapi.String, Enum: []any{val.Name}, }, - valueKey: sf.derived(val.Name).schemafy(val.Attribute), + valueKey: sf.unionMember(val.Name).schemafy(val.Attribute), }, Required: []string{typeKey, valueKey}, }) } case expr.UserType: if expr.IsAlias(t) && !sf.nameAliases { - s = sf.rebased(t.ID()).schemafy(t.Attribute()) + s = sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute()) break } h := sf.hashAttribute(attr, fnv.New64()) @@ -376,7 +390,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi typeName := sf.uniquify(codegen.Goify(name, true)) s.Ref = toRef(typeName) sf.hashes[h] = append(sf.hashes[h], s.Ref) - schema := sf.rebased(t.ID()).schemafy(t.Attribute(), true) + schema := sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute(), true) if schema.Description == "" { schema.Description = userTypeDescription(t, attr) } diff --git a/http/codegen/openapi/v3/types_test.go b/http/codegen/openapi/v3/types_test.go index 6b23e592c2..0275c9f5db 100644 --- a/http/codegen/openapi/v3/types_test.go +++ b/http/codegen/openapi/v3/types_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 schema construction and stable example +// generation for primitive, collection, object, and transport body types. package openapiv3 import ( @@ -216,7 +218,7 @@ func TestBuildBodyTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) svc, ok := bodies[svcName] if !ok { @@ -411,7 +413,7 @@ func TestMapTypes(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { // Build the OpenAPI spec root := codegen.RunDSL(t, tc.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) // Find the service and method svcBodies, ok := bodies[svcName] @@ -500,7 +502,7 @@ func validateAdditionalPropsSchema(t *testing.T, ctx string, schema *openapi.Sch func TestTypesOnlyDifferByEnum(t *testing.T) { root := codegen.RunDSL(t, dsls.StringEnumBodyDSL()) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) svc1, ok := bodies["svc_enum_1"] if !ok { @@ -549,7 +551,7 @@ func TestBuildBodyTypesPreservesPrimitiveAliasComponents(t *testing.T) { }) }) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32, expr.NewExampleGenerator(root.API.RandomizerFactory)) tests := []struct { name string status int @@ -700,7 +702,7 @@ func TestHashAttribute(t *testing.T) { } h := fnv.New64() - sf := newSchemafier(expr.NewRandom("test")) + sf := newSchemafier(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test"))) for _, group := range cases { t.Run(group.name, func(t *testing.T) { diff --git a/http/codegen/openapi/v3/types_union_test.go b/http/codegen/openapi/v3/types_union_test.go index c0a87ee798..f072544997 100644 --- a/http/codegen/openapi/v3/types_union_test.go +++ b/http/codegen/openapi/v3/types_union_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI 3 schema conversion for unions, including that a +// typed owner keeps each discriminator paired with its generated member value. package openapiv3 import ( @@ -11,7 +13,11 @@ import ( ) func TestSchemafyCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := (&schemafier{rand: expr.NewRandom("test")}).schemafy(unionAttribute()) + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := (&schemafier{rand: generator}).schemafy(unionAttribute()) require.Len(t, schema.AnyOf, 2) assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.Type(openapi.String)) diff --git a/http/codegen/openapi_disabled_examples_test.go b/http/codegen/openapi_disabled_examples_test.go new file mode 100644 index 0000000000..a35f47cd06 --- /dev/null +++ b/http/codegen/openapi_disabled_examples_test.go @@ -0,0 +1,59 @@ +// This file verifies that disabling OpenAPI examples uses document-private +// disabled generators and never changes service or evaluated design state. +package codegen + +import ( + "bytes" + "maps" + "strings" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIDisabledExamplesDoNotConsumeServiceState(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Meta = expr.MetaExpr{"openapi:example": {"false"}} + factory := root.API.RandomizerFactory + meta := maps.Clone(root.API.Meta) + examples := expr.NewExampleGenerator(factory) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NoError(t, service.Plan(root, generation)) + require.NoError(t, generation.Freeze()) + services, err := service.NewServicesData(root, generation, examples) + require.NoError(t, err) + method := services.Get("testService").Methods[0] + payloadExample := method.PayloadEx + require.NotNil(t, payloadExample) + + openapi.Definitions = make(map[string]*openapi.Schema) + files, err := OpenAPIFiles(root, examples) + require.NoError(t, err) + require.Len(t, files, 6) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var rendered bytes.Buffer + tmpl := template.Must(template.New("openapi").Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&rendered, section.Data)) + content := rendered.String() + if strings.HasSuffix(file.Path, ".json") { + require.NotContains(t, content, `"example"`) + } else { + require.NotContains(t, content, "\nexample:") + } + } + + require.Equal(t, payloadExample, method.PayloadEx) + require.Equal(t, factory, root.API.RandomizerFactory) + require.Equal(t, meta, root.API.Meta) +} diff --git a/http/codegen/openapi_order_independence_test.go b/http/codegen/openapi_order_independence_test.go index 88431e87a9..eff943d61e 100644 --- a/http/codegen/openapi_order_independence_test.go +++ b/http/codegen/openapi_order_independence_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP and OpenAPI analysis produce identical examples +// regardless of which transport representation is analyzed first. package codegen import ( @@ -36,10 +38,10 @@ func TestOpenAPIOrderIndependence(t *testing.T) { // NOTE: methods declaring anonymous object results (e.g. // testdata.SSEObjectDSL) only pass this check because the raw // object wrapping moved out of the service analyze pass into - // codegen.NormalizeRoot which CreateHTTPServices applies before + // codegen.NewGeneration, which CreateHTTPServices constructs before // computing the transport data. The pristine root below is rendered - // without normalization, so designs whose OpenAPI output depends on - // the wrapping must normalize both roots (see + // without generation ownership, so designs whose OpenAPI output depends + // on the wrapping must prepare both roots (see // TestGeneratorsTreatDesignAsReadOnly in codegen/generator for the // full read-only guarantee). {"sse", testdata.SSEStringDSL}, @@ -69,13 +71,12 @@ func TestOpenAPIOrderIndependence(t *testing.T) { // renderOpenAPI generates and renders all the OpenAPI specification files for // the given root and returns their content indexed by file path. The global -// schema registry and the example generator are reset first so that two -// generations of identical design trees yield identical documents. +// schema registry is reset first and the call receives a fresh example +// generator so two identical design trees yield identical documents. func renderOpenAPI(t *testing.T, root *expr.RootExpr) map[string]string { t.Helper() openapi.Definitions = make(map[string]*openapi.Schema) - root.API.ExampleGenerator = expr.NewRandom(root.API.Name) - files, err := OpenAPIFiles(root) + files, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) out := make(map[string]string, len(files)) for _, f := range files { diff --git a/http/codegen/openapi_test.go b/http/codegen/openapi_test.go index 66228ae900..9a7329ae8c 100644 --- a/http/codegen/openapi_test.go +++ b/http/codegen/openapi_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP OpenAPI generation uses prepared service data and +// run-owned example streams without mutating the evaluated design. package codegen import ( @@ -24,7 +26,7 @@ func TestOpenAPI(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - spec, err := OpenAPIFiles(root) + spec, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) assert.Equal(t, c.NilSpec, spec == nil, k) } @@ -75,7 +77,7 @@ func TestOutputPath(t *testing.T) { // Reset global variables openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - o, err := OpenAPIFiles(root) + o, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) if c.Err != "" { require.EqualError(t, err, c.Err) return diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 2f754cd3c9..43a45f4360 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -21,18 +21,20 @@ func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { dsl.Method("Read", func() {}) }) }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) require.Equal(t, "path2", services.ServiceImport("Path").Name) } func TestPlanRejectsFrozenGeneration(t *testing.T) { - generation := codegen.NewGeneration("generated.local/gen", nil) + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.Error(t, Plan(generation)) @@ -50,11 +52,12 @@ func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { }) } }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) client := services.PackageImport("generated.local/gen/http/foo/client") diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 4ff9df7888..af41d9113a 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -810,7 +810,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Type: att.Type, Pointer: pointer, Required: true, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(httpEndpoint.MethodExpr.Payload, arg)), + Example: sds.FieldExample(att, httpEndpoint.MethodExpr.Payload, arg, expr.MethodPayloadExampleIdentity(httpEndpoint.MethodExpr)), Validate: vcode, }, } @@ -1296,26 +1296,28 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD sd.clientWireTypes.applyNames(clientHTTPBody, wireRequestBody, clientPolicy) } var ( - payload = e.MethodExpr.Payload - svc = sd.Service - body = httpBody.Type - ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = httpContext(sd.serverWireTypes.scope, true, true) - httpclictx = httpContext(sd.clientWireTypes.scope, true, false) - svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) - svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) + payload = e.MethodExpr.Payload + svc = sd.Service + body = httpBody.Type + ep = svc.Method(e.MethodExpr.Name) + httpsvrctx = httpContext(sd.serverWireTypes.scope, true, true) + httpclictx = httpContext(sd.clientWireTypes.scope, true, false) + svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) + svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) + payloadOwner = expr.MethodPayloadExampleIdentity(e.MethodExpr) + bodyOwner = expr.RequestBodyExampleIdentity(e) request *RequestData mapQueryParam *ParamData ) { var ( - serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd) - clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd) - paramsData = sds.extractPathParams(e.PathParams(), payload, sd) - queryData = sds.extractQueryParams(e.QueryParams(), payload, sd) - headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope) - cookiesData = sds.extractCookies(e.Cookies, payload, svcsvrctx, sd.Scope) + serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd, payloadOwner, bodyOwner) + clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd, payloadOwner, bodyOwner) + paramsData = sds.extractPathParams(e.PathParams(), payload, sd, payloadOwner) + queryData = sds.extractQueryParams(e.QueryParams(), payload, sd, payloadOwner) + headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope, payloadOwner) + cookiesData = sds.extractCookies(e.Cookies, payload, svcsvrctx, sd.Scope, payloadOwner) origin string mustValidate bool @@ -1351,7 +1353,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD TypeRef: sd.Scope.GoTypeRef(pAtt), Validate: codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name), DefaultValue: pAtt.DefaultValue, - Example: pAtt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, name)), + Example: sds.FieldExample(pAtt, e.MethodExpr.Payload, name, payloadOwner), }, }, } @@ -1468,7 +1470,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD TypeRef: sd.serverWireTypes.scope.GoTypeRef(serverHTTPBody), Type: serverHTTPBody.Type, Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(httpBody, bodyOwner), Validate: svcode, }, }) @@ -1481,7 +1483,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD TypeRef: sd.clientWireTypes.scope.GoTypeRefWithDefaults(clientHTTPBody), Type: clientHTTPBody.Type, Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(httpBody, bodyOwner), Validate: cvcode, }, }) @@ -1542,7 +1544,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD Type: uatt.Type, Pointer: sc.UsernamePointer, Validate: codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr), - Example: uatt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.UsernameAttr)), + Example: sds.FieldExample(uatt, e.MethodExpr.Payload, sc.UsernameAttr, payloadOwner), }, } patt := e.MethodExpr.Payload.Find(sc.PasswordAttr) @@ -1566,7 +1568,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD Type: patt.Type, Pointer: sc.PasswordPointer, Validate: codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr), - Example: patt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.PasswordAttr)), + Example: sds.FieldExample(patt, e.MethodExpr.Payload, sc.PasswordAttr, payloadOwner), }, } cliArgs = []*InitArgData{uarg, parg} @@ -1779,6 +1781,8 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A notag := -1 for i, resp := range e.Responses { respBody := sd.bodies.response(resp) + resultOwner := expr.MethodResultExampleIdentity(e.MethodExpr) + bodyOwner := expr.ResponseBodyExampleIdentity(e, resp) if resp.Tag[0] == "" { if notag > -1 { continue // we don't want more than one response with no tag @@ -1799,8 +1803,8 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A resAttr = result ) { - headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope) - cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope) + headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope, resultOwner) + cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope, resultOwner) if respBody.Type != expr.Empty { // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body @@ -1816,14 +1820,14 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A if origin != "" { // Response body is explicitly set to an attribute in the method // result type. No need to do any view-based projections server side. - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else if v, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { // Design explicitly sets the view to render the result. // We generate only one server body type which will be rendered // using the specified view. - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else { @@ -1836,24 +1840,24 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // attributes defined in the view in the response (NOTE: a required // attribute in the result type may not be present in all its views) for _, view := range md.ViewedResult.Views { - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } } if clientView != "" { clientRespBody = effectiveClientResponseBody(respBody, e, md) - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd, resultOwner, bodyOwner) clientBodyView = &clientView } else { - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, resultOwner, bodyOwner) clientBodyView = &vname } } else { - if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, resultOwner, bodyOwner) } if clientRespBody.Type != expr.Empty { var viewName string @@ -2029,6 +2033,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa respBody := expr.DupAtt(sd.bodies.errorResponse(v)) addMarshalTags(respBody) errorAttribute := e.MethodExpr.Error(v.Name).AttributeExpr + errorOwner := expr.MethodErrorExampleIdentity(e.MethodExpr, v.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, v) var ( init *InitData body = respBody.Type @@ -2046,8 +2052,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa name = fmt.Sprintf("New%s%s", codegen.Goify(ep.Name, true), codegen.Goify(v.ErrorExpr.Name, true)) desc = fmt.Sprintf("%s builds a %s service %s endpoint %s error.", name, svc.Name, e.Name(), v.ErrorExpr.Name) - headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) argsCap := len(headers) + len(cookies) if body != expr.Empty { argsCap++ @@ -2135,10 +2141,10 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa clientBodyData *TypeData ) { - if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd); sbd != nil { + if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd, errorOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd, errorOwner, bodyOwner) if clientBodyData != nil { clientBodyData.Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", clientBodyData.VarName, svc.Name, e.Name(), v.Name) @@ -2147,8 +2153,8 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } } - headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) var mustValidate bool for _, h := range headers { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -2231,7 +2237,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa // svr is true if the function is generated for server side code. // // sd is the service data -func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { if body.Type == expr.Empty { return nil } @@ -2333,7 +2339,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * TypeRef: svcctx.Scope.Ref(att, svcctx.Pkg(att)), Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ @@ -2354,7 +2360,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * Init: init, ValidateDef: validateDef, ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(body, bodyOwner), } if record == nil || data.Def == "" && data.ValidateDef == "" { return data @@ -2373,7 +2379,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // svr is true if the function is generated for server side code // // view is the view name to add as a suffix to the type name. -func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { if body.Type == expr.Empty { return nil } @@ -2534,7 +2540,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e TypeRef: tref, Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ @@ -2555,7 +2561,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e Init: init, ValidateDef: validateDef, ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(body, bodyOwner), View: viewName, } if record == nil || td.Def == "" && td.ValidateDef == "" { @@ -2564,10 +2570,10 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e return catalog.bind(record, td) } -func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData) []*ParamData { +func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData svcctx := sds.serviceTypeContext(sd, "server").Enter(service) - sds.extractElements(pathElement, a, service, svcctx, sd.Scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(pathElement, a, service, svcctx, sd.Scope, owner, func(el *Element, _ *expr.AttributeExpr) { params = append(params, &ParamData{ Map: false, MapStringSlice: false, @@ -2577,10 +2583,10 @@ func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData) []*ParamData { +func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData svcctx := sds.serviceTypeContext(sd, "server").Enter(service) - sds.extractElements(queryElement, a, service, svcctx, sd.Scope, func(el *Element, att *expr.AttributeExpr) { + sds.extractElements(queryElement, a, service, svcctx, sd.Scope, owner, func(el *Element, att *expr.AttributeExpr) { mp := expr.AsMap(att.Type) params = append(params, &ParamData{ Map: mp != nil, @@ -2594,9 +2600,9 @@ func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*HeaderData { +func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*HeaderData { var headers []*HeaderData - sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { headers = append(headers, &HeaderData{ CanonicalName: http.CanonicalHeaderKey(el.HTTPName), Element: el, @@ -2605,9 +2611,9 @@ func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *exp return headers } -func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*CookieData { +func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*CookieData { var cookies []*CookieData - sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { c := &CookieData{Element: el} for n, v := range a.Meta { switch n { @@ -2664,7 +2670,7 @@ func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *exp // the service expression to compute field pointer semantics, // // - cookies do not track slice information (cookie values are scalars). -func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, add func(el *Element, att *expr.AttributeExpr)) { +func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity, add func(el *Element, att *expr.AttributeExpr)) { codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck if kind == pathElement { required = true @@ -2750,7 +2756,7 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt Validate: validate, IsTextUnmarshaler: isText, DefaultValue: att.DefaultValue, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(svcAtt, name)), + Example: sds.FieldExample(att, svcAtt, name, owner), }, }, att) return nil @@ -2938,7 +2944,7 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve Ref: record.ref, ValidateDef: validate, ValidateRef: validateRef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, expr.UserTypeExampleIdentity(ut)), }) } diff --git a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden index a520c7dfae..f9cd37c2e4 100644 --- a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyCustomNamePayload(serviceBodyCustomNameMethodBodyCustomNameB { err = json.Unmarshal([]byte(serviceBodyCustomNameMethodBodyCustomNameBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Itaque ab itaque.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Et consequatur molestiae.\"\n }'") } } v := &servicebodycustomname.MethodBodyCustomNamePayload{ diff --git a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden index 0e95662003..eb03207236 100644 --- a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyQueryPathObjectPayload(serviceBodyQueryPathObjectMethodBodyQ { err = json.Unmarshal([]byte(serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Hic at eveniet porro sit nisi.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Dolor rerum.\"\n }'") } } var c2 string diff --git a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden index 68335349e1..995004eae1 100644 --- a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyPrimitiveArrayUserPayload(serviceBodyPrimitiveArrayUserMetho if serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA != "" { err = json.Unmarshal([]byte(serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA), &a) if err != nil { - return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Ducimus hic sint temporibus velit blanditiis in.\",\n \"Sint qui sit quaerat quas illo.\"\n ]'") + return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Enim ut qui quaerat assumenda voluptatum.\",\n \"Ut iste molestiae.\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden index ff5cf7ad44..6e3accf9e9 100644 --- a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden @@ -27,7 +27,7 @@ func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectB { err = json.Unmarshal([]byte(serviceMapQueryObjectMethodMapQueryObjectC), &c) if err != nil { - return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"5691875720969669573\": [\n \"Rerum quia ea.\",\n \"Qui iure hic quisquam nulla error.\",\n \"Nihil necessitatibus expedita architecto atque.\",\n \"Nulla nisi.\"\n ],\n \"7072545540989245598\": [\n \"Adipisci veritatis sunt impedit et soluta fugiat.\",\n \"Ut commodi cum exercitationem voluptas autem voluptates.\",\n \"Debitis voluptatem.\"\n ],\n \"986504572350809452\": [\n \"Accusantium corrupti sed enim optio consequatur aut.\",\n \"Est molestiae qui.\"\n ]\n }'") + return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"1025921908706477149\": [\n \"Nostrum et.\",\n \"Reiciendis rerum aliquam vitae esse.\",\n \"Omnis alias cumque nulla rerum.\"\n ]\n }'") } } v := &servicemapqueryobject.PayloadType{ diff --git a/http/codegen/testdata/golden/client_cli_map-query.go.golden b/http/codegen/testdata/golden/client_cli_map-query.go.golden index f9367d6765..042a59c994 100644 --- a/http/codegen/testdata/golden/client_cli_map-query.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query.go.golden @@ -85,7 +85,7 @@ func ParseEndpoint( err = json.Unmarshal([]byte(*serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Quidem accusamus.\": [\n 3906106080549376696,\n 18039872150139340048\n ],\n \"Repellat occaecati officiis doloremque.\": [\n 8210943955859777434,\n 7701234893751856770,\n 12687845642945213165,\n 5080739739723289788\n ],\n \"Voluptas officiis in eum nostrum voluptatem.\": [\n 7799018251876253497,\n 23602025540978920\n ]\n }'") + return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Deserunt itaque pariatur ipsam consequuntur nostrum.\": [\n 11990920063555956079,\n 17089161724625472560\n ],\n \"Libero dolore est accusantium explicabo nostrum rerum.\": [\n 2438159965416078706,\n 977176377124085955,\n 16802288397808742928\n ],\n \"Occaecati nulla iusto.\": [\n 11103002557489322756,\n 17383528869145716448,\n 5505226717552383690,\n 3219217238434884659\n ]\n }'") } } } diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index a97b24bbce..563029a6e9 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s { err = json.Unmarshal([]byte(serviceMultiMethodMultiPayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": false,\n \"att10\": \"Ea impedit omnis.\",\n \"att11\": \"RXQgb21uaXMgcXVhcyBuaWhpbCBiZWF0YWUgZXNzZSBkZWxlbml0aS4=\",\n \"att12\": \"Dolorum non.\",\n \"att13\": [\n \"Sint nisi.\",\n \"Accusantium rerum nihil quae ducimus consequatur fugiat.\",\n \"Autem eum et et et nulla quasi.\",\n \"Cumque ut optio.\"\n ],\n \"att14\": {\n \"Vel sunt architecto.\": \"Animi distinctio sequi atque et explicabo ullam.\",\n \"Vitae beatae ea porro magni et.\": \"Et eaque iusto fugit qui.\"\n },\n \"att15\": {\n \"inline\": \"Quia sint.\"\n },\n \"att2\": 143235663851589327,\n \"att3\": 1890639226,\n \"att4\": 8858961141680733979,\n \"att5\": 15592217388400497022,\n \"att6\": 1918120858,\n \"att7\": 17362445853891470951,\n \"att8\": 0.35741758,\n \"att9\": 0.44400284372015414\n }\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": false,\n \"att10\": \"Fugiat deserunt qui unde odit blanditiis aut.\",\n \"att11\": \"SXBzYSBtaW51cyBpdXJlIHZlcml0YXRpcyByZXJ1bS4=\",\n \"att12\": \"Illum nulla alias.\",\n \"att13\": [\n \"Quod quos architecto.\",\n \"Deleniti numquam.\",\n \"Similique voluptatibus non quaerat eum nobis.\"\n ],\n \"att14\": {\n \"Libero laboriosam.\": \"Et nesciunt corrupti.\"\n },\n \"att15\": {\n \"inline\": \"Labore hic unde.\"\n },\n \"att2\": 7364427417869607579,\n \"att3\": 1230775405,\n \"att4\": 7548751036052687007,\n \"att5\": 13421568780903955996,\n \"att6\": 955870894,\n \"att7\": 5943663396465067570,\n \"att8\": 0.45085564,\n \"att9\": 0.9928171957403447\n }\n }'") } } var b *string diff --git a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden index 78b13409ba..f333133bb6 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden @@ -85,7 +85,7 @@ func ParseEndpoint( err = json.Unmarshal([]byte(*serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\",\n \"val\",\n \"val\"\n ]'") + return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index 86f4b8877a..8468cea998 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyI { err = json.Unmarshal([]byte(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") } } v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) diff --git a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden index 4ce075ee76..86615cfbbc 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineO { err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden index a0564a8397..4448b25adb 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineO { err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_simple-build.go.golden b/http/codegen/testdata/golden/client_cli_simple-build.go.golden index 4b8d815c4b..54bce0f8e0 100644 --- a/http/codegen/testdata/golden/client_cli_simple-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_simple-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodMultiSimplePayloadPayload(serviceMultiSimple1MethodMultiSimplePa { err = json.Unmarshal([]byte(serviceMultiSimple1MethodMultiSimplePayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": false\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": true\n }'") } } v := &servicemultisimple1.MethodMultiSimplePayloadPayload{ diff --git a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden index 6aad04fffb..dc98d50d28 100644 --- a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden @@ -6,7 +6,7 @@ func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, ser { err = json.Unmarshal([]byte(serviceWithParamsAndHeadersBlockMethodABody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Molestias quia.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Dolores eos voluptas.\"\n }'") } } var path uint diff --git a/http/codegen/testing.go b/http/codegen/testing.go index 7bb01518be..df2c4db0cc 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -1,5 +1,5 @@ // This file builds HTTP code-generation analysis in tests using the same -// normalize, plan, freeze, and render lifecycle as production generation. +// generation construction, planning, freezing, and rendering as production. package codegen import ( @@ -10,18 +10,19 @@ import ( "goa.design/goa/v3/expr" ) -// CreateHTTPServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. +// CreateHTTPServices creates a new ServicesData instance for testing. +// Generation construction normalizes the root before any planner reads it. func CreateHTTPServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) return NewServicesData(createServiceServices(root), root.API.HTTP) } // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("/", []eval.Root{root}) + generation, err := codegen.NewGeneration("/", []eval.Root{root}) + if err != nil { + panic(err) + } if err := service.Plan(root, generation); err != nil { panic(err) } @@ -34,7 +35,7 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) if err != nil { panic(err) } diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index f89aca53eb..2fe8b50d25 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -101,9 +101,10 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin cliRecvWithContextDesc := fmt.Sprintf("%s reads instances of %q from the %q endpoint websocket connection with context.", md.ClientStream.RecvWithContextName, svrSendTypeName, md.Name) if e.MethodExpr.Stream == expr.ClientStreamKind || e.MethodExpr.Stream == expr.BidirectionalStreamKind { streamBody := sd.bodies.streaming(e) + streamOwner := expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr) svrRecvTypeName = svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true) svrRecvTypeRef = svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)) - svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd) + svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd, streamOwner, streamOwner) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type // generate constructor function to transform request body, @@ -146,9 +147,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin TypeRef: sd.serverWireTypes.scope.GoTypeRef(streamBody), Type: streamBody.Type, Required: true, - // The example has always been computed from the - // request body, not the streaming body. - Example: sd.bodies.request(e).Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(streamBody, streamOwner), Validate: svcode, }, }} @@ -175,7 +174,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ServerCode: serverCode, } } - cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd) + cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd, streamOwner, streamOwner) if e.MethodExpr.Stream == expr.ClientStreamKind { svrSendDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection and closes the connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) svrSendWithContextDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection with context and closes the connection.", md.ServerStream.SendWithContextName, svrSendTypeName, md.Name) diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index 1c659125be..8865ff7865 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -14,11 +14,12 @@ import ( goacodegen "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/generator" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" "goa.design/goa/v3/jsonrpc/codegen/testdata" ) @@ -31,20 +32,19 @@ import ( // against a manifest so files that appear or disappear fail the test. func TestJSONRPCKitchenSink(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) - // The test invokes the generator functions directly so it must apply the - // design normalization generator.Generate runs before them. - goacodegen.NormalizeRoot(root) roots := []eval.Root{root} - generation := goacodegen.NewGeneration("kitchensink", roots) + generation, err := goacodegen.NewGeneration("kitchensink", roots) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, jsonrpccodegen.Plan(generation)) require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) - tfiles, err := generator.Transport(generation) - require.NoError(t, err) - efiles, err := generator.Example(generation) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + services, err := service.NewServicesData(root, generation, examples) require.NoError(t, err) + tfiles := kitchenSinkTransportFiles(root, services) + efiles := kitchenSinkExampleFiles(root, services) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -77,3 +77,57 @@ func TestJSONRPCKitchenSink(t *testing.T) { testutil.CompareOrUpdateGolden(t, string(content), filepath.Join(goldenDir, rel+".golden")) } } + +// kitchenSinkTransportFiles assembles every transport file through the public +// subsystem APIs exercised by the golden fixture. +func kitchenSinkTransportFiles(root *expr.RootExpr, services *service.ServicesData) []*goacodegen.File { + httpServices := httpcodegen.NewServicesData(services, root.API.HTTP) + files := httpcodegen.ServerFiles(httpServices) + files = append(files, httpcodegen.ClientFiles(httpServices)...) + files = append(files, httpcodegen.ServerTypeFiles(httpServices)...) + files = append(files, httpcodegen.ClientTypeFiles(httpServices)...) + files = append(files, httpcodegen.PathFiles(httpServices)...) + files = append(files, httpcodegen.ClientCLIFiles(httpServices)...) + + grpcServices := grpccodegen.NewServicesData(services) + files = append(files, grpccodegen.ProtoFiles(grpcServices)...) + files = append(files, grpccodegen.ServerFiles(grpcServices)...) + files = append(files, grpccodegen.ClientFiles(grpcServices)...) + files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) + + jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) + files = append(files, jsonrpccodegen.ServerFiles(jsonrpcServices)...) + files = append(files, jsonrpccodegen.ClientFiles(jsonrpcServices)...) + files = append(files, httpcodegen.ServerTypeFiles(jsonrpcServices)...) + files = append(files, httpcodegen.ClientTypeFiles(jsonrpcServices)...) + files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) + return append(files, httpcodegen.ClientCLIFiles(jsonrpcServices)...) +} + +// kitchenSinkExampleFiles assembles example service and transport files +// through their public subsystem APIs. +func kitchenSinkExampleFiles(root *expr.RootExpr, services *service.ServicesData) []*goacodegen.File { + files := service.ExampleServiceFiles(services.GenPkg(), root, services) + files = append(files, service.ExampleInterceptorsFiles(services.GenPkg(), root, services)...) + files = append(files, example.ServerFiles(root, services)...) + files = append(files, example.CLIFiles(root)...) + + if len(root.API.HTTP.Services) > 0 { + httpServices := httpcodegen.NewServicesData(services, root.API.HTTP) + files = append(files, httpcodegen.ExampleServerFiles(httpServices)...) + files = append(files, httpcodegen.ExampleCLIFiles(httpServices)...) + } + if len(root.API.JSONRPC.Services) > 0 { + jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) + files = append(files, jsonrpccodegen.ExampleServerFiles(jsonrpcServices, files)...) + files = append(files, httpcodegen.ExampleCLIFiles(jsonrpcServices)...) + } + if len(root.API.GRPC.Services) > 0 { + grpcServices := grpccodegen.NewServicesData(services) + files = append(files, grpccodegen.ExampleServerFiles(grpcServices)...) + files = append(files, grpccodegen.ExampleCLIFiles(grpcServices)...) + } + return files +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go index d6e1732042..4d3380e012 100644 --- a/jsonrpc/codegen/plan_test.go +++ b/jsonrpc/codegen/plan_test.go @@ -23,11 +23,12 @@ func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { }) }) }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) require.Equal(t, "uuid2", services.ServiceImport("UUID").Name) @@ -43,11 +44,12 @@ func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { }) } }) - generation := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) require.NoError(t, service.Plan(root, generation)) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) client := services.PackageImport("generated.local/gen/jsonrpc/foo/client") diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden index 960a032aba..6075cfb070 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden @@ -31,7 +31,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'" + "\n" + os.Args[0] + " " + "health check" + "\n" + "" } @@ -176,7 +176,7 @@ func mixedLookupUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } // healthUsage displays the usage of the health command and its subcommands. diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden index 9c72b09db6..ffb7c294ed 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden @@ -21,7 +21,7 @@ func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { { err = json.Unmarshal([]byte(mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } } v := &mixed.LookupPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden index e6eabf22b7..ecafd2112a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden @@ -20,7 +20,7 @@ func BuildAddPayload(calcAddBody string) (*calc.AddPayload, error) { { err = json.Unmarshal([]byte(calcAddBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'") } } v := &calc.AddPayload{ @@ -39,7 +39,7 @@ func BuildLogPayload(calcLogBody string) (*calc.LogPayload, error) { { err = json.Unmarshal([]byte(calcLogBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Quae consectetur.\",\n \"message\": \"Quo excepturi.\"\n }'") } } v := &calc.LogPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden index f2dcb9b86c..d9a6e9bd34 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden @@ -21,7 +21,7 @@ func BuildEchoPayload(chatEchoBody string) (*chat.EchoPayload, error) { { err = json.Unmarshal([]byte(chatEchoBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'") } } v := &chat.EchoPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index c9fbe6d6ae..89568c2478 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -35,10 +35,10 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'" + "\n" + - os.Args[0] + " " + "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'" + "\n" + - os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'" + "\n" + - os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'" + "\n" + + os.Args[0] + " " + "chat echo --body '{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'" + "\n" + + os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'" + "\n" + + os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'" + "\n" + "" } @@ -254,7 +254,7 @@ func calcAddUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'") } func calcPingUsage() { @@ -288,7 +288,7 @@ func calcLogUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Quae consectetur.\",\n \"message\": \"Quo excepturi.\"\n }'") } // chatUsage displays the usage of the chat command and its subcommands. @@ -316,7 +316,7 @@ func chatEchoUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "chat echo --body '{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'") } // feedUsage displays the usage of the feed command and its subcommands. @@ -344,7 +344,7 @@ func feedWatchUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") } // mixedUsage displays the usage of the mixed command and its subcommands. @@ -372,5 +372,5 @@ func mixedLookupUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'") } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden index 084bb0c210..985370d22c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden @@ -21,7 +21,7 @@ func BuildWatchPayload(feedWatchBody string) (*feed.WatchPayload, error) { { err = json.Unmarshal([]byte(feedWatchBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") } } v := &feed.WatchPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden index cc9f3238d7..92afb4a877 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden @@ -21,7 +21,7 @@ func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { { err = json.Unmarshal([]byte(mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'") } } v := &mixed.LookupPayload{ diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 3aeb97eb95..4f94e38ed1 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -1,5 +1,5 @@ // This file builds JSON-RPC code-generation analysis in tests using the same -// normalize, plan, freeze, and render lifecycle as production generation. +// generation construction, planning, freezing, and rendering as production. package codegen import ( @@ -11,10 +11,9 @@ import ( ) // CreateJSONRPCServices creates a new ServicesData instance for JSON-RPC -// testing. The root is normalized first like the production Generate flow -// does before the generators read the design. +// testing. Generation construction normalizes the root before any planner +// reads it. func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { - codegen.NormalizeRoot(root) services := createServiceServices(root) return httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) } @@ -22,7 +21,10 @@ func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation := codegen.NewGeneration("/", []eval.Root{root}) + generation, err := codegen.NewGeneration("/", []eval.Root{root}) + if err != nil { + panic(err) + } if err := service.Plan(root, generation); err != nil { panic(err) } @@ -32,7 +34,7 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation) + services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) if err != nil { panic(err) } From 3bf68c499e3828bd61b808d60f8ee84013dc1508 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 21 Aug 2026 20:07:11 -0700 Subject: [PATCH 29/43] fix(codegen): retain complete service generation plans --- codegen/ARCHITECTURE.md | 40 +- codegen/example/example_server_test.go | 7 +- codegen/generated_types.go | 18 +- codegen/generated_types_test.go | 109 +- codegen/generation.go | 91 +- codegen/generator/example.go | 10 +- codegen/generator/generate.go | 110 +- ...generate_grpc_metadata_integration_test.go | 10 +- ...uired_union_validation_integration_test.go | 180 ++ ...erate_http_union_shape_integration_test.go | 2 +- codegen/generator/generate_merge_test.go | 6 + .../generate_union_merge_integration_test.go | 1 + .../generated_package_import_test.go | 347 +++ ...erated_transport_alias_integration_test.go | 10 +- codegen/generator/generation_test.go | 2 + codegen/generator/generators.go | 8 +- codegen/generator/lifecycle.go | 7 + codegen/generator/openapi.go | 5 +- codegen/generator/plan.go | 28 + codegen/generator/purity_test.go | 2 +- codegen/generator/registry_test.go | 36 +- codegen/generator/service.go | 55 +- .../service_union_package_scope_test.go | 101 +- codegen/generator/test_helpers_test.go | 50 +- codegen/generator/transport.go | 11 +- ...iewed_transport_import_integration_test.go | 215 ++ codegen/go_transform.go | 419 +++- codegen/go_transform_test.go | 329 ++- codegen/go_type_plan.go | 748 ++++++ codegen/go_type_plan_test.go | 500 ++++ codegen/name_declaration.go | 51 +- codegen/service/client.go | 21 +- codegen/service/client_test.go | 4 +- codegen/service/conversion_plan.go | 563 +++++ .../service/conversion_plan_contract_test.go | 387 ++++ codegen/service/convert.go | 344 +-- codegen/service/convert_test.go | 125 +- codegen/service/declaration_resolver.go | 71 +- codegen/service/declaration_resolver_test.go | 22 +- codegen/service/endpoint.go | 92 +- codegen/service/endpoint_test.go | 4 +- codegen/service/example_generator_test.go | 23 +- codegen/service/example_interceptors.go | 74 +- codegen/service/example_interceptors_test.go | 28 +- codegen/service/example_svc.go | 80 +- codegen/service/example_svc_test.go | 4 +- codegen/service/generated_emission.go | 124 + codegen/service/generated_package.go | 721 ++++-- codegen/service/imports.go | 587 +++-- codegen/service/imports_test.go | 137 +- codegen/service/interceptor_data.go | 147 ++ codegen/service/interceptors.go | 112 +- codegen/service/interceptors_test.go | 137 +- codegen/service/method_data.go | 277 +++ codegen/service/plan.go | 943 ++++++++ codegen/service/plan_lifecycle.go | 118 + ...ained_expression_mutation_contract_test.go | 167 ++ codegen/service/retained_plan_test.go | 93 + codegen/service/security_data.go | 78 + codegen/service/security_test.go | 12 +- codegen/service/service.go | 289 +-- codegen/service/service_data.go | 2002 ++--------------- .../service_data_union_nilability_test.go | 30 +- .../service/service_data_union_order_test.go | 151 +- ...ice_declaration_condition_contract_test.go | 110 + codegen/service/service_dedup_test.go | 4 +- codegen/service/service_fact_plan.go | 315 +++ codegen/service/service_link.go | 423 ++++ .../service_name_collision_contract_test.go | 152 ++ codegen/service/service_names.go | 323 +++ codegen/service/service_names_test.go | 189 ++ .../service_plan_compile_contract_test.go | 144 ++ .../service_plan_render_contract_test.go | 223 ++ codegen/service/service_test.go | 352 ++- ...nt_interceptor_stream_wrapper_types.go.tpl | 6 +- .../client_interceptor_stream_wrappers.go.tpl | 20 +- .../client_interceptor_wrappers.go.tpl | 16 +- .../templates/client_interceptors.go.tpl | 4 +- .../service/templates/client_wrappers.go.tpl | 8 +- codegen/service/templates/endpoint.go.tpl | 4 +- .../templates/endpoint_wrappers.go.tpl | 8 +- codegen/service/templates/error.go.tpl | 2 +- codegen/service/templates/error_init.go.tpl | 4 +- .../example_client_interceptor.go.tpl | 14 +- .../example_security_authfuncs.go.tpl | 2 +- .../example_server_interceptor.go.tpl | 14 +- .../templates/example_service_init.go.tpl | 4 +- .../templates/example_service_struct.go.tpl | 2 +- codegen/service/templates/interceptors.go.tpl | 68 +- .../templates/interceptors_types.go.tpl | 36 +- .../templates/jsonrpc_handle_stream.go.tpl | 2 +- .../jsonrpc_streaming_endpoint.go.tpl | 4 +- .../service/templates/return_type_init.go.tpl | 12 +- ...er_interceptor_stream_wrapper_types.go.tpl | 6 +- .../server_interceptor_stream_wrappers.go.tpl | 20 +- .../server_interceptor_wrappers.go.tpl | 16 +- .../templates/server_interceptors.go.tpl | 4 +- codegen/service/templates/service.go.tpl | 20 +- .../service/templates/service_client.go.tpl | 4 +- .../templates/service_client_init.go.tpl | 10 +- .../templates/service_client_method.go.tpl | 6 +- .../templates/service_endpoint_method.go.tpl | 12 +- .../templates/service_endpoints.go.tpl | 2 +- .../templates/service_endpoints_init.go.tpl | 12 +- .../templates/service_endpoints_use.go.tpl | 2 +- .../service/templates/transform_helper.go.tpl | 6 +- codegen/service/templates/type_init.go.tpl | 2 +- .../service/templates/type_validate.go.tpl | 12 +- codegen/service/templates/validate.go.tpl | 2 +- .../service/templates/viewed_type_map.go.tpl | 4 +- codegen/service/test_helpers_test.go | 8 + .../service/testdata/a-nested-alpha/unused.go | 8 + .../golden/pkg_path_array_foo.go.golden | 2 +- .../golden/pkg_path_dupes_foo.go.golden | 2 +- .../golden/pkg_path_multiple_bar.go.golden | 2 +- .../golden/pkg_path_multiple_baz.go.golden | 2 +- .../pkg_path_payload_attribute_foo.go.golden | 2 +- .../golden/pkg_path_recursive_foo.go.golden | 2 +- ...pkg_path_recursive_recursive_foo.go.golden | 3 +- .../golden/pkg_path_single_foo.go.golden | 2 +- ...-with-explicit-and-default-views.go.golden | 6 +- ...ce-result-with-inline-validation.go.golden | 15 + ...read-payload_client_interceptors.go.golden | 2 +- ...ead-payload_interceptor_wrappers.go.golden | 4 +- ...ead-payload_service_interceptors.go.golden | 2 +- ...-read-result_client_interceptors.go.golden | 2 +- ...read-result_interceptor_wrappers.go.golden | 4 +- ...read-result_service_interceptors.go.golden | 2 +- ...rite-payload_client_interceptors.go.golden | 2 +- ...ite-payload_interceptor_wrappers.go.golden | 4 +- ...ite-payload_service_interceptors.go.golden | 2 +- ...write-result_client_interceptors.go.golden | 2 +- ...rite-result_interceptor_wrappers.go.golden | 4 +- ...rite-result_service_interceptors.go.golden | 2 +- ...rite-payload_client_interceptors.go.golden | 2 +- ...ite-payload_interceptor_wrappers.go.golden | 4 +- ...ite-payload_service_interceptors.go.golden | 2 +- ...write-result_client_interceptors.go.golden | 2 +- ...rite-result_interceptor_wrappers.go.golden | 4 +- ...rite-result_service_interceptors.go.golden | 2 +- ...interceptors_client_interceptors.go.golden | 4 +- ...nterceptors_interceptor_wrappers.go.golden | 8 +- ...nterceptors_service_interceptors.go.golden | 4 +- ...interceptor_interceptor_wrappers.go.golden | 4 +- ...interceptor_service_interceptors.go.golden | 4 +- ...-interceptor_client_interceptors.go.golden | 2 +- ...interceptor_interceptor_wrappers.go.golden | 2 +- ...interceptor_interceptor_wrappers.go.golden | 2 +- ...interceptor_service_interceptors.go.golden | 2 +- ...interceptor_interceptor_wrappers.go.golden | 4 +- ...interceptor_service_interceptors.go.golden | 4 +- ...ming-payload_client_interceptors.go.golden | 2 +- ...ing-payload_interceptor_wrappers.go.golden | 4 +- ...ing-payload_service_interceptors.go.golden | 2 +- ...ead-payload_interceptor_wrappers.go.golden | 2 +- ...ead-payload_service_interceptors.go.golden | 2 +- ...read-result_interceptor_wrappers.go.golden | 2 +- ...read-result_service_interceptors.go.golden | 2 +- ...aming-result_client_interceptors.go.golden | 2 +- ...ming-result_interceptor_wrappers.go.golden | 4 +- ...ming-result_service_interceptors.go.golden | 2 +- ...interceptors_client_interceptors.go.golden | 2 +- ...nterceptors_interceptor_wrappers.go.golden | 4 +- ...nterceptors_service_interceptors.go.golden | 2 +- .../service/testdata/nested-alpha/alpha.go | 7 + codegen/service/testdata/nested-beta/beta.go | 7 + .../service/testdata/nested-outer/outer.go | 17 + codegen/service/testdata/service_dsls.go | 2 +- ...ransform_helper_operation_contract_test.go | 265 +++ .../type_map_identity_contract_test.go | 33 + codegen/service/type_plan.go | 527 +++++ codegen/service/view_data.go | 566 +++++ codegen/service/view_validation_plan.go | 235 ++ codegen/service/views.go | 90 +- codegen/service/views_test.go | 4 +- codegen/templates/transform_go_array.go.tpl | 4 +- codegen/templates/transform_go_map.go.tpl | 8 +- codegen/templates/validation/union.go.tpl | 22 +- codegen/templates/validation/user.go.tpl | 5 +- .../golden/validation_float-pointer.go.golden | 4 +- .../validation_float-required.go.golden | 4 +- .../validation_float-use-default.go.golden | 4 +- .../validation_integer-pointer.go.golden | 4 +- .../validation_integer-required.go.golden | 4 +- .../validation_integer-use-default.go.golden | 4 +- codegen/transformer.go | 107 +- codegen/validation.go | 81 +- codegen/validation_plan.go | 817 +++++++ codegen/validation_plan_test.go | 238 ++ codegen/validation_protobuf_union_test.go | 99 + codegen/validation_test.go | 30 + .../2026-08-20-generated-package-ownership.md | 128 +- expr/root.go | 39 + expr/root_test.go | 47 + grpc/codegen/client.go | 8 +- grpc/codegen/example_cli_test.go | 8 +- grpc/codegen/parse_endpoint_test.go | 2 +- grpc/codegen/plan_test.go | 7 +- grpc/codegen/protobuf.go | 6 + grpc/codegen/protobuf_catalog.go | 10 + .../codegen/required_union_validation_test.go | 82 + grpc/codegen/server.go | 5 +- grpc/codegen/service_data.go | 36 +- .../codegen/templates/response_decoder.go.tpl | 2 +- grpc/codegen/templates/stream_recv.go.tpl | 2 +- grpc/codegen/templates/stream_send.go.tpl | 4 +- .../testdata/client-interceptors.golden | 4 +- grpc/codegen/testdata/client-no-server.golden | 2 +- ...nt-server-hosting-multiple-services.golden | 2 +- ...lient-server-hosting-service-subset.golden | 2 +- ...endpoint-endpoint-with-interceptors.golden | 4 +- ...ent_cli_payload-with-validations.go.golden | 2 +- grpc/codegen/testing.go | 10 +- grpc/codegen/types.go | 4 +- http/codegen/client.go | 34 +- .../codegen/openapi_disabled_examples_test.go | 7 +- http/codegen/plan_test.go | 14 +- http/codegen/server.go | 5 +- http/codegen/service_data.go | 15 +- http/codegen/sse.go | 22 +- http/codegen/sse_client.go | 30 +- .../codegen/templates/response_decoder.go.tpl | 2 +- http/codegen/templates/server_sse.go.tpl | 4 +- http/codegen/templates/websocket_recv.go.tpl | 2 +- http/codegen/templates/websocket_send.go.tpl | 4 +- ...client_cli_payload-map-user-type.go.golden | 2 +- ...yload_types_body-inline-map-user.go.golden | 2 +- ...irectional-streaming-complex-client.golden | 3 +- ...ket-bidirectional-streaming-complex.golden | 2 +- ...ectional-streaming-primitive-client.golden | 3 +- ...t-bidirectional-streaming-primitive.golden | 2 +- ...ctional-streaming-with-views-client.golden | 4 +- ...-bidirectional-streaming-with-views.golden | 2 +- .../websocket-client-streaming-array.golden | 3 +- .../websocket-client-streaming-object.golden | 3 +- ...ebsocket-client-streaming-primitive.golden | 3 +- ...ebsocket-client-streaming-user-type.golden | 3 +- ...et-client-streaming-with-validation.golden | 3 +- .../websocket-conn-configurer-client.golden | 3 +- .../websocket-conn-configurer.golden | 2 +- .../websocket-mixed-endpoints-client.golden | 3 +- .../websocket-mixed-endpoints.golden | 2 +- .../websocket-no-payload-streaming.golden | 2 +- .../websocket-no-result-streaming.golden | 2 +- .../websocket-server-streaming-array.golden | 2 +- .../websocket-server-streaming-object.golden | 2 +- ...ebsocket-server-streaming-primitive.golden | 2 +- ...ebsocket-server-streaming-user-type.golden | 2 +- ...bsocket-server-streaming-with-views.golden | 2 +- .../websocket-struct-types-client.golden | 3 +- .../websocket/websocket-struct-types.golden | 2 +- http/codegen/testing.go | 10 +- http/codegen/types.go | 10 +- http/codegen/websocket.go | 4 +- jsonrpc/codegen/client.go | 38 +- jsonrpc/codegen/kitchen_sink_test.go | 21 +- jsonrpc/codegen/plan_test.go | 14 +- jsonrpc/codegen/server.go | 15 +- .../codegen/templates/response_decoder.go.tpl | 2 +- .../golden/kitchen_sink/calc.go.golden | 2 +- .../golden/kitchen_sink/chat.go.golden | 2 +- .../cmd/kitchen_sink-cli/http.go.golden | 2 +- .../cmd/kitchen_sink-cli/jsonrpc.go.golden | 2 +- .../cmd/kitchen_sink/http.go.golden | 22 +- .../cmd/kitchen_sink/main.go.golden | 12 +- .../golden/kitchen_sink/feed.go.golden | 2 +- .../gen/http/cli/kitchen_sink/cli.go.golden | 4 +- .../gen/http/health/server/server.go.golden | 2 +- .../gen/http/mixed/client/cli.go.golden | 3 +- .../http/mixed/client/encode_decode.go.golden | 2 +- .../gen/http/mixed/client/types.go.golden | 3 +- .../http/mixed/server/encode_decode.go.golden | 2 +- .../gen/http/mixed/server/server.go.golden | 2 +- .../gen/http/mixed/server/types.go.golden | 3 +- .../gen/jsonrpc/calc/client/cli.go.golden | 3 +- .../calc/client/encode_decode.go.golden | 2 +- .../gen/jsonrpc/calc/client/types.go.golden | 3 +- .../calc/server/encode_decode.go.golden | 2 +- .../gen/jsonrpc/calc/server/server.go.golden | 2 +- .../gen/jsonrpc/calc/server/types.go.golden | 3 +- .../gen/jsonrpc/chat/client/cli.go.golden | 3 +- .../chat/client/encode_decode.go.golden | 2 +- .../gen/jsonrpc/chat/client/types.go.golden | 2 +- .../jsonrpc/chat/client/websocket.go.golden | 2 +- .../chat/server/encode_decode.go.golden | 2 +- .../gen/jsonrpc/chat/server/server.go.golden | 2 +- .../gen/jsonrpc/chat/server/types.go.golden | 2 +- .../jsonrpc/chat/server/websocket.go.golden | 2 +- .../jsonrpc/cli/kitchen_sink/cli.go.golden | 8 +- .../gen/jsonrpc/feed/client/cli.go.golden | 3 +- .../feed/client/encode_decode.go.golden | 2 +- .../gen/jsonrpc/feed/client/stream.go.golden | 2 +- .../gen/jsonrpc/feed/client/types.go.golden | 2 +- .../feed/server/encode_decode.go.golden | 2 +- .../gen/jsonrpc/feed/server/server.go.golden | 2 +- .../gen/jsonrpc/feed/server/sse.go.golden | 2 +- .../gen/jsonrpc/feed/server/types.go.golden | 3 +- .../gen/jsonrpc/mixed/client/cli.go.golden | 3 +- .../mixed/client/encode_decode.go.golden | 2 +- .../gen/jsonrpc/mixed/client/types.go.golden | 3 +- .../mixed/server/encode_decode.go.golden | 2 +- .../gen/jsonrpc/mixed/server/server.go.golden | 2 +- .../gen/jsonrpc/mixed/server/types.go.golden | 3 +- .../golden/kitchen_sink/health.go.golden | 2 +- .../golden/kitchen_sink/mixed.go.golden | 2 +- jsonrpc/codegen/testing.go | 10 +- 306 files changed, 14220 insertions(+), 4107 deletions(-) create mode 100644 codegen/generator/generate_grpc_required_union_validation_integration_test.go create mode 100644 codegen/generator/generated_package_import_test.go create mode 100644 codegen/generator/viewed_transport_import_integration_test.go create mode 100644 codegen/go_type_plan.go create mode 100644 codegen/go_type_plan_test.go create mode 100644 codegen/service/conversion_plan.go create mode 100644 codegen/service/conversion_plan_contract_test.go create mode 100644 codegen/service/generated_emission.go create mode 100644 codegen/service/interceptor_data.go create mode 100644 codegen/service/method_data.go create mode 100644 codegen/service/plan.go create mode 100644 codegen/service/plan_lifecycle.go create mode 100644 codegen/service/retained_expression_mutation_contract_test.go create mode 100644 codegen/service/retained_plan_test.go create mode 100644 codegen/service/security_data.go create mode 100644 codegen/service/service_declaration_condition_contract_test.go create mode 100644 codegen/service/service_fact_plan.go create mode 100644 codegen/service/service_link.go create mode 100644 codegen/service/service_name_collision_contract_test.go create mode 100644 codegen/service/service_names.go create mode 100644 codegen/service/service_names_test.go create mode 100644 codegen/service/service_plan_compile_contract_test.go create mode 100644 codegen/service/service_plan_render_contract_test.go create mode 100644 codegen/service/testdata/a-nested-alpha/unused.go create mode 100644 codegen/service/testdata/nested-alpha/alpha.go create mode 100644 codegen/service/testdata/nested-beta/beta.go create mode 100644 codegen/service/testdata/nested-outer/outer.go create mode 100644 codegen/service/transform_helper_operation_contract_test.go create mode 100644 codegen/service/type_map_identity_contract_test.go create mode 100644 codegen/service/type_plan.go create mode 100644 codegen/service/view_data.go create mode 100644 codegen/service/view_validation_plan.go create mode 100644 codegen/validation_plan.go create mode 100644 codegen/validation_plan_test.go create mode 100644 codegen/validation_protobuf_union_test.go create mode 100644 grpc/codegen/required_union_validation_test.go diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 9a6d6d43bf..4fcc4b7a4b 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -51,15 +51,21 @@ design. One run follows this order: 6. Each subsystem completes collection, sorts declarations by stable typed identity, and declares every package-level symbol in its actual output package. The generation then freezes package names and import qualifiers. -7. Core generators render their retained subsystem plans. Plugins render using +7. Link each retained subsystem plan once. Linking converts recorded design + facts and frozen declaration references into immutable template data. It + cannot discover a declaration, reserve a name or import, mutate an + expression, or create another analysis graph. +8. Core generators render their retained subsystem plans. Plugins render using the same `generator.Plan` and exact core service plans. -8. Merge contributions with the same canonical output path and render files. +9. Merge contributions with the same canonical output path and render files. Collection must be complete before freeze. Stable ordering makes preferred-name suffixes independent of map iteration, traversal order, plugin registration order, and process history. Freeze turns every declaration record into a -read-only value. Render performs no expression mutation, graph analysis, -declaration discovery, name allocation, or import allocation. +read-only value. Linking resolves those records exactly once before rendering; +it does not repeat collection or allocate another name. Render performs no +expression mutation, graph analysis, declaration discovery, name allocation, +or import allocation. ## Fresh run objects @@ -113,12 +119,23 @@ A plugin that needs core service declarations consumes `Plan.Service(root)`; it may not call service analysis again or rebuild an equivalent plan from the root. -Each subsystem has one retained plan constructor. The service contract is: +Each subsystem has one retained planning entry point. Service files can be +shared by several Goa roots in one generation, so service planning accepts the +complete root batch: ```go -func service.NewPlan(root *expr.RootExpr, generation *codegen.Generation) (*service.Plan, error) +func service.NewPlans(generation *codegen.Generation, inputs ...service.PlanInput) ([]*service.Plan, error) +func service.NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*service.Plan, error) ``` +`NewPlans` requires every service root owned by the generation exactly once. +It assigns relocated declaration files and external conversion methods across +the complete run before names freeze. Exact compiler copies with the same +retained Go layout share one declaration; copies that bind one declaration to +different fields, tags, pointer policies, union branches, or file facts are +rejected. `NewPlan` is only the strict single-root convenience form and rejects +a generation that contains more than one service root. + HTTP, gRPC, JSON-RPC, OpenAPI, and example generation use equivalent typed constructors. A transport plan receives the exact `*service.Plan` for its root. JSON-RPC may retain and reuse its HTTP plan because it emits HTTP codecs and @@ -126,8 +143,9 @@ wire files, but it does not rebuild HTTP analysis. Render functions accept the retained subsystem plan, not a `Generation`, generated module path, expression root, or reconstructed `ServicesData`. -The plan stores immutable render data and canonical declaration pointers. It -does not store callbacks that repeat analysis. `NewServicesData`, `Genfunc`, +The plan stores collected design facts, immutable linked render data, and +canonical declaration pointers. It does not store callbacks that repeat +analysis. `NewServicesData`, `Genfunc`, the replaceable `Generators` variable, `renderOnly`, and the callback plugin registry are transition mechanisms to delete. @@ -177,6 +195,12 @@ typed identity and allocated second. A subsystem must reject two distinct identities whose ordering facts are equal; pointer addresses, expression hashes, map order, and rendered text are not tie-breakers. +A companion whose spelling includes another declaration, such as +`Validate`, is registered as a dependent declaration before freeze. +The package freezes the base declaration first, then derives and reserves the +companion from that exact final name. Callers never rebuild the companion by +concatenating a separately resolved type string. + ### Imports and output paths Complete import path is the only import identity. Static-template requirements diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 3aec585a07..24815ad2e8 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -67,11 +67,12 @@ func TestExampleServerFiles(t *testing.T) { root := codegen.RunDSL(t, c.DSL) generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() fs := ServerFiles(root, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 8fd07d80fa..8ba20c2c16 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -299,6 +299,18 @@ func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { return nil } +// DeclareDependentName registers a compiler-owned companion whose preferred +// spelling is derived from base's final name. The base must already belong to +// p. Freeze resolves base first, then reserves prefix+base+suffix in the same +// package namespace. +func (p *GeneratedPackage) DeclareDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) (*NameDeclaration, error) { + declaration := newDependentName(kind, base, prefix, suffix, order) + if err := p.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + // DeclareUserType reserves userType's exact exported Go name and returns its // canonical package declaration. Repeated calls return the same declaration. func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { @@ -354,7 +366,7 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin return declaration, nil } order := newDerivedTypeOrder(identity, canonicalName) - nameDeclaration := NewPreferredName(NameType, canonicalName, order) + nameDeclaration := NewPreferredName(NameType, canonicalName, ExportedName, order) if err := p.DeclareName(nameDeclaration); err != nil { return nil, err } @@ -395,7 +407,7 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, e return planned.declaration, nil } - nameDeclaration := NewPreferredName(NameType, union.Name(), unionNameOrder{ + nameDeclaration := NewPreferredName(NameType, union.Name(), ExportedName, unionNameOrder{ union: identity, role: unionTypeNameRole, }) @@ -490,7 +502,7 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName return branch.branchType, nil } typeName := Goify(userType.Name(), true) - nameDeclaration := NewPreferredName(NameType, typeName, unionNameOrder{ + nameDeclaration := NewPreferredName(NameType, typeName, ExportedName, unionNameOrder{ union: NewUnionTypeID(union), role: unionBranchTypeNameRole, branch: branchName, diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index df8953d500..08cd7026cc 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -66,7 +66,7 @@ func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) types := mustClaimTestPackage(t, generation, "generated.local/gen/types") exact := NewExactName(NameType, "Build") - preferred := NewPreferredName(NameFunction, "Build", testNameOrder{value: "build"}) + preferred := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "build"}) require.NoError(t, types.DeclareName(exact)) require.NoError(t, types.DeclareName(exact)) @@ -89,6 +89,28 @@ func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { } } +// TestDependentNameUsesFrozenBase verifies that companion declarations derive +// their spelling from the exact final name selected for their base declaration. +func TestDependentNameUsesFrozenBase(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Result"))) + base := NewPreferredName(NameType, "Result", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + validator, err := pkg.DeclareDependentName( + NameFunction, + base, + "Validate", + "", + testNameOrder{value: "validator"}, + ) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + // TestNameDeclarationRejectsUnownedPackageAccess verifies that only a package // catalog can make internal declaration ownership available to typed records. func TestNameDeclarationRejectsUnownedPackageAccess(t *testing.T) { @@ -104,7 +126,7 @@ func TestNameDeclarationRejectsEmptyPreferredName(t *testing.T) { declaration *NameDeclaration }{ {"exact", NewExactName(NameType, "")}, - {"preferred", NewPreferredName(NameFunction, "", testNameOrder{value: "empty"})}, + {"preferred", NewPreferredName(NameFunction, "", ExportedName, testNameOrder{value: "empty"})}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -138,14 +160,14 @@ func TestNameDeclarationRejectsInvalidKind(t *testing.T) { { "preferred", func(*testing.T, *GeneratedPackage) *NameDeclaration { - return NewPreferredName(NameVariable+1, "Value", testNameOrder{value: "invalid"}) + return NewPreferredName(NameVariable+1, "Value", ExportedName, testNameOrder{value: "invalid"}) }, 0, }, { "dependent", func(t *testing.T, pkg *GeneratedPackage) *NameDeclaration { - base := NewPreferredName(NameType, "Value", testNameOrder{value: "base"}) + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) require.NoError(t, pkg.DeclareName(base)) return newDependentName(0, base, "New", "", testNameOrder{value: "dependent"}) }, @@ -172,8 +194,8 @@ func TestNameDeclarationPreferredOrder(t *testing.T) { declare := func(reverse bool) (string, string) { generation := mustTestGeneration(t, "generated.local/gen", nil) pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") - first := NewPreferredName(NameFunction, "Build", testNameOrder{value: "a"}) - second := NewPreferredName(NameConstant, "Build", testNameOrder{value: "b"}) + first := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "a"}) + second := NewPreferredName(NameConstant, "Build", ExportedName, testNameOrder{value: "b"}) declarations := []*NameDeclaration{first, second} if reverse { declarations[0], declarations[1] = declarations[1], declarations[0] @@ -197,24 +219,43 @@ func TestNameDeclarationPreferredOrder(t *testing.T) { require.NoError(t, pkg.DeclareName(NewPreferredName( NameFunction, "Build", + ExportedName, testNameOrder{value: "same"}, ))) err := pkg.DeclareName(NewPreferredName( NameVariable, "Build", + ExportedName, testNameOrder{value: "same"}, )) require.ErrorContains(t, err, "cannot deterministically order") } +// TestPreferredNameVisibility verifies preferred declarations preserve their +// requested package visibility while sharing deterministic collision handling. +func TestPreferredNameVisibility(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + exported := NewPreferredName(NameFunction, "build value", ExportedName, testNameOrder{value: "exported"}) + privateFirst := NewPreferredName(NameFunction, "Build Value", UnexportedName, testNameOrder{value: "private-a"}) + privateSecond := NewPreferredName(NameFunction, "build value", UnexportedName, testNameOrder{value: "private-b"}) + require.NoError(t, pkg.DeclareName(exported)) + require.NoError(t, pkg.DeclareName(privateSecond)) + require.NoError(t, pkg.DeclareName(privateFirst)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "BuildValue", exported.Name()) + require.Equal(t, "buildValue", privateFirst.Name()) + require.Equal(t, "buildValue2", privateSecond.Name()) +} + // TestNameDeclarationOrdersConcreteFamilies verifies that unrelated named // order types never receive each other's values and remain discovery-order independent. func TestNameDeclarationOrdersConcreteFamilies(t *testing.T) { declare := func(reverse bool) (string, string) { generation := mustTestGeneration(t, "generated.local/gen", nil) pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") - alpha := NewPreferredName(NameFunction, "Build", alphaTestNameOrder("same")) - omega := NewPreferredName(NameConstant, "Build", omegaTestNameOrder("same")) + alpha := NewPreferredName(NameFunction, "Build", ExportedName, alphaTestNameOrder("same")) + omega := NewPreferredName(NameConstant, "Build", ExportedName, omegaTestNameOrder("same")) declarations := []*NameDeclaration{alpha, omega} if reverse { declarations[0], declarations[1] = declarations[1], declarations[0] @@ -252,7 +293,7 @@ func TestNameDeclarationRejectsUnstableOrderTypes(t *testing.T) { t.Run(test.name, func(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") - declaration := NewPreferredName(NameFunction, "Build", test.order) + declaration := NewPreferredName(NameFunction, "Build", ExportedName, test.order) err := pkg.DeclareName(declaration) require.ErrorContains(t, err, "stable concrete named value type") }) @@ -264,7 +305,7 @@ func TestNameDeclarationRejectsUnstableOrderTypes(t *testing.T) { func TestNameDeclarationRejectsDependentOrderTie(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") - base := NewPreferredName(NameType, "Value", testNameOrder{value: "base"}) + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) first := newDependentName(NameFunction, base, "New", "First", testNameOrder{value: "same"}) second := newDependentName(NameFunction, base, "New", "Second", testNameOrder{value: "same"}) require.NoError(t, pkg.DeclareName(base)) @@ -279,7 +320,7 @@ func TestNameDeclarationRejectsInvalidDependentOwners(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) first := mustClaimTestPackage(t, generation, "generated.local/gen/first") second := mustClaimTestPackage(t, generation, "generated.local/gen/second") - unowned := NewPreferredName(NameType, "Value", testNameOrder{value: "unowned"}) + unowned := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "unowned"}) dependent := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "dependent"}) err := first.DeclareName(dependent) require.ErrorContains(t, err, "base declaration is not owned") @@ -372,6 +413,52 @@ func TestGeneratedOutputPathRejectsBackslashes(t *testing.T) { require.ErrorContains(t, err, "contains a backslash") } +// TestExplicitOutputPackageClaims verifies that packages outside GenPkg use +// the same canonical import and portable output ownership as ordinary claims. +func TestExplicitOutputPackageClaims(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + starter, err := generation.ClaimOutputPackage("generated.local", ".") + require.NoError(t, err) + reused, err := generation.ClaimOutputPackage("generated.local", "work/..") + require.NoError(t, err) + require.Same(t, starter, reused) + require.Equal(t, "generated.local", starter.ImportPath()) + require.Equal(t, ".", starter.OutputDirectory()) + require.Same(t, starter, generation.Package("generated.local")) + + _, err = generation.ClaimOutputPackage("generated.local", "starter") + require.ErrorContains(t, err, "already mapped") + _, err = generation.ClaimOutputPackage("generated.local/../generated.local", ".") + require.ErrorContains(t, err, "normalize to import path") + require.NoError(t, generation.Freeze()) + _, err = generation.ClaimOutputPackage("generated.local/late", "late") + require.ErrorContains(t, err, "after generation freeze") +} + +// TestExplicitOutputPackageRejectsInvalidDirectories verifies that explicit +// output packages cannot escape the generation working directory or rely on +// host-specific path separators. +func TestExplicitOutputPackageRejectsInvalidDirectories(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + for _, directory := range []string{"../starter", "/starter", `starter\service`} { + _, err := generation.ClaimOutputPackage("generated.local/starter", directory) + require.Error(t, err, directory) + } +} + +// TestExplicitOutputPackageSharesOrdinaryOwnership verifies that ordinary and +// explicit claims cannot assign one import path or portable directory twice. +func TestExplicitOutputPackageSharesOrdinaryOwnership(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + ordinary := mustClaimTestPackage(t, generation, "generated.local/gen/service") + reused, err := generation.ClaimOutputPackage("generated.local/gen/service", ordinary.OutputDirectory()) + require.NoError(t, err) + require.Same(t, ordinary, reused) + + _, err = generation.ClaimOutputPackage("generated.local/other", "gen/SERVICE") + require.ErrorContains(t, err, "case-insensitive filesystem") +} + // TestGenerationRejectsImplicitLocalRoots verifies that only the exact local // output sentinels are accepted as non-module generation roots. func TestGenerationRejectsImplicitLocalRoots(t *testing.T) { diff --git a/codegen/generation.go b/codegen/generation.go index e2e473007c..c72bae64ca 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -81,37 +81,71 @@ func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { if g.frozen { return nil, fmt.Errorf("generated package %q cannot be claimed after generation freeze", path) } - if generatedPackage, ok := g.packages[path]; ok { - return generatedPackage, nil - } canonicalPath, err := canonicalGeneratedPackagePath(g.genpkg, path) if err != nil { return nil, err } + outputDir, err := generatedOutputDirectory(g.genpkg, canonicalPath) + if err != nil { + return nil, err + } + return g.claimOutputPackage(path, canonicalPath, outputDir) +} + +// ClaimOutputPackage claims a Go package emitted at an explicit directory +// relative to the code generation working directory. It is used for generated +// files such as starter implementations that intentionally live outside the +// generated module import root while sharing the same declaration lifecycle. +func (g *Generation) ClaimOutputPackage(importPath, outputDirectory string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("output package %q cannot be claimed after generation freeze", importPath) + } + canonicalPath, err := canonicalOutputPackagePath(importPath) + if err != nil { + return nil, err + } + canonicalDirectory, err := canonicalOutputDirectory(outputDirectory) + if err != nil { + return nil, err + } + return g.claimOutputPackage(importPath, canonicalPath, canonicalDirectory) +} + +// claimOutputPackage installs one package after its import path and output +// directory have been validated by the public operation that owns their +// relationship. +func (g *Generation) claimOutputPackage(claim, canonicalPath, outputDir string) (*GeneratedPackage, error) { + if generatedPackage, ok := g.packages[claim]; ok { + if generatedPackage.outputDir != outputDir { + return nil, fmt.Errorf( + "generated package %q is already mapped to output directory %q, not %q", + claim, + generatedPackage.outputDir, + outputDir, + ) + } + return generatedPackage, nil + } if owner, ok := g.importOwners[canonicalPath]; ok { return nil, fmt.Errorf( "generated package paths %q and %q normalize to import path %q", owner.claim, - path, + claim, canonicalPath, ) } - outputDir, err := generatedOutputDirectory(g.genpkg, canonicalPath) - if err != nil { - return nil, err - } for existingDir, owner := range g.outputOwners { if strings.EqualFold(existingDir, outputDir) { return nil, fmt.Errorf( "generated package paths %q and %q resolve to output directory %q on a case-insensitive filesystem", owner.claim, - path, + claim, outputDir, ) } } - generatedPackage := newGeneratedPackage(path, canonicalPath, outputDir) - g.packages[path] = generatedPackage + generatedPackage := newGeneratedPackage(claim, canonicalPath, outputDir) + g.packages[claim] = generatedPackage g.importOwners[canonicalPath] = generatedPackage g.outputOwners[outputDir] = generatedPackage return generatedPackage, nil @@ -151,6 +185,12 @@ func (g *Generation) Freeze() error { return nil } +// Frozen reports whether declaration and import collection has closed and all +// canonical names are available for linking retained subsystem plans. +func (g *Generation) Frozen() bool { + return g.frozen +} + // ImportPath returns the canonical Go import path owned by the package. func (p *GeneratedPackage) ImportPath() string { return p.path @@ -203,6 +243,35 @@ func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { return canonical, nil } +// canonicalOutputPackagePath validates the import identity of an explicitly +// located generated output package without requiring it to be below GenPkg. +func canonicalOutputPackagePath(importPath string) (string, error) { + canonical, err := cleanImportPath("output package path", importPath) + if err != nil { + return "", err + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("output package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// canonicalOutputDirectory accepts one relative output location and rejects +// spellings that could escape or vary across host path conventions. +func canonicalOutputDirectory(outputDirectory string) (string, error) { + if strings.Contains(outputDirectory, "\\") { + return "", fmt.Errorf("output directory %q contains a backslash", outputDirectory) + } + if filepath.IsAbs(outputDirectory) { + return "", fmt.Errorf("output directory %q must be relative", outputDirectory) + } + canonical := filepath.Clean(filepath.FromSlash(outputDirectory)) + if canonical == ".." || strings.HasPrefix(canonical, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("output directory %q escapes the generation working directory", outputDirectory) + } + return canonical, nil +} + // cleanImportPath rejects filesystem separators in Go import identities and // preserves the raw spelling for diagnostics before cleaning dot segments. func cleanImportPath(label, importPath string) (string, error) { diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 3ce1f67012..7e01b63a06 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -18,17 +18,15 @@ func exampleFiles(plan *Plan) ([]*codegen.File, error) { generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { - services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) - if err != nil { - return nil, err - } + servicePlan := plan.Service(r) + services := servicePlan.Services() // example service implementation - if fs := service.ExampleServiceFiles(generation.GenPkg(), r, services); len(fs) != 0 { + if fs := service.ExampleServiceFiles(servicePlan); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(generation.GenPkg(), r, services); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(servicePlan); len(fs) != 0 { files = append(files, fs...) } diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 22d356b48c..f416e4ef98 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -5,8 +5,11 @@ package generator import ( + "errors" "fmt" + "io/fs" "os" + "os/exec" "path" "path/filepath" "runtime" @@ -17,6 +20,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" + "golang.org/x/mod/module" "golang.org/x/tools/go/packages" ) @@ -83,19 +87,10 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string } startPkgLoad := time.Now() - pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName}, path) + genpkg, err = generatedPackageImportPath(path) if err != nil { return nil, err } - // In temporary workspaces (e.g., tests) and on Windows, PkgPath may resolve - // to an absolute filesystem path which is not a valid Go import path and - // would produce invalid imports (e.g., backslashes). Fall back to the - // relative generated package import path in that case. - if filepath.IsAbs(pkgs[0].PkgPath) { - genpkg = codegen.Gendir - } else { - genpkg = pkgs[0].PkgPath - } if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] packages.Load took %v\n", time.Since(startPkgLoad)) fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 2: Compute gen package import path took %v\n", time.Since(start)) @@ -241,6 +236,101 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string return outputs, nil } +// generatedPackageImportPath asks Go to identify the package in dir and +// returns the exact canonical path that generated files can import. +func generatedPackageImportPath(dir string) (string, error) { + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: dir, + }, ".") + if err != nil { + return "", fmt.Errorf("load generated Go package in %q: %w", dir, err) + } + if len(pkgs) != 1 { + return "", fmt.Errorf("load generated Go package in %q: expected exactly one package, got %d", dir, len(pkgs)) + } + pkg := pkgs[0] + if len(pkg.Errors) != 0 { + packageErrors := make([]error, len(pkg.Errors)) + for i, packageError := range pkg.Errors { + packageErrors[i] = packageError + } + return "", fmt.Errorf("load generated Go package in %q: %w", dir, errors.Join(packageErrors...)) + } + importPath := pkg.PkgPath + if pkg.Module == nil && strings.HasPrefix(importPath, "_/") { + owned, err := gopathOwnsImportPath(pkg.Dir, importPath) + if err != nil { + return "", err + } + if !owned { + return "", fmt.Errorf("generated Go package in %q has synthetic import path %q", dir, importPath) + } + } + if path.Clean(importPath) != importPath { + return "", fmt.Errorf("generated Go package in %q has noncanonical import path %q", dir, importPath) + } + if err := module.CheckImportPath(importPath); err != nil { + return "", fmt.Errorf("generated Go package in %q has invalid import path %q: %w", dir, importPath, err) + } + return importPath, nil +} + +// gopathOwnsImportPath asks the Go command for its effective GOPATH and reports +// whether dir has the exact import identity it claims beneath a source root. +func gopathOwnsImportPath(dir, importPath string) (bool, error) { + output, err := exec.Command("go", "env", "GOPATH").Output() + if err != nil { + return false, fmt.Errorf("read effective GOPATH: %w", err) + } + gopath := string(output) + if strings.HasSuffix(gopath, "\r\n") { + gopath = strings.TrimSuffix(gopath, "\r\n") + } else { + gopath = strings.TrimSuffix(gopath, "\n") + } + roots := filepath.SplitList(gopath) + for _, root := range roots { + if gopathSourceOwnsImportPath(filepath.Join(root, "src"), dir, importPath) { + return true, nil + } + } + + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return false, fmt.Errorf("resolve generated Go package directory %q: %w", dir, err) + } + var resolutionErrors []error + for _, root := range roots { + packagePath := filepath.Join(root, "src", filepath.FromSlash(importPath)) + resolvedPackagePath, err := filepath.EvalSymlinks(packagePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + resolutionErrors = append(resolutionErrors, fmt.Errorf("resolve %q: %w", packagePath, err)) + continue + } + if resolvedPackagePath == resolvedDir { + return true, nil + } + } + if len(resolutionErrors) != 0 { + return false, fmt.Errorf("resolve GOPATH package path for %q: %w", importPath, errors.Join(resolutionErrors...)) + } + return false, nil +} + +// gopathSourceOwnsImportPath reports whether dir is lexically beneath source +// with the exact slash-separated relative import path. +func gopathSourceOwnsImportPath(source, dir, importPath string) bool { + relative, err := filepath.Rel(source, dir) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return false + } + return filepath.ToSlash(relative) == importPath +} + // mergeFilesByPath coalesces files that share the same output path by // concatenating their non-header sections and merging header imports. This // prevents later renders from truncating earlier content when multiple diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go index aea52f90c4..a6ca6b3e96 100644 --- a/codegen/generator/generate_grpc_metadata_integration_test.go +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -65,7 +65,7 @@ func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("generate gRPC metadata module: %v", err) } @@ -87,10 +87,10 @@ import ( "context" "testing" - genclient "gen/grpc/metadata/client" - genserver "gen/grpc/metadata/server" - genmetadata "gen/metadata" - gentypes "gen/shared/types" + genclient "generated.local/gen/grpc/metadata/client" + genserver "generated.local/gen/grpc/metadata/server" + genmetadata "generated.local/gen/metadata" + gentypes "generated.local/gen/shared/types" "google.golang.org/grpc/metadata" ) diff --git a/codegen/generator/generate_grpc_required_union_validation_integration_test.go b/codegen/generator/generate_grpc_required_union_validation_integration_test.go new file mode 100644 index 0000000000..586e2aae17 --- /dev/null +++ b/codegen/generator/generate_grpc_required_union_validation_integration_test.go @@ -0,0 +1,180 @@ +// This file verifies generated gRPC client and server validators reject +// incomplete required OneOf branches while accepting every complete branch. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +func TestGenerateGRPCRequiredUnionValidators(t *testing.T) { + root := codegen.RunDSL(t, requiredGRPCUnionValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCRequiredUnionValidationTest(t, dir) + runGeneratedTests(t, dir) +} + +// requiredGRPCUnionValidationDSL gives request and response unions the same +// branch contract so generation must enforce it in both transport validators. +func requiredGRPCUnionValidationDSL() { + d.API("required-union", func() {}) + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + d.Service("validation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCRequiredUnionValidationTest adds a consumer test that invokes the +// public validators generated into the server and client packages. +func writeGRPCRequiredUnionValidationTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "uniontest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create required union validation package: %v", err) + } + const source = `package uniontest_test + +import ( + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/validation/client" + genpb "generated.local/gen/grpc/validation/pb" + genserver "generated.local/gen/grpc/validation/server" +) + +func TestServerRequestValidator(t *testing.T) { + valid := []*genpb.ExchangeRequest{ + {Choice: &genpb.ExchangeRequest_Number{Number: 1}}, + {Choice: &genpb.ExchangeRequest_Detail{Detail: &genpb.Detail{Label: "ready"}}}, + {Choice: &genpb.ExchangeRequest_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeRequest_Blob{Blob: nil}}, + {Choice: &genpb.ExchangeRequest_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genserver.ValidateExchangeRequest(message); err != nil { + t.Errorf("valid request branch failed: %v", err) + } + } + + var nilNumber *genpb.ExchangeRequest_Number + assertErrorName(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: nilNumber}), "number", "\"number\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") +} + +func TestClientResponseValidator(t *testing.T) { + valid := []*genpb.ExchangeResponse{ + {Choice: &genpb.ExchangeResponse_Number{Number: 1}}, + {Choice: &genpb.ExchangeResponse_Detail{Detail: &genpb.Detail{Label: "ready"}}}, + {Choice: &genpb.ExchangeResponse_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeResponse_Blob{Blob: []byte{}}}, + {Choice: &genpb.ExchangeResponse_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genclient.ValidateExchangeResponse(message); err != nil { + t.Errorf("valid response branch failed: %v", err) + } + } + + var nilDetail *genpb.ExchangeResponse_Detail + assertErrorName(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: nilDetail}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") +} + +func assertErrorName(t *testing.T, err error, name string) { + t.Helper() + if err == nil { + t.Errorf("expected %q error", name) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != name { + t.Errorf("expected %q, got %q", name, serviceError.Name) + } +} + +func assertMissingField(t *testing.T, err error, field, message string) { + t.Helper() + if err == nil { + t.Errorf("expected missing field %q", field) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != goa.MissingField { + t.Errorf("expected %q, got %q", goa.MissingField, serviceError.Name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("expected field %q, got %#v", field, serviceError.Field) + } + if serviceError.Message != message { + t.Errorf("expected message %q, got %q", message, serviceError.Message) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "required_union_validation_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write required union validation test: %v", err) + } +} diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index d0b8e60121..bfa44284d7 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -64,7 +64,7 @@ func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { _ = codegen.RunDSL(t, dsl) dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index b57b2925eb..d8967a7fbe 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -43,6 +43,7 @@ func TestMergeFilesPreservesSameLabelSections(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "types", "same_label.go")) @@ -243,6 +244,7 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") _, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) @@ -294,6 +296,7 @@ func TestGenerateParallelManyFiles(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) @@ -356,6 +359,7 @@ func TestGenerateParallelWithMerge(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) @@ -425,6 +429,7 @@ func TestGenerateParallelErrorHandling(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") _, err := generate(dir, "gen", false, registry) if err == nil { t.Fatal("expected error from parallel generation, got nil") @@ -452,6 +457,7 @@ func TestGenerateParallelSingleFile(t *testing.T) { }) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index eb85853983..07ac0dfeb9 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -61,6 +61,7 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { _ = cg.RunDSL(t, dsl) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, cg.Gendir), "generated.local/gen") if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generated_package_import_test.go b/codegen/generator/generated_package_import_test.go new file mode 100644 index 0000000000..408722acfc --- /dev/null +++ b/codegen/generator/generated_package_import_test.go @@ -0,0 +1,347 @@ +// This file verifies that generation accepts only importable package identities +// returned by Go's package loader and never invents an import path. +package generator + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +func TestGeneratedPackageImportPath(t *testing.T) { + t.Run("module package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "generated.local/gen", got) + }) + + t.Run("authored underscore module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module _/authored\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/authored/gen", got) + }) + + t.Run("workspace module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + workspaceDir := t.TempDir() + otherDir := filepath.Join(workspaceDir, "other") + targetDir := filepath.Join(workspaceDir, "target") + require.NoError(t, os.MkdirAll(otherDir, 0o750)) + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + writePackageFixture(t, otherDir, "module workspace.local/other\n\ngo 1.25\n") + writePackageFixture(t, targetDir, "module _/target\n\ngo 1.25\n") + workFile := filepath.Join(workspaceDir, "go.work") + require.NoError(t, os.WriteFile(workFile, []byte("go 1.25\n\nuse (\n\t./other\n\t./target\n)\n"), 0o600)) + t.Setenv("GOWORK", workFile) + + got, err := generatedPackageImportPath(filepath.Join(targetDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/target/gen", got) + }) + + t.Run("GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "foo") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package foo\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.Equal(t, "_/foo", pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/foo", got) + }) + + t.Run("GOPATH package reached through symlink", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + realPackageDir := filepath.Join(gopath, "src", "_", "linked") + require.NoError(t, os.MkdirAll(realPackageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(realPackageDir, "generated.go"), []byte("package linked\n"), 0o600)) + linkedGOPATH := filepath.Join(t.TempDir(), "linked-gopath") + require.NoError(t, os.Symlink(gopath, linkedGOPATH)) + linkedPackageDir := filepath.Join(linkedGOPATH, "src", "_", "linked") + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: linkedPackageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, realPackageDir, pkgs[0].Dir) + require.Equal(t, "_/linked", pkgs[0].PkgPath) + + owned, err := gopathOwnsImportPath(linkedPackageDir, "_/linked") + require.NoError(t, err) + require.True(t, owned) + + got, err := generatedPackageImportPath(linkedPackageDir) + require.NoError(t, err) + require.Equal(t, "_/linked", got) + }) + + t.Run("missing GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "missing") + importPath := "_/missing" + lexicalDir := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(lexicalDir, importPath) + require.NoError(t, err) + require.True(t, owned) + + owned, err = gopathOwnsImportPath(t.TempDir(), importPath) + require.NoError(t, err) + require.False(t, owned) + }) + + t.Run("GOPATH symlink resolution error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + importPath := "_/loop" + packagePath := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(packagePath), 0o750)) + require.NoError(t, os.Symlink(packagePath, packagePath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(t.TempDir(), importPath) + require.Error(t, err) + require.False(t, owned) + require.ErrorContains(t, err, "resolve GOPATH package path") + }) + + t.Run("non-first GOPATH package", func(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + packageDir := filepath.Join(second, "src", "_", "second") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package second\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/second", got) + }) + + t.Run("GOPATH ending in space", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot portably end in a space") + } + gopath := filepath.Join(t.TempDir(), "gopath ") + packageDir := filepath.Join(gopath, "src", "_", "space") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package space\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/space", got) + }) + + t.Run("GOENV GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "goenv") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package goenv\n"), 0o600)) + goenvFile := filepath.Join(t.TempDir(), "go.env") + require.NoError(t, os.WriteFile(goenvFile, []byte("GOPATH="+gopath+"\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", goenvFile) + t.Setenv("GOPACKAGESDRIVER", "off") + unsetTestEnv(t, "GOPATH") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/goenv", got) + }) + + t.Run("empty GOPATH uses default", func(t *testing.T) { + home := t.TempDir() + gopath := filepath.Join(home, "go") + packageDir := filepath.Join(gopath, "src", "_", "default") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package defaultpkg\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", "") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/default", got) + }) + + t.Run("synthetic GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "gopath") + require.NoError(t, os.MkdirAll(gopath, 0o750)) + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.True(t, strings.HasPrefix(pkgs[0].PkgPath, "_/"), pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("synthetic package with missing GOPATH roots", func(t *testing.T) { + first := filepath.Join(t.TempDir(), "missing-first") + second := filepath.Join(t.TempDir(), "missing-second") + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("invalid package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.WriteFile(filepath.Join(genDir, "other.go"), []byte("package other\n"), 0o600)) + + got, err := generatedPackageImportPath(genDir) + require.Error(t, err) + require.Empty(t, got) + var packageError packages.Error + require.ErrorAs(t, err, &packageError) + }) + + t.Run("invalid module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module invalid path\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "errors parsing") + }) +} + +// unsetTestEnv removes key for one subtest and restores its exact process +// state after the loader has observed the missing variable. +func unsetTestEnv(t *testing.T, key string) { + t.Helper() + value, exists := os.LookupEnv(key) + require.NoError(t, os.Unsetenv(key)) + t.Cleanup(func() { + if exists { + require.NoError(t, os.Setenv(key, value)) + return + } + require.NoError(t, os.Unsetenv(key)) + }) +} + +// writePackageFixture creates the module and generated package consumed by the +// real package loader in each test case. +func writePackageFixture(t *testing.T, moduleDir, moduleFile string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(moduleFile), 0o600)) + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.MkdirAll(genDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(genDir, "generated.go"), []byte("package gen\n"), 0o600)) +} diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go index 246fe90617..97a55dd730 100644 --- a/codegen/generator/generated_transport_alias_integration_test.go +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -39,15 +39,13 @@ func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing. } }) - generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, planTransportData(generation)) - require.NoError(t, generation.Freeze()) - files, err := testServiceFiles(generation) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) require.NoError(t, err) - transport, err := testTransportFiles(generation) + transport, err := testTransportFiles(plan) require.NoError(t, err) files = append(files, transport...) - exampleFiles, err := assembleExampleFilesForTest(generation) + exampleFiles, err := assembleExampleFilesForTest(plan) require.NoError(t, err) files = append(files, exampleFiles...) diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index f524a63cd8..bf97b02f3e 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -4,6 +4,7 @@ package generator import ( "fmt" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -130,6 +131,7 @@ func TestPreparedRootsRejectFileRenderMutation(t *testing.T) { t.Run(phase, func(t *testing.T) { root := codegen.RunDSL(t, httpdata.AliasTypeDSL) dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") mutate := func() { root.API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" } diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 5ea2a3547a..54f140b790 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -27,7 +27,7 @@ func genGeneratorFactories() []generatorFactory { return coreGenerator{ name: "service", Plan: func(plan *Plan) error { - return planServiceData(plan.Generation()) + return planServiceData(plan) }, Generate: func(plan *Plan) ([]*codegen.File, error) { return serviceFiles(plan) @@ -38,7 +38,7 @@ func genGeneratorFactories() []generatorFactory { return coreGenerator{ name: "transport", Plan: func(plan *Plan) error { - return planTransportData(plan.Generation()) + return planTransportData(plan) }, Generate: func(plan *Plan) ([]*codegen.File, error) { return transportFiles(plan) @@ -49,7 +49,7 @@ func genGeneratorFactories() []generatorFactory { return coreGenerator{ name: "openapi", Plan: func(plan *Plan) error { - return planServiceData(plan.Generation()) + return planServiceData(plan) }, Generate: func(plan *Plan) ([]*codegen.File, error) { return openAPIFiles(plan) @@ -66,7 +66,7 @@ func exampleGeneratorFactories() []generatorFactory { return coreGenerator{ name: "example", Plan: func(plan *Plan) error { - return planTransportData(plan.Generation()) + return planTransportData(plan) }, Generate: func(plan *Plan) ([]*codegen.File, error) { return exampleFiles(plan) diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go index 8d49987e0b..bf4d41c57a 100644 --- a/codegen/generator/lifecycle.go +++ b/codegen/generator/lifecycle.go @@ -116,6 +116,13 @@ func (r *generationRun) execute(genpkg string, roots []eval.Root) (*generationRe if freezeErr != nil { return nil, freezeErr } + linkErr := plan.link() + if err := plan.verifyPreparedDesign("plan linking"); err != nil { + return nil, err + } + if linkErr != nil { + return nil, linkErr + } var files []*codegen.File for _, core := range r.cores { diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 7a0c5e1584..106cfe5306 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -4,7 +4,6 @@ package generator import ( "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/service" httpcodegen "goa.design/goa/v3/http/codegen" ) @@ -14,9 +13,7 @@ func openAPIFiles(plan *Plan) ([]*codegen.File, error) { generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, root := range designRoots { - if _, err := service.NewServicesData(root, generation, plan.exampleGenerator(root)); err != nil { - return nil, err - } + plan.Service(root).Services() } if len(designRoots) > 0 { root := designRoots[0] diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go index 250e09a707..0860ecb978 100644 --- a/codegen/generator/plan.go +++ b/codegen/generator/plan.go @@ -7,6 +7,7 @@ import ( "fmt" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -19,6 +20,7 @@ type ( generation *codegen.Generation preparedRoots []eval.Root examples map[*expr.RootExpr]*expr.ExampleGenerator + services map[*expr.RootExpr]*service.Plan design *designSnapshot } ) @@ -28,6 +30,17 @@ func (p *Plan) Generation() *codegen.Generation { return p.generation } +// Service returns the retained service plan collected for root. It panics for +// an unplanned root because plugins and transports must consume the exact core +// analysis rather than reconstructing one. +func (p *Plan) Service(root *expr.RootExpr) *service.Plan { + plan, ok := p.services[root] + if !ok { + panic(fmt.Sprintf("service plan requested for unplanned design root %q", root.API.Name)) + } + return plan +} + // exampleGenerator returns the mutable example state created for root in this // run. A root outside the prepared plan is an orchestration bug. func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { @@ -38,6 +51,21 @@ func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { return generator } +// link resolves every collected subsystem plan through the frozen generation +// before any core or plugin renderer receives it. +func (p *Plan) link() error { + for _, root := range serviceRoots(p.preparedRoots) { + plan, ok := p.services[root] + if !ok { + continue + } + if err := plan.Link(); err != nil { + return err + } + } + return nil +} + // verifyPreparedDesign rejects the first expression change made after // preparation and identifies the callback or render operation that made it. func (p *Plan) verifyPreparedDesign(operation string) error { diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index 2e59089d1f..c6d0156d04 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -42,7 +42,7 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { root := expr.RunDSL(t, c.DSL) for _, cmd := range []string{"gen", "example"} { - _, err := executeGeneration("gen", []eval.Root{root}, cmd, newDefaultRegistry()) + _, err := executeGeneration("generated.local/gen", []eval.Root{root}, cmd, newDefaultRegistry()) require.NoError(t, err) } }) diff --git a/codegen/generator/registry_test.go b/codegen/generator/registry_test.go index 3bd7194e54..e4d02a394b 100644 --- a/codegen/generator/registry_test.go +++ b/codegen/generator/registry_test.go @@ -9,13 +9,13 @@ import ( ) type ( - // testGenfunc retains the previous fixture shape while adapting callbacks - // into fresh core generator objects. + // testGenfunc describes one retained planner and renderer used by an + // isolated generator command. testGenfunc struct { - // Plan declares package symbols through the fixture's Generation seam. - Plan func(*codegen.Generation) error - // Generate renders fixture files through the same Generation seam. - Generate func(*codegen.Generation) ([]*codegen.File, error) + // Plan declares package symbols and retains the analysis used by Generate. + Plan func(*Plan) error + // Generate renders fixture files from the linked plan. + Generate func(*Plan) ([]*codegen.File, error) } ) @@ -37,26 +37,20 @@ func testRegistryFromGenfuncs(callbacks []testGenfunc) *registry { // testRenderOnly adapts a root-based rendering fixture into a test callback. func testRenderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) testGenfunc { - return testGenfunc{Generate: func(generation *codegen.Generation) ([]*codegen.File, error) { + return testGenfunc{Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() return generate(generation.GenPkg(), generation.Roots()) }} } -// testGenerator adapts the current Generation-based core callback functions to -// a fresh run factory. Retained subsystem plans replace this adapter in Tasks 7–10. -func testGenerator(plan func(*codegen.Generation) error, generate func(*codegen.Generation) ([]*codegen.File, error)) generatorFactory { +// testGenerator returns a fresh core generator that receives one retained plan +// from declaration collection through rendering. +func testGenerator(plan func(*Plan) error, generate func(*Plan) ([]*codegen.File, error)) generatorFactory { return func() coreGenerator { - generator := coreGenerator{name: "test"} - if plan != nil { - generator.Plan = func(retained *Plan) error { - return plan(retained.Generation()) - } + return coreGenerator{ + name: "test", + Plan: plan, + Generate: generate, } - if generate != nil { - generator.Generate = func(retained *Plan) ([]*codegen.File, error) { - return generate(retained.Generation()) - } - } - return generator } } diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 0c037e5201..0de2938a5b 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -12,45 +12,32 @@ import ( // serviceFiles returns the service files described by plan's frozen package // declarations and run-owned example state. func serviceFiles(plan *Plan) ([]*codegen.File, error) { - var files []*codegen.File - generation := plan.Generation() - designRoots := serviceRoots(generation.Roots()) - analyses := make([]*service.ServicesData, len(designRoots)) - for i, r := range designRoots { - services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) - if err != nil { - return nil, err - } - analyses[i] = services - - for _, s := range r.Services { - endpointFiles := []*codegen.File{ - service.EndpointFile(generation.GenPkg(), s, services), - service.ClientFile(generation.GenPkg(), s, services), - } - files = append(files, endpointFiles...) - - if f := service.ViewsFile(generation.GenPkg(), s, services); f != nil { - files = append(files, f) - } - convFiles, err := service.ConvertFiles(r, s, services) - if err != nil { - return nil, err - } - files = append(files, convFiles...) - } + designRoots := serviceRoots(plan.Generation().Roots()) + plans := make([]*service.Plan, len(designRoots)) + for index, root := range designRoots { + plans[index] = plan.Service(root) } - svcFiles := service.Files(generation.GenPkg(), analyses) - return append(svcFiles, files...), nil + return service.Files(plans...) } // planServiceData declares service-owned generated package types for every Goa // design root in generation. -func planServiceData(generation *codegen.Generation) error { - for _, root := range serviceRoots(generation.Roots()) { - if err := service.Plan(root, generation); err != nil { - return err - } +func planServiceData(plan *Plan) error { + if plan.services != nil { + return nil + } + plan.services = make(map[*expr.RootExpr]*service.Plan) + roots := serviceRoots(plan.Generation().Roots()) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = service.PlanInput{Root: root, Examples: plan.exampleGenerator(root)} + } + servicePlans, err := service.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + for index, root := range roots { + plan.services[root] = servicePlans[index] } return nil } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index 47e6559c27..bb82e6d4ba 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -89,7 +89,7 @@ func TestRelocatedUnionPackageNamesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, path := range []string{ @@ -133,7 +133,7 @@ func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) @@ -151,7 +151,7 @@ func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { outer := dsl.Type("Outer", func() { dsl.Meta("struct:pkg:path", "domain/outer") dsl.Field(1, "value", dsl.String, func() { - dsl.Meta("struct:field:type", "custom.Value", "gen/custom/value", "custom") + dsl.Meta("struct:field:type", "custom.Value", "generated.local/gen/custom/value", "custom") }) }) dsl.Service("Values", func() { @@ -172,7 +172,7 @@ func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "value"), "custom") @@ -220,7 +220,7 @@ func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) @@ -255,7 +255,7 @@ func TestInheritedTransportErrorsOwnImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) @@ -289,7 +289,7 @@ func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) unionSource, err := os.ReadFile(filepath.Join(genDir, "types", "unions.go")) @@ -323,7 +323,7 @@ func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) @@ -371,7 +371,7 @@ func TestServiceFilesOwnTheirImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) runGeneratedTests(t, genDir) @@ -422,7 +422,7 @@ func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) @@ -465,14 +465,14 @@ func TestServiceReferencesUseImportPathAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) require.NoError(t, err) code := string(content) - require.Contains(t, code, `shared "gen/first/shared"`) - require.Contains(t, code, `shared2 "gen/second/shared"`) + require.Contains(t, code, `shared "generated.local/gen/first/shared"`) + require.Contains(t, code, `shared2 "generated.local/gen/second/shared"`) require.Contains(t, code, `*shared.First`) require.Contains(t, code, `*shared2.Second`) runGeneratedTests(t, genDir) @@ -539,13 +539,13 @@ func TestTransportReferencesUseImportPathAliases(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, transport := range []string{"http", "grpc", "jsonrpc"} { source := generatedTreeSource(t, filepath.Join(genDir, transport, "values")) - require.Contains(t, source, `shared "gen/first/shared"`) - require.Contains(t, source, `shared2 "gen/second/shared"`) + require.Contains(t, source, `shared "generated.local/gen/first/shared"`) + require.Contains(t, source, `shared2 "generated.local/gen/second/shared"`) require.Contains(t, source, "shared.First") require.Contains(t, source, "shared2.Second") } @@ -587,7 +587,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { value := dsl.Type("Value", func() { dsl.OneOf("choice", func() { dsl.Attribute("external", dsl.String, func() { - dsl.Meta("struct:field:type", "json.Value", "gen/custom/json", "json") + dsl.Meta("struct:field:type", "json.Value", "generated.local/gen/custom/json", "json") }) }) }) @@ -600,7 +600,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "json"), "json") @@ -608,7 +608,7 @@ func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { require.NoError(t, err) code := string(content) require.Contains(t, code, `"encoding/json"`) - require.NotContains(t, code, `"gen/custom/json"`) + require.NotContains(t, code, `"generated.local/gen/custom/json"`) runGeneratedTests(t, genDir) } @@ -651,7 +651,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) @@ -695,7 +695,7 @@ func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) @@ -718,13 +718,13 @@ func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { outer := dsl.Type("Outer", func() { dsl.Meta("struct:pkg:path", "models") dsl.Attribute("value", dsl.String, func() { - dsl.Meta("struct:field:type", "shared.Value", "gen/custom/first/shared", "shared") + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/first/shared", "shared") }) }) inner := dsl.Type("Inner", func() { dsl.Meta("struct:pkg:path", "models") dsl.Attribute("value", dsl.String, func() { - dsl.Meta("struct:field:type", "shared.Value", "gen/custom/second/shared", "shared") + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/second/shared", "shared") }) }) dsl.Service("Nested", func() { @@ -739,7 +739,7 @@ func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) writeStubPackage(t, filepath.Join(genDir, "custom", "first", "shared"), "shared") @@ -778,10 +778,8 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { }) }) }) - generation := mustTestGeneration(t, "gen", []eval.Root{root}) - require.NoError(t, planTransportData(generation)) - require.NoError(t, generation.Freeze()) - files, err := testTransportFiles(generation) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testTransportFiles(plan) require.NoError(t, err) var header strings.Builder @@ -793,8 +791,8 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { break } require.NotEmpty(t, header.String()) - require.Contains(t, header.String(), `"gen/stream/shared"`) - require.NotContains(t, header.String(), `"gen/request/shared"`) + require.Contains(t, header.String(), `"generated.local/gen/stream/shared"`) + require.NotContains(t, header.String(), `"generated.local/gen/request/shared"`) } // TestRelocatedStreamingUnionReferencesCompile verifies WebSocket and SSE @@ -847,7 +845,7 @@ func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) for _, path := range []string{ @@ -894,10 +892,8 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { codegen.RunDSL(t, relocatedDifferentUnionRoot()), codegen.RunDSL(t, relocatedTopLevelValueRoot()), } - generation := mustTestGeneration(t, "goa.design/goa/example", roots) - require.NoError(t, planServiceData(generation)) - require.NoError(t, generation.Freeze()) - files, err := testServiceFiles(generation) + plan := mustTestPlan(t, "goa.design/goa/example", roots, planServiceData) + files, err := testServiceFiles(plan) require.NoError(t, err) var generated strings.Builder @@ -931,12 +927,9 @@ func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { // root analysis emits one shared relocated union for every referencing service. func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { root := codegen.RunDSL(t, sharedRelocatedUnionRoot()) - generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, servicecodegen.Plan(root, generation)) - require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + files, err := servicecodegen.Files(plan.Service(root)) require.NoError(t, err) - files := servicecodegen.Files("generated.local/gen", []*servicecodegen.ServicesData{services}) dir := t.TempDir() for _, file := range files { _, err := file.Render(dir) @@ -976,16 +969,13 @@ func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { }) }) }) - generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, servicecodegen.Plan(root, generation)) - require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) dir := t.TempDir() - files, err := testServiceFiles(generation) + files, err := testServiceFiles(plan) require.NoError(t, err) - files = append(files, servicecodegen.ExampleServiceFiles(generation.GenPkg(), root, services)...) + files = append(files, servicecodegen.ExampleServiceFiles(servicePlan)...) for _, file := range files { _, err := file.Render(dir) require.NoError(t, err) @@ -1010,16 +1000,13 @@ func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { }) } }) - generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, servicecodegen.Plan(root, generation)) - require.NoError(t, generation.Freeze()) - services, err := servicecodegen.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) dir := t.TempDir() - files, err := testServiceFiles(generation) + files, err := testServiceFiles(plan) require.NoError(t, err) - files = append(files, servicecodegen.ExampleInterceptorsFiles(generation.GenPkg(), root, services)...) + files = append(files, servicecodegen.ExampleInterceptorsFiles(servicePlan)...) for _, file := range files { _, err := file.Render(dir) require.NoError(t, err) @@ -1064,7 +1051,7 @@ func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { dir := t.TempDir() genDir := filepath.Join(dir, codegen.Gendir) - writeGeneratedModule(t, genDir, "gen") + writeGeneratedModule(t, genDir, "generated.local/gen") _, err := generate(dir, "gen", false, registry) require.NoError(t, err) httpServers, err := filepath.Glob(filepath.Join(genDir, "http", "*", "server", "server.go")) @@ -1076,8 +1063,8 @@ func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { require.NoError(t, err) httpSource.Write(source) } - require.Contains(t, httpSource.String(), `http_ "gen/http_"`) - require.Contains(t, httpSource.String(), `path2 "gen/path"`) + require.Contains(t, httpSource.String(), `http_ "generated.local/gen/http_"`) + require.Contains(t, httpSource.String(), `path2 "generated.local/gen/path"`) runGeneratedTests(t, genDir) } diff --git a/codegen/generator/test_helpers_test.go b/codegen/generator/test_helpers_test.go index 01415c4002..800f9a6d97 100644 --- a/codegen/generator/test_helpers_test.go +++ b/codegen/generator/test_helpers_test.go @@ -11,39 +11,41 @@ import ( "goa.design/goa/v3/eval" ) -// mustTestGeneration creates one generation or fails the calling test. -func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *codegen.Generation { +// mustTestPlan runs the production declaration, freeze, and link lifecycle for +// focused assembler tests and fails the calling test on any invalid phase. +func mustTestPlan(t *testing.T, genpkg string, roots []eval.Root, planners ...func(*Plan) error) *Plan { t.Helper() generation, err := codegen.NewGeneration(genpkg, roots) require.NoError(t, err) - return generation -} - -// testServiceFiles adapts the private plan-owned service assembler for package tests. -func testServiceFiles(generation *codegen.Generation) ([]*codegen.File, error) { - return serviceFiles(testPlan(generation)) + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + } + for _, planner := range planners { + require.NoError(t, planner(plan)) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.link()) + return plan } -// testTransportFiles adapts the private plan-owned transport assembler for package tests. -func testTransportFiles(generation *codegen.Generation) ([]*codegen.File, error) { - return transportFiles(testPlan(generation)) +// testServiceFiles renders service files from the retained plan under test. +func testServiceFiles(plan *Plan) ([]*codegen.File, error) { + return serviceFiles(plan) } -// testOpenAPIFiles adapts the private plan-owned OpenAPI assembler for package tests. -func testOpenAPIFiles(generation *codegen.Generation) ([]*codegen.File, error) { - return openAPIFiles(testPlan(generation)) +// testTransportFiles renders transport files from the retained plan under test. +func testTransportFiles(plan *Plan) ([]*codegen.File, error) { + return transportFiles(plan) } -// assembleExampleFilesForTest adapts the private plan-owned example assembler -// for package tests. -func assembleExampleFilesForTest(generation *codegen.Generation) ([]*codegen.File, error) { - return exampleFiles(testPlan(generation)) +// testOpenAPIFiles renders OpenAPI files from the retained plan under test. +func testOpenAPIFiles(plan *Plan) ([]*codegen.File, error) { + return openAPIFiles(plan) } -// testPlan creates the run-only state needed by a focused assembler test. -func testPlan(generation *codegen.Generation) *Plan { - return &Plan{ - generation: generation, - examples: newExampleGenerators(generation.Roots()), - } +// assembleExampleFilesForTest renders example files from the retained plan. +func assembleExampleFilesForTest(plan *Plan) ([]*codegen.File, error) { + return exampleFiles(plan) } diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 4ffaf75bd8..2e55c8c808 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -5,7 +5,6 @@ package generator import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" @@ -18,10 +17,7 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { - services, err := service.NewServicesData(r, generation, plan.exampleGenerator(r)) - if err != nil { - return nil, err - } + services := plan.Service(r).Services() // HTTP httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) files = append(files, httpcodegen.ServerFiles(httpServices)...) @@ -54,10 +50,11 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { // planTransportData declares service packages and the fixed import qualifiers // required by each transport before the shared generation catalog freezes. -func planTransportData(generation *codegen.Generation) error { - if err := planServiceData(generation); err != nil { +func planTransportData(plan *Plan) error { + if err := planServiceData(plan); err != nil { return err } + generation := plan.Generation() if err := example.Plan(generation); err != nil { return err } diff --git a/codegen/generator/viewed_transport_import_integration_test.go b/codegen/generator/viewed_transport_import_integration_test.go new file mode 100644 index 0000000000..84bb77953d --- /dev/null +++ b/codegen/generator/viewed_transport_import_integration_test.go @@ -0,0 +1,215 @@ +// This file verifies that transport client files import generated views only +// when one of their emitted response or stream-receive sections references it. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestViewedTransportClientImportsCompile verifies that HTTP and JSON-RPC +// response decoders and gRPC response and stream decoders receive the exact +// generated views import used by their rendered sections. The unary gRPC +// client also proves client.go does not reserve the codec-only import. +func TestViewedTransportClientImportsCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.viewed-import", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("ViewedUnary", func() { + dsl.Method("GRPCUnary", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedStream", func() { + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedHTTPSSE", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-sse") + dsl.ServerSentEvents("value") + }) + }) + }) + dsl.Service("ViewedHTTPWebSocket", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-websocket") + }) + }) + }) + dsl.Service("Ordinary", func() { + dsl.Method("HTTP", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("GRPCUnary", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + httpJSON := codegen.SnakeCase("ViewedHTTPJSON") + unary := codegen.SnakeCase("ViewedUnary") + stream := codegen.SnakeCase("ViewedStream") + assertFilesImportPath(t, genDir, "/"+httpJSON+"/views\"", []string{ + filepath.Join("http", httpJSON, "client", "encode_decode.go"), + filepath.Join("http", httpJSON, "client", "types.go"), + filepath.Join("http", httpJSON, "server", "encode_decode.go"), + filepath.Join("http", httpJSON, "server", "types.go"), + filepath.Join("jsonrpc", httpJSON, "client", "encode_decode.go"), + filepath.Join("jsonrpc", httpJSON, "client", "types.go"), + filepath.Join("jsonrpc", httpJSON, "server", "server.go"), + filepath.Join("jsonrpc", httpJSON, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", unary, "client", "client.go"), "/"+unary+"/views\"") + assertFilesImportPath(t, genDir, "/"+unary+"/views\"", []string{ + filepath.Join("grpc", unary, "client", "encode_decode.go"), + filepath.Join("grpc", unary, "client", "types.go"), + filepath.Join("grpc", unary, "server", "encode_decode.go"), + filepath.Join("grpc", unary, "server", "types.go"), + }) + assertFilesImportPath(t, genDir, "/"+stream+"/views\"", []string{ + filepath.Join("grpc", stream, "client", "client.go"), + filepath.Join("grpc", stream, "client", "types.go"), + filepath.Join("grpc", stream, "server", "encode_decode.go"), + filepath.Join("grpc", stream, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", stream, "client", "encode_decode.go"), "/"+stream+"/views\"") + assertViewedStreamingTransportFiles(t, genDir) + ordinary := codegen.SnakeCase("Ordinary") + assertFilesOmitImportPath(t, genDir, "/"+ordinary+"/views\"", []string{ + filepath.Join("http", ordinary, "client", "encode_decode.go"), + filepath.Join("http", ordinary, "client", "types.go"), + filepath.Join("http", ordinary, "server", "encode_decode.go"), + filepath.Join("http", ordinary, "server", "types.go"), + filepath.Join("jsonrpc", ordinary, "client", "encode_decode.go"), + filepath.Join("jsonrpc", ordinary, "client", "types.go"), + filepath.Join("jsonrpc", ordinary, "server", "server.go"), + filepath.Join("jsonrpc", ordinary, "server", "types.go"), + filepath.Join("grpc", ordinary, "client", "client.go"), + filepath.Join("grpc", ordinary, "client", "encode_decode.go"), + filepath.Join("grpc", ordinary, "client", "types.go"), + filepath.Join("grpc", ordinary, "server", "encode_decode.go"), + filepath.Join("grpc", ordinary, "server", "types.go"), + }) + runGeneratedTests(t, genDir) +} + +// assertViewedStreamingTransportFiles verifies that HTTP SSE and WebSocket +// services render and that only the WebSocket receive file imports views +// directly. Server send files call the service constructor and therefore do +// not import the views package themselves. +func assertViewedStreamingTransportFiles(t *testing.T, genDir string) { + t.Helper() + httpSSE := codegen.SnakeCase("ViewedHTTPSSE") + httpWebSocket := codegen.SnakeCase("ViewedHTTPWebSocket") + for _, path := range []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + filepath.Join("http", httpSSE, "client", "sse.go"), + filepath.Join("http", httpWebSocket, "server", "websocket.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + assertImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "client", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) + assertFilesOmitImportPath(t, genDir, "/"+httpSSE+"/views\"", []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + filepath.Join("http", httpSSE, "client", "sse.go"), + }) + assertNoImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "server", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) +} + +// assertFilesImportPath verifies that each generated file imports a package +// whose full path ends with suffix. +func assertFilesImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertFilesOmitImportPath verifies that none of the generated files reserve +// the package whose full path ends with suffix. +func assertFilesOmitImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertNoImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertImportPath verifies that a generated file imports a package whose full +// path ends with suffix. +func assertImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(content), suffix) +} + +// assertNoImportPath verifies that a generated file does not reserve an import +// for a package unused by its rendered sections. +func assertNoImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.False(t, strings.Contains(string(content), suffix), string(content)) +} diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 956b384f1c..51e7656baf 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "reflect" + "slices" "strings" "text/template" @@ -73,13 +74,114 @@ func GoTransformWithAttrs(source, target *expr.AttributeExpr, sourceVar, targetV if err != nil { return "", nil, err } - - funcs, err := collectHelpers(source, target, true, true, ta, make(map[string]*TransformFunctionData)) + helpers, err := collectLegacyHelpers(source, target, true, true, ta, make(map[string]*TransformFunctionData)) if err != nil { return "", nil, err } + return strings.TrimRight(code, "\n"), helpers, nil +} + +// NewTransformPlan selects the exact recursive helper operations required to +// transform source into target. It does not resolve generated names. +func NewTransformPlan(source, target *expr.AttributeExpr) (*TransformPlan, error) { + plan := &TransformPlan{ + source: source, + target: target, + operations: []*transformOperation{{}}, + } + if err := planTransformOperation(source, target, true, true, plan.operations[0], make(map[transformPair]TransformHelperID), plan); err != nil { + return nil, err + } + return plan, nil +} + +// Helpers returns the recursive helper operations selected by the plan. The +// returned slice is independent of the plan; each descriptor and its ID are +// the exact values Render uses for calls and definitions. +func (p *TransformPlan) Helpers() []TransformHelper { + return slices.Clone(p.helpers) +} + +// BindHelperDeclaration assigns the package-level function that defines one +// retained helper operation. Binding happens during declaration planning so +// calls and definitions cannot choose names independently at render time. +func (p *TransformPlan) BindHelperDeclaration(id TransformHelperID, declaration *NameDeclaration) error { + if id.plan != p || id.index < 0 || id.index >= len(p.helpers) { + return fmt.Errorf("transform helper does not belong to this plan") + } + if declaration == nil { + return fmt.Errorf("transform helper declaration must not be nil") + } + if declaration.Kind() != NameFunction { + return fmt.Errorf("transform helper declaration must be a function, got %s", declaration.Kind()) + } + helper := &p.helpers[id.index] + if helper.Declaration != nil && helper.Declaration != declaration { + return fmt.Errorf("transform helper already has a different declaration") + } + for index := range p.helpers { + if index != id.index && p.helpers[index].Declaration == declaration { + return fmt.Errorf("transform helper declaration is already bound to a different operation") + } + } + helper.Declaration = declaration + return nil +} - return strings.TrimRight(code, "\n"), funcs, nil +// BindContexts assigns the frozen source and target type resolvers used by +// every call and helper definition in the retained plan. Contexts may be bound +// only once so later renders cannot change pointer or package-name policy. +func (p *TransformPlan) BindContexts(source, target *AttributeContext) error { + if source == nil || target == nil { + return fmt.Errorf("transform contexts must not be nil") + } + if p.sourceCtx != nil || p.targetCtx != nil { + return fmt.Errorf("transform contexts are already bound") + } + p.sourceCtx = source.Dup() + p.targetCtx = target.Dup() + return nil +} + +// Render formats the transformation and its retained recursive helpers using +// the contexts and declarations bound to the plan. +func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (string, []*TransformFunctionData, error) { + if p.sourceCtx == nil || p.targetCtx == nil { + return "", nil, fmt.Errorf("transform contexts are not bound") + } + renderAttrs := TransformAttrs{ + SourceCtx: p.sourceCtx.Dup(), + TargetCtx: p.targetCtx.Dup(), + } + renderAttrs.helpers = make(map[TransformHelperID]TransformHelper, len(p.helpers)) + for _, planned := range p.helpers { + if planned.Declaration == nil { + return "", nil, fmt.Errorf("transform helper occurrence %d has no declaration", planned.Occurrence) + } + renderAttrs.helpers[planned.ID] = planned + } + renderAttrs.calls = &transformCallCursor{calls: p.operations[0].calls} + code, err := TransformAttribute(p.source, p.target, sourceVar, targetVar, newVar, &renderAttrs) + if err != nil { + return "", nil, err + } + if err := renderAttrs.calls.complete("top-level transform"); err != nil { + return "", nil, err + } + helpers := make([]*TransformFunctionData, 0, len(p.helpers)) + for index, planned := range p.helpers { + entered := enterTransformAttrs(planned.Source, planned.Target, &renderAttrs) + entered.calls = &transformCallCursor{calls: p.operations[index+1].calls} + helper, err := generateRetainedHelper(planned, entered) + if err != nil { + return "", nil, err + } + if err := entered.calls.complete(fmt.Sprintf("transform helper occurrence %d", planned.Occurrence)); err != nil { + return "", nil, err + } + helpers = append(helpers, helper) + } + return strings.TrimRight(code, "\n"), helpers, nil } // TransformAttribute returns the code to transform source attribute to target @@ -131,11 +233,24 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar return prelude + code, nil } -// TransformHelperName returns the transformation function name to initialize a -// target user type from an instance of a source user type. It is exported so -// that TransformHooks implementations can compute the names of the helper -// functions the engine collects. +// TransformHelperName returns the retained or one-pass helper function used to +// initialize target from source. Retained transforms call it only for named +// object pairs; one-pass transform hooks retain their legacy naming contract. func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { + if ta.calls != nil { + call := ta.calls.consume() + helper, ok := ta.helpers[call.helper] + if !ok { + panic("retained transform call references an unknown helper") // bug + } + return helper.Declaration.Name() + } + return legacyTransformHelperName(source, target, ta) +} + +// legacyTransformHelperName preserves the naming strategy used by generators +// that have not yet bound their package-level helper declarations. +func legacyTransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { var ( sname string tname string @@ -156,6 +271,15 @@ func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) return Goify(prefix+sname+"To"+tname, false) } +// usesTransformHelper reports whether source and target can define one named +// helper function signature. Anonymous objects are rendered inline because +// they have no package-level parameter or result declaration. +func usesTransformHelper(source, target *expr.AttributeExpr) bool { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + return sourceNamed && targetNamed && expr.IsObject(source.Type) && expr.IsObject(target.Type) +} + // transformPrimitive returns the code to transform source primitive type to // target primitive type. The caller (TransformAttribute) already verified that // source and target are compatible. @@ -312,7 +436,6 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st dispatchTgtVar = unionVar postlude = fmt.Sprintf("%s = &%s\n", tgtVar, unionVar) } - _, ok := srcc.Type.(expr.UserType) switch { case expr.IsArray(srcc.Type): if h != nil && h.TransformArray != nil { @@ -332,10 +455,8 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st } else { code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) } - case ok: - if !expr.IsPrimitive(srcc.Type) { - code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) - } + case usesTransformHelper(srcc, tgtc): + code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) case expr.IsObject(srcc.Type): code, err = TransformAttribute(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) } @@ -490,7 +611,8 @@ func transformArray(source, target *expr.Array, sourceVar, targetVar string, new "NewVar": newVar, "TransformAttrs": ta, "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), - "IsStruct": expr.IsObject(target.ElemType.Type), + "SourceIsObject": expr.IsObject(source.ElemType.Type), + "UseHelper": usesTransformHelper(source.ElemType, target.ElemType), } var buf bytes.Buffer if err := transformGoArrayT.Execute(&buf, data); err != nil { @@ -519,8 +641,9 @@ func transformMap(source, target *expr.Map, sourceVar, targetVar string, newVar "NewVar": newVar, "TransformAttrs": ta, "LoopVar": "", - "IsKeyStruct": expr.IsObject(target.KeyType.Type), - "IsElemStruct": expr.IsObject(target.ElemType.Type), + "ElemIsObject": expr.IsObject(source.ElemType.Type), + "UseKeyHelper": usesTransformHelper(source.KeyType, target.KeyType), + "UseElemHelper": usesTransformHelper(source.ElemType, target.ElemType), } if depth := MapDepth(target); depth > 0 { data["LoopVar"] = string(rune(97 + depth)) @@ -569,17 +692,13 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str cases := make([]map[string]any, 0, len(srcUnion.Values)) for i, st := range srcUnion.Values { tt := tgtUnion.Values[i] - branchAttrs := &TransformAttrs{ - SourceCtx: ta.SourceCtx.Enter(st.Attribute), - TargetCtx: ta.TargetCtx.Enter(tt.Attribute), - Prefix: ta.Prefix, - Hooks: ta.Hooks, - } - useHelper := false - if _, ok := st.Attribute.Type.(expr.UserType); ok && expr.IsObject(st.Attribute.Type) { - if _, ok := tt.Attribute.Type.(expr.UserType); ok && expr.IsObject(tt.Attribute.Type) { - useHelper = true - } + branchAttrs := *ta + branchAttrs.SourceCtx = ta.SourceCtx.Enter(st.Attribute) + branchAttrs.TargetCtx = ta.TargetCtx.Enter(tt.Attribute) + useHelper := usesTransformHelper(st.Attribute, tt.Attribute) + helperName := "" + if useHelper { + helperName = TransformHelperName(st.Attribute, tt.Attribute, &branchAttrs) } cases = append(cases, map[string]any{ "CaseName": st.Name, @@ -589,7 +708,7 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str "TargetAttr": tt.Attribute, "TargetCastType": branchAttrs.TargetCtx.Scope.Ref(tt.Attribute, branchAttrs.TargetCtx.Pkg(tt.Attribute)), "UseHelper": useHelper, - "HelperName": TransformHelperName(st.Attribute, tt.Attribute, branchAttrs), + "HelperName": helperName, }) } @@ -612,75 +731,115 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str return buf.String(), nil } -// collectHelpers recurses through the given attributes and returns the -// transform helper functions required by the code GoTransform produces. The -// top-level call (topLevel true) does not generate a helper for the top-most -// user type because the generated code inlines that transformation; children -// of composite top-level types always get helpers. -// -// seen keeps track of generated transform functions to avoid infinite -// recursion on recursive types. -func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (helpers []*TransformFunctionData, err error) { - if h := ta.Hooks; h != nil && h.UnwrapPair != nil { - source, target, _ = h.UnwrapPair(source, target) +// planTransformOperation retains the helper call edges emitted by one +// top-level transform or helper body. Every nonrecursive named object +// occurrence gets a new helper. Only a source-target pair already active in +// the current helper body becomes a back-edge to its ancestor helper. +func planTransformOperation(source, target *expr.AttributeExpr, required, topLevel bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + if err := IsCompatible(source.Type, target.Type, "source", "target"); err != nil { + return err } - ta = enterTransformAttrs(source, target, ta) if topLevel { - req = true - } else { - name := TransformHelperName(source, target, ta) - if _, ok := seen[name]; ok { - return helpers, err + required = true + } else if usesTransformHelper(source, target) { + pair := transformPair{ + source: transformIdentity(source.Type), + target: transformIdentity(target.Type), } - if _, ok := source.Type.(expr.UserType); ok && expr.IsObject(source.Type) { - var h *TransformFunctionData - if h, err = generateHelper(source, target, req, ta, seen); h != nil { - helpers = append(helpers, h) - } + if ancestor, recursive := active[pair]; recursive { + operation.calls = append(operation.calls, transformCall{ + helper: ancestor, + }) + return nil } + + id := TransformHelperID{plan: plan, index: len(plan.helpers)} + plan.helpers = append(plan.helpers, TransformHelper{ + ID: id, + Source: source, + Target: target, + Required: required, + Occurrence: id.index + 1, + }) + operation.calls = append(operation.calls, transformCall{ + helper: id, + }) + body := &transformOperation{} + plan.operations = append(plan.operations, body) + active[pair] = id + err := planTransformChildren(source, target, required, body, active, plan) + delete(active, pair) + return err + } + return planTransformChildren(source, target, required, operation, active, plan) +} + +// planTransformChildren walks child transformations in the same order as the +// core templates consume helper names. +func planTransformChildren(source, target *expr.AttributeExpr, required bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + collect := func(source, target *expr.AttributeExpr, childRequired bool, top bool) error { + return planTransformOperation(source, target, childRequired, top, operation, active, plan) } - // Renderers which inline composite element construction do not call - // element transform helpers: skip helper generation for the elements - // themselves by treating them as top-level attributes. - elemTop := ta.Hooks != nil && ta.Hooks.InlineCompositeElems - var other []*TransformFunctionData switch { case expr.IsArray(source.Type): - if other, err = collectHelpers(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + return collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, false) case expr.IsMap(source.Type): - sm, tm := expr.AsMap(source.Type), expr.AsMap(target.Type) - if other, err = collectHelpers(sm.ElemType, tm.ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - if other, err = collectHelpers(sm.KeyType, tm.KeyType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) + if err := collect(sourceMap.KeyType, targetMap.KeyType, required, false); err != nil { + return err } + return collect(sourceMap.ElemType, targetMap.ElemType, required, false) case expr.IsUnion(source.Type): - tt := expr.AsUnion(target.Type) - if tt == nil { - return helpers, err + targetUnion := expr.AsUnion(target.Type) + if targetUnion == nil { + return nil } - for i, st := range expr.AsUnion(source.Type).Values { - if other, err = collectHelpers(st.Attribute, tt.Values[i].Attribute, req, false, ta, seen); err == nil { - helpers = append(helpers, other...) + for index, branch := range expr.AsUnion(source.Type).Values { + if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false); err != nil { + return err } } case expr.IsObject(source.Type): if expr.IsUnion(target.Type) { - return helpers, err + return nil } - walkMatches(source, target, func(srcMatt, _ *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { - if err != nil { - return - } - if other, err = collectHelpers(srcc, tgtc, srcMatt.IsRequired(n), false, ta, seen); err == nil { - helpers = append(helpers, other...) + var walkErr error + walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { + if walkErr == nil { + walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false) } }) + return walkErr + } + return nil +} + +// transformIdentity returns the authored origin used only to detect a +// source-target pair already active in the current recursive helper body. +func transformIdentity(dataType expr.DataType) expr.DataType { + if userType, ok := dataType.(expr.UserType); ok { + return userType.Origin() + } + return dataType +} + +// consume returns the next retained helper call. Cursor position and the +// edge's typed helper ID select the exact operation. +func (c *transformCallCursor) consume() transformCall { + if c.next >= len(c.calls) { + panic("transform render consumed more helper calls than the plan retained") // bug + } + call := c.calls[c.next] + c.next++ + return call +} + +// complete reports a render that skipped retained helper calls. +func (c *transformCallCursor) complete(owner string) error { + if c.next != len(c.calls) { + return fmt.Errorf("%s rendered %d of %d retained helper calls", owner, c.next, len(c.calls)) } - return helpers, err + return nil } // enterTransformAttrs returns transform attributes whose source and target @@ -692,29 +851,111 @@ func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *Transfo return &entered } -// generateHelper generates the code that transforms instances of source into -// target. Both source and target must be user types. The caller -// (collectHelpers) guarantees no helper was generated yet for the pair. -func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - name := TransformHelperName(source, target, ta) - - code, err := TransformAttribute(source, target, "v", "res", true, ta) +// generateRetainedHelper formats one helper operation retained by +// TransformPlan. +func generateRetainedHelper(helper TransformHelper, ta *TransformAttrs) (*TransformFunctionData, error) { + code, err := TransformAttribute(helper.Source, helper.Target, "v", "res", true, ta) if err != nil { return nil, err } - if !req && !expr.IsPrimitive(source.Type) { + if !helper.Required && !expr.IsPrimitive(helper.Source.Type) { code = "if v == nil {\n\treturn nil\n}\n" + code } tfd := &TransformFunctionData{ - Name: name, - ParamTypeRef: ta.SourceCtx.Scope.Ref(source, ta.SourceCtx.Pkg(source)), - ResultTypeRef: ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)), + ID: helper.ID, + Declaration: helper.Declaration, + ParamTypeRef: ta.SourceCtx.Scope.Ref(helper.Source, ta.SourceCtx.Pkg(helper.Source)), + ResultTypeRef: ta.TargetCtx.Scope.Ref(helper.Target, ta.TargetCtx.Pkg(helper.Target)), Code: code, } - seen[name] = tfd return tfd, nil } +// collectLegacyHelpers renders the unbound helper definitions used by +// GoTransformWithAttrs. Hook-aware transports keep this one-pass contract +// until they acquire an explicit retained planning API. +func collectLegacyHelpers(source, target *expr.AttributeExpr, required, topLevel bool, attrs *TransformAttrs, seen map[string]*TransformFunctionData) (helpers []*TransformFunctionData, err error) { + if hooks := attrs.Hooks; hooks != nil && hooks.UnwrapPair != nil { + source, target, _ = hooks.UnwrapPair(source, target) + } + attrs = enterTransformAttrs(source, target, attrs) + if topLevel { + required = true + } else if usesTransformHelper(source, target) { + name := legacyTransformHelperName(source, target, attrs) + if _, exists := seen[name]; exists { + return nil, nil + } + helper, helperErr := generateLegacyHelper(source, target, required, attrs, seen) + if helperErr != nil { + return nil, helperErr + } + helpers = append(helpers, helper) + } + + elementTop := attrs.Hooks != nil && attrs.Hooks.InlineCompositeElems + collect := func(childSource, childTarget *expr.AttributeExpr, childRequired bool, childTop bool) error { + other, collectErr := collectLegacyHelpers(childSource, childTarget, childRequired, childTop, attrs, seen) + helpers = append(helpers, other...) + return collectErr + } + switch { + case expr.IsArray(source.Type): + return helpers, collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, elementTop) + case expr.IsMap(source.Type): + sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) + if err := collect(sourceMap.ElemType, targetMap.ElemType, required, elementTop); err != nil { + return helpers, err + } + return helpers, collect(sourceMap.KeyType, targetMap.KeyType, required, elementTop) + case expr.IsUnion(source.Type): + targetUnion := expr.AsUnion(target.Type) + if targetUnion == nil { + return helpers, nil + } + for index, branch := range expr.AsUnion(source.Type).Values { + if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false); err != nil { + return helpers, err + } + } + case expr.IsObject(source.Type): + if expr.IsUnion(target.Type) { + return helpers, nil + } + var walkErr error + walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { + if walkErr == nil { + walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false) + } + }) + if walkErr != nil { + return helpers, walkErr + } + } + return helpers, nil +} + +// generateLegacyHelper formats one helper definition and records its name +// before rendering its body so recursive calls stop at that definition. +func generateLegacyHelper(source, target *expr.AttributeExpr, required bool, attrs *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { + name := legacyTransformHelperName(source, target, attrs) + helper := &TransformFunctionData{ + Name: name, + ParamTypeRef: attrs.SourceCtx.Scope.Ref(source, attrs.SourceCtx.Pkg(source)), + ResultTypeRef: attrs.TargetCtx.Scope.Ref(target, attrs.TargetCtx.Pkg(target)), + } + seen[name] = helper + code, err := TransformAttribute(source, target, "v", "res", true, attrs) + if err != nil { + return nil, err + } + if !required && !expr.IsPrimitive(source.Type) { + code = "if v == nil {\n\treturn nil\n}\n" + code + } + helper.Code = code + return helper, nil +} + // walkMatches iterates through the attributes of source and looks for // attributes with identical names in target. walkMatches calls the walker // function for each pair of matched attributes. Both source and target must be diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 0f389547fb..1037ca0545 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -3,6 +3,10 @@ package codegen import ( + "fmt" + "os" + "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -289,10 +293,8 @@ func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { require.Contains(t, helpers[0].ResultTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") require.Contains(t, *sourceOwner.entered, "sourceSourceEnvelope") require.Contains(t, *sourceOwner.entered, "sourceSourceChoiceContainer") - require.Contains(t, *sourceOwner.entered, "sourceSourceChoice") require.Contains(t, *targetOwner.entered, "targetTargetEnvelope") require.Contains(t, *targetOwner.entered, "targetTargetSelectionContainer") - require.Contains(t, *targetOwner.entered, "targetTargetSelection") reverseSource := newTransformOwnerAttributor("source") reverseTarget := newTransformOwnerAttributor("target") @@ -312,6 +314,325 @@ func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") } +func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + deep := root.UserType("Deep") + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: deep}, + &expr.AttributeExpr{Type: deep}, + ) + require.NoError(t, err) + + planned := plan.Helpers() + require.Len(t, planned, 2) + declarations := make(map[TransformHelperID]*NameDeclaration, len(planned)) + plannedByID := make(map[TransformHelperID]TransformHelper, len(planned)) + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range planned { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + declarations[helper.ID] = declaration + plannedByID[helper.ID] = helper + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + } + require.NoError(t, plan.BindContexts(attrs.SourceCtx, attrs.TargetCtx)) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, len(planned)) + rendered := code + for index, helper := range helpers { + require.Equal(t, planned[index].ID, helper.ID) + require.Same(t, declarations[helper.ID], helper.Declaration) + require.Same(t, plannedByID[helper.ID].Source, plan.Helpers()[index].Source) + require.Same(t, plannedByID[helper.ID].Target, plan.Helpers()[index].Target) + require.Empty(t, helper.Name) + rendered += helper.Code + } + for _, helper := range helpers { + require.Contains(t, rendered, helper.Declaration.Name()) + } +} + +func TestTransformPlanRetainsSameTypeSiblingOccurrences(t *testing.T) { + plan := siblingTransformPlan(t) + require.Len(t, plan.Helpers(), 2) + require.NotEqual(t, plan.Helpers()[0].ID, plan.Helpers()[1].ID) +} + +func TestTransformPlanRejectsOneDeclarationForDifferentHelpers(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + err := plan.BindHelperDeclaration(helpers[1].ID, declaration) + require.EqualError(t, err, "transform helper declaration is already bound to a different operation") +} + +func TestTransformPlanRequiresEveryHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.NoError(t, plan.BindHelperDeclaration( + helpers[0].ID, + NewExactName(NameFunction, "transformLeftRecursive"), + )) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err := plan.Render("source", "target", true) + require.EqualError(t, err, "transform helper occurrence 2 has no declaration") +} + +func TestTransformPlanBindsContextsOnce(t *testing.T) { + plan := siblingTransformPlan(t) + source := NewAttributeContext(false, false, true, "", NewNameScope()) + target := NewAttributeContext(false, false, true, "", NewNameScope()) + require.NoError(t, plan.BindContexts(source, target)) + require.EqualError(t, plan.BindContexts(source, target), "transform contexts are already bound") +} + +func TestTransformPlanRejectsNonFunctionHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + err := plan.BindHelperDeclaration( + plan.Helpers()[0].ID, + NewExactName(NameType, "TransformRecursive"), + ) + require.EqualError(t, err, "transform helper declaration must be a function, got type") +} + +func TestTransformPlanHelperEligibilityMatchesCompositeRenderers(t *testing.T) { + shapes := map[string]func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr){ + "array": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Array{ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Array{ElemType: target}} + }, + "map": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: target}} + }, + "union": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Union{TypeName: "SourceChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: source}}}}, + &expr.AttributeExpr{Type: &expr.Union{TypeName: "TargetChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: target}}}} + }, + } + pairs := map[string]struct { + source, target *expr.AttributeExpr + helpers int + }{ + "both-named": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("TargetNode", true), + helpers: 1, + }, + "anonymous-source": { + source: transformObjectAttribute("", false), + target: transformObjectAttribute("TargetNode", true), + }, + "anonymous-target": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("", false), + }, + } + + for shapeName, shape := range shapes { + for pairName, pair := range pairs { + t.Run(shapeName+"/"+pairName, func(t *testing.T) { + source, target := shape(pair.source, pair.target) + plan, err := NewTransformPlan(source, target) + require.NoError(t, err) + require.Len(t, plan.Helpers(), pair.helpers) + + code, helpers := renderTransformPlan(t, plan) + require.Len(t, helpers, pair.helpers) + if pair.helpers == 0 { + require.NotContains(t, code, "CanonicalHelper") + } else { + require.Contains(t, code, "CanonicalHelper1") + } + }) + } + } +} + +func TestTransformPlanRetainsRequiredAndOptionalSiblingCalls(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + + var required, optional *TransformFunctionData + for _, definition := range definitions { + if plan.Helpers()[definition.ID.index].Required { + required = definition + } else { + optional = definition + } + } + require.NotNil(t, required) + require.NotNil(t, optional) + require.NotContains(t, required.Code, "if v == nil") + require.Contains(t, optional.Code, "if v == nil") + require.Contains(t, code, "target.Left = "+required.Declaration.Name()+"(source.Left)") + require.Contains(t, code, "target.Right = "+optional.Declaration.Name()+"(source.Right)") +} + +func TestTransformPlanMapKeyHelperReceivesKey(t *testing.T) { + sourceKey := transformObjectAttribute("SourceKey", true) + targetKey := transformObjectAttribute("TargetKey", true) + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: sourceKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: targetKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + require.Same(t, sourceKey, plan.Helpers()[0].Source) + require.Same(t, targetKey, plan.Helpers()[0].Target) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + bound := plan.Helpers()[0] + require.Equal(t, bound.ID, definitions[0].ID) + require.Same(t, bound.Declaration, definitions[0].Declaration) + + generated := fmt.Sprintf(`package transformtest + +type SourceKey struct { + Value string +} + +type TargetKey struct { + Value string +} + +func transform(source map[*SourceKey]string) map[*TargetKey]string { +%s + return target +} + +func %s(v %s) %s { +%s + return res +} +`, code, definitions[0].Declaration.Name(), definitions[0].ParamTypeRef, + definitions[0].ResultTypeRef, definitions[0].Code) + compileTransformSource(t, generated) + require.Contains(t, code, definitions[0].Declaration.Name()+"(key)") +} + +// siblingTransformPlan builds two nonrecursive occurrences of the same named +// recursive type. Each field must own a helper even though both types share an +// authored origin. +func siblingTransformPlan(t *testing.T) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: fields}, + TypeName: "Container", + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: container}, + &expr.AttributeExpr{Type: container}, + ) + require.NoError(t, err) + return plan +} + +// mixedSiblingTransformPlan builds required and optional occurrences of the +// same recursive named type in one transform operation. +func mixedSiblingTransformPlan(t *testing.T) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: container}, + &expr.AttributeExpr{Type: container}, + ) + require.NoError(t, err) + return plan +} + +// transformObjectAttribute builds either a named or anonymous object with the +// same compatible field shape. +func transformObjectAttribute(name string, named bool) *expr.AttributeExpr { + object := &expr.Object{} + object.Set("value", &expr.AttributeExpr{Type: expr.String}) + if !named { + return &expr.AttributeExpr{Type: object} + } + return &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: name, + }} +} + +// renderTransformPlan binds deterministic function declarations and contexts, +// then renders the retained operation and definitions. +func renderTransformPlan(t *testing.T, plan *TransformPlan) (string, []*TransformFunctionData) { + t.Helper() + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range plan.Helpers() { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + return code, helpers +} + +// compileTransformSource proves that a rendered transform and its retained +// helper definitions agree on concrete Go argument and result types. +func compileTransformSource(t *testing.T, source string) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "go.mod"), + []byte("module example.com/transformtest\n\ngo 1.25.0\n"), + 0o600, + )) + require.NoError(t, os.WriteFile(filepath.Join(dir, "transform.go"), []byte(source), 0o600)) + command := exec.Command("go", "test", "./...") + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("generated transform did not compile: %v\n%s", err, output) + } +} + func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { entered := make([]string, 0) return &transformOwnerAttributor{ @@ -352,6 +673,10 @@ func (*transformOwnerAttributor) IsSumType() bool { return true } +func (a *transformOwnerAttributor) ValidatorName(att *expr.AttributeExpr, view string) string { + return "Validate" + a.Name(att, "", false, true) + Goify(view, true) +} + func (a *transformOwnerAttributor) Scope() *NameScope { return a.scope } diff --git a/codegen/go_type_plan.go b/codegen/go_type_plan.go new file mode 100644 index 0000000000..2b24108504 --- /dev/null +++ b/codegen/go_type_plan.go @@ -0,0 +1,748 @@ +// This file retains Go type layouts and exact generated declaration bindings +// before package names freeze. Linked formatters render only these copied facts; +// they never inspect the mutable Goa expression graph. +package codegen + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "goa.design/goa/v3/expr" +) + +type ( + // GoTypeKind identifies one retained Go layout category. + GoTypeKind uint8 + + // GoTypeImport identifies one package used by a retained type spelling. + // Name is the preferred qualifier supplied by the type contract; generated + // declaration owners leave Name empty. + GoTypeImport struct { + // Name is the preferred package qualifier, when the design supplied one. + Name string + // Path is the canonical Go import path. + Path string + } + + // GoTypeBindingRequest describes one named or union occurrence whose owning + // subsystem must bind an exact generated declaration during planning. + GoTypeBindingRequest struct { + // Attribute is the exact expression occurrence being planned. Binders may + // inspect it during planning; linked formatters never do. + Attribute *expr.AttributeExpr + // InheritedOwner is the package inherited from the enclosing layout. + InheritedOwner string + // Kind distinguishes named types from union declarations. + Kind GoTypeKind + } + + // GoTypeBinding binds one planned occurrence to its exact generated package + // declaration. Named occurrences require Type; union occurrences require + // Union. The other declaration field must be nil. + GoTypeBinding struct { + // Owner is the canonical import path that owns the declaration. + Owner string + // Type is the exact generated declaration for a named user type. + Type *TypeDeclaration + // Union is the exact generated declaration for a sum type. + Union *UnionDeclaration + } + + // GoTypeBinder supplies package ownership and canonical declaration records + // for named and union occurrences. The subsystem that owns those catalogs + // must provide this callback; core layout planning never infers ownership. + GoTypeBinder func(GoTypeBindingRequest) (GoTypeBinding, error) + + // GoLayoutPolicy records the complete generated Go field and validation + // representation selected for one plan. A shared named value keeps service, + // view, and transport planners from independently reconstructing policy. + GoLayoutPolicy struct { + // Pointer forces primitive object fields to use pointers. + Pointer bool + // IgnoreRequired suppresses required checks for primitive transport fields. + IgnoreRequired bool + // UseDefault keeps optional primitive fields with defaults as values. + UseDefault bool + // UnionPointer uses pointers for optional sum-type union fields and for + // required union fields when Pointer is also true. + UnionPointer bool + // SumType reports that unions use Goa's generated struct representation. + SumType bool + } + + // GoTypePlanOptions configures one exact layout occurrence. + GoTypePlanOptions struct { + // Owner is the package inherited by the root occurrence. + Owner string + // FieldName is the optional design field name of the root occurrence. + FieldName string + // Policy is the complete Go representation selected by the caller. + Policy GoLayoutPolicy + // Bind resolves every named type and union to an exact declaration. + Bind GoTypeBinder + } + + // GoTypePlan is an immutable symbolic Go layout built while expressions are + // available and package declarations remain mutable. It retains source + // pointers only for occurrence identity; no method reads an expression after + // PlanGoType returns. + GoTypePlan struct { + kind GoTypeKind + owner string + policy GoLayoutPolicy + occurrence *expr.AttributeExpr + fieldNameUpper string + fieldNameLower string + description string + comment string + tag string + fieldPointer bool + definitionPointer bool + referencePointer bool + primitive string + directImport GoTypeImport + hasDirectImport bool + customQualifier string + typeDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration + fields []*GoTypePlan + branches []*GoTypePlan + element *GoTypePlan + key *GoTypePlan + } + + // GoTypeQualifier returns the final package qualifier for one canonical + // import path after the generation freezes its shared import aliases. + GoTypeQualifier func(importPath string) string + + // LinkedGoType formats one retained plan relative to an output package. It + // resolves only frozen declaration names and retained import identities. + LinkedGoType struct { + plan *GoTypePlan + outputPath string + qualifier GoTypeQualifier + } + + // goTypePlanner owns the expression-reading planning phase. + goTypePlanner struct { + policy GoLayoutPolicy + bind GoTypeBinder + } +) + +const ( + // GoPrimitive is a built-in or explicitly imported primitive spelling. + GoPrimitive GoTypeKind = iota + 1 + // GoArray is a slice layout with one retained element occurrence. + GoArray + // GoMap is a map layout with retained key and element occurrences. + GoMap + // GoStruct is an anonymous struct with retained ordered field occurrences. + GoStruct + // GoNamed is a user type bound to an exact generated type declaration. + GoNamed + // GoUnion is a sum type bound to an exact generated union declaration. + GoUnion + // GoEmpty is Goa's built-in empty service type. + GoEmpty + // GoServiceError is Goa's built-in service error type. + GoServiceError +) + +// PlanGoType copies the complete Go layout for attribute while generated +// packages are still mutable. Callers link and format the returned plan only +// after the generation freezes its declaration and import-alias catalogs. +func PlanGoType(attribute *expr.AttributeExpr, options GoTypePlanOptions) (*GoTypePlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan Go type: attribute must not be nil") + } + if options.Owner == "" { + return nil, fmt.Errorf("plan Go type: inherited owner must not be empty") + } + planner := goTypePlanner{ + policy: options.Policy, + bind: options.Bind, + } + return planner.plan(attribute, options.Owner, options.FieldName, nil, false) +} + +// String returns the layout category used in planning diagnostics. +func (k GoTypeKind) String() string { + switch k { + case GoPrimitive: + return "primitive" + case GoArray: + return "array" + case GoMap: + return "map" + case GoStruct: + return "struct" + case GoNamed: + return "named type" + case GoUnion: + return "union" + case GoEmpty: + return "empty type" + case GoServiceError: + return "service error" + default: + return "unknown" + } +} + +// Kind returns the retained layout category. +func (p *GoTypePlan) Kind() GoTypeKind { + return p.kind +} + +// Owner returns the canonical import path inherited or selected for this +// exact occurrence. +func (p *GoTypePlan) Owner() string { + return p.owner +} + +// Policy returns the complete generated representation selected for this +// occurrence. +func (p *GoTypePlan) Policy() GoLayoutPolicy { + return p.policy +} + +// MatchesOccurrence reports whether attribute is the exact expression pointer +// used to build this plan. It never reads the expression. +func (p *GoTypePlan) MatchesOccurrence(attribute *expr.AttributeExpr) bool { + return p.occurrence == attribute +} + +// PlansForOccurrence returns every plan in this retained layout that was built +// from attribute. Separate entries preserve distinct field, owner, or pointer +// policies when one expression pointer is reused. +func (p *GoTypePlan) PlansForOccurrence(attribute *expr.AttributeExpr) []*GoTypePlan { + var matches []*GoTypePlan + p.walk(func(candidate *GoTypePlan) { + if candidate.occurrence == attribute { + matches = append(matches, candidate) + } + }) + return matches +} + +// TypeDeclaration returns the exact named declaration retained for this +// occurrence, or nil for layouts that are not named user types. +func (p *GoTypePlan) TypeDeclaration() *TypeDeclaration { + return p.typeDeclaration +} + +// UnionDeclaration returns the exact union declaration retained for this +// occurrence, or nil for layouts that are not unions. +func (p *GoTypePlan) UnionDeclaration() *UnionDeclaration { + return p.unionDeclaration +} + +// FieldName returns the retained Go field identifier. It returns the exported +// spelling when firstUpper is true and the package-local spelling otherwise. +func (p *GoTypePlan) FieldName(firstUpper bool) string { + if firstUpper { + return p.fieldNameUpper + } + return p.fieldNameLower +} + +// Description returns the copied design description for this occurrence. +func (p *GoTypePlan) Description() string { + return p.description +} + +// Tag returns the complete retained Go struct tag, including leading space. +func (p *GoTypePlan) Tag() string { + return p.tag +} + +// IsPointer reports whether an enclosing struct field stores this occurrence +// through a pointer under the planned pointer/default policy. +func (p *GoTypePlan) IsPointer() bool { + return p.fieldPointer +} + +// Import returns the package imported directly by this type spelling. The +// boolean is false for native and generated declaration spellings. +func (p *GoTypePlan) Import() (GoTypeImport, bool) { + return p.directImport, p.hasDirectImport +} + +// ImportPreferences returns every distinct authored alias preference and +// generated declaration path reachable from this plan in stable layout order. +// Multiple preferences for one path remain distinct so generation can resolve +// them before freezing its import aliases. +func (p *GoTypePlan) ImportPreferences() []GoTypeImport { + seen := make(map[GoTypeImport]struct{}) + var imports []GoTypeImport + p.walkImports(func(candidate *GoTypePlan) { + var goImport GoTypeImport + switch { + case candidate.hasDirectImport: + goImport = candidate.directImport + case candidate.typeDeclaration != nil || candidate.unionDeclaration != nil: + goImport = GoTypeImport{Path: candidate.owner} + default: + return + } + if _, exists := seen[goImport]; exists { + return + } + seen[goImport] = struct{}{} + imports = append(imports, goImport) + }) + return imports +} + +// Fields returns a copy of the ordered anonymous struct field plans. +func (p *GoTypePlan) Fields() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.fields...) +} + +// Branches returns a copy of the ordered union branch plans. +func (p *GoTypePlan) Branches() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.branches...) +} + +// Elem returns the retained array or map element plan, or nil for other kinds. +func (p *GoTypePlan) Elem() *GoTypePlan { + return p.element +} + +// Key returns the retained map key plan, or nil for other kinds. +func (p *GoTypePlan) Key() *GoTypePlan { + return p.key +} + +// Equivalent reports whether p and other retain the same complete Go layout. +// Source expression pointers are deliberately excluded: independently built +// compiler copies are equivalent when they bind the same declarations and +// retain identical owners, policies, field spellings, tags, pointer choices, +// imports, and ordered child layouts. +func (p *GoTypePlan) Equivalent(other *GoTypePlan) bool { + if p == nil || other == nil { + return p == other + } + if p.kind != other.kind || p.owner != other.owner || p.policy != other.policy || + p.fieldNameUpper != other.fieldNameUpper || p.fieldNameLower != other.fieldNameLower || + p.description != other.description || p.comment != other.comment || p.tag != other.tag || + p.fieldPointer != other.fieldPointer || p.definitionPointer != other.definitionPointer || + p.referencePointer != other.referencePointer || p.primitive != other.primitive || + p.directImport != other.directImport || p.hasDirectImport != other.hasDirectImport || + p.customQualifier != other.customQualifier || p.typeDeclaration != other.typeDeclaration || + p.unionDeclaration != other.unionDeclaration || len(p.fields) != len(other.fields) || + len(p.branches) != len(other.branches) { + return false + } + if !p.key.Equivalent(other.key) || !p.element.Equivalent(other.element) { + return false + } + for index := range p.fields { + if !p.fields[index].Equivalent(other.fields[index]) { + return false + } + } + for index := range p.branches { + if !p.branches[index].Equivalent(other.branches[index]) { + return false + } + } + return true +} + +// Link binds this retained layout to one generated output package after the +// owning generation freezes declarations and import aliases. Link itself is a +// pure binding operation; declaration access remains governed by the catalog's +// freeze contract. The returned formatter contains no expression traversal or +// metadata decisions. +func (p *GoTypePlan) Link(outputPath string, qualifier GoTypeQualifier) LinkedGoType { + return LinkedGoType{plan: p, outputPath: outputPath, qualifier: qualifier} +} + +// Name returns the Go type spelling selected by the retained layout. +func (l LinkedGoType) Name() string { + switch l.plan.kind { + case GoPrimitive: + if !l.plan.hasDirectImport || l.plan.customQualifier == "" { + return l.plan.primitive + } + return strings.ReplaceAll( + l.plan.primitive, + l.plan.customQualifier+".", + l.qualify(l.plan.directImport.Path)+".", + ) + case GoArray: + return "[]" + l.Enter(l.plan.element).Ref() + case GoMap: + return fmt.Sprintf( + "map[%s]%s", + l.Enter(l.plan.key).Ref(), + l.Enter(l.plan.element).Ref(), + ) + case GoStruct: + return l.Def() + case GoNamed: + return l.qualifiedDeclaration(l.plan.typeDeclaration.Declaration()) + case GoUnion: + return l.qualifiedDeclaration(l.plan.unionDeclaration.Declaration()) + case GoEmpty: + return "struct {}" + case GoServiceError: + return l.qualify(l.plan.directImport.Path) + ".ServiceError" + default: + panic(fmt.Sprintf("format unknown retained Go type kind %d", l.plan.kind)) + } +} + +// Def returns the Go definition selected by the retained layout. +func (l LinkedGoType) Def() string { + switch l.plan.kind { + case GoArray: + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return "[]" + element + case GoMap: + key := l.Enter(l.plan.key).Def() + if l.plan.key.definitionPointer { + key = "*" + key + } + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return fmt.Sprintf("map[%s]%s", key, element) + case GoStruct: + lines := []string{"struct {"} + for _, field := range l.plan.fields { + fieldType := l.Enter(field).Def() + if field.fieldPointer { + fieldType = "*" + fieldType + } + var description string + if field.comment != "" { + description = field.comment + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + field.fieldNameUpper, + fieldType, + field.tag, + )) + } + return strings.Join(append(lines, "}"), "\n") + default: + return l.Name() + } +} + +// Ref returns the retained Go reference spelling, including named object and +// union pointer semantics. +func (l LinkedGoType) Ref() string { + name := l.Name() + if l.plan.referencePointer { + return "*" + name + } + return name +} + +// Field returns the retained field identifier for this exact occurrence. +func (l LinkedGoType) Field(firstUpper bool) string { + return l.plan.FieldName(firstUpper) +} + +// Package returns the qualifier for this occurrence's owner relative to the +// linked output package, or the empty string for a same-package occurrence. +func (l LinkedGoType) Package() string { + if l.plan.owner == l.outputPath { + return "" + } + return l.qualify(l.plan.owner) +} + +// Enter links an exact retained child while preserving the output package and +// frozen import alias lookup. +func (l LinkedGoType) Enter(child *GoTypePlan) LinkedGoType { + if child == nil { + panic("enter nil retained Go type plan") + } + return LinkedGoType{plan: child, outputPath: l.outputPath, qualifier: l.qualifier} +} + +// Imports returns every recursively retained import except the linked output +// package itself. +func (l LinkedGoType) Imports() []GoTypeImport { + preferences := l.plan.ImportPreferences() + seen := make(map[string]struct{}) + imports := make([]GoTypeImport, 0, len(preferences)) + for _, preference := range preferences { + if preference.Path == l.outputPath { + continue + } + if _, exists := seen[preference.Path]; exists { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, GoTypeImport{ + Name: l.qualify(preference.Path), + Path: preference.Path, + }) + } + return imports +} + +// plan copies one exact occurrence and recursively retains anonymous child +// layouts. Named types terminate at their canonical declaration binding. +func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName string, parent *expr.AttributeExpr, definitionPointer bool) (*GoTypePlan, error) { + layoutAttribute := attribute + for { + if _, named := layoutAttribute.Type.(expr.UserType); named { + break + } + composite, ok := layoutAttribute.Type.(expr.CompositeExpr) + if !ok { + break + } + layoutAttribute = composite.Attribute() + } + plan := &GoTypePlan{ + owner: owner, + policy: p.policy, + occurrence: attribute, + fieldNameUpper: GoifyAtt(attribute, fieldName, true), + fieldNameLower: GoifyAtt(attribute, fieldName, false), + description: attribute.Description, + tag: AttributeTagsWithName(parent, fieldName, attribute), + definitionPointer: definitionPointer, + } + if attribute.Description != "" { + plan.comment = Comment(attribute.Description) + } + if parent != nil { + field := expr.AsObject(parent.Type).Attribute(fieldName) + switch { + case expr.IsUnion(field.Type): + plan.fieldPointer = p.policy.UnionPointer && (!parent.IsRequired(fieldName) || p.policy.Pointer) + case !p.policy.SumType: + plan.fieldPointer = expr.IsPrimitive(field.Type) && + (p.policy.Pointer || parent.IsPrimitivePointer(fieldName, p.policy.UseDefault)) + default: + plan.fieldPointer = goFieldIsPointer(parent, fieldName, p.policy.Pointer, p.policy.UseDefault) + } + } + + dataType := layoutAttribute.Type + _, rawObject := dataType.(*expr.Object) + plan.referencePointer = !rawObject && (expr.IsObject(dataType) || expr.IsUnion(dataType)) + switch actual := dataType.(type) { + case expr.Primitive: + plan.kind = GoPrimitive + plan.primitive = GoNativeTypeName(actual) + if custom, importSpec := GetMetaType(layoutAttribute); custom != "" { + plan.primitive = custom + if importSpec != nil { + plan.directImport = GoTypeImport{Name: importSpec.Name, Path: importSpec.Path} + plan.hasDirectImport = true + plan.customQualifier = customTypeQualifier(custom, importSpec.Name) + } + } + case *expr.Array: + plan.kind = GoArray + element, err := p.plan(actual.ElemType, owner, "", nil, expr.IsObject(actual.ElemType.Type)) + if err != nil { + return nil, err + } + plan.element = element + case *expr.Map: + plan.kind = GoMap + key, err := p.plan(actual.KeyType, owner, "", nil, expr.IsObject(actual.KeyType.Type)) + if err != nil { + return nil, err + } + element, err := p.plan(actual.ElemType, owner, "", nil, expr.IsObject(actual.ElemType.Type)) + if err != nil { + return nil, err + } + plan.key = key + plan.element = element + case *expr.Object: + plan.kind = GoStruct + plan.fields = make([]*GoTypePlan, len(*actual)) + for index, field := range *actual { + child, err := p.plan(field.Attribute, owner, field.Name, layoutAttribute, false) + if err != nil { + return nil, err + } + plan.fields[index] = child + } + case expr.UserType: + switch actual { + case expr.Empty: + plan.kind = GoEmpty + case expr.ErrorResult: + plan.kind = GoServiceError + goaImport := GoaImport("") + plan.directImport = GoTypeImport{Name: goaImport.Name, Path: goaImport.Path} + plan.hasDirectImport = true + default: + plan.kind = GoNamed + binding, err := p.binding(layoutAttribute, owner, GoNamed) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.typeDeclaration = binding.Type + } + case *expr.Union: + plan.kind = GoUnion + binding, err := p.binding(layoutAttribute, owner, GoUnion) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.unionDeclaration = binding.Union + plan.branches = make([]*GoTypePlan, len(actual.Values)) + for index, branch := range actual.Values { + child, err := p.plan(branch.Attribute, binding.Owner, branch.Name, nil, false) + if err != nil { + return nil, err + } + plan.branches[index] = child + } + default: + return nil, fmt.Errorf("plan Go type: unsupported data type %T", actual) + } + return plan, nil +} + +// binding obtains and validates one exact subsystem-owned declaration record. +func (p goTypePlanner) binding(attribute *expr.AttributeExpr, inheritedOwner string, kind GoTypeKind) (GoTypeBinding, error) { + if p.bind == nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s: declaration binder must not be nil", kind) + } + binding, err := p.bind(GoTypeBindingRequest{ + Attribute: attribute, + InheritedOwner: inheritedOwner, + Kind: kind, + }) + if err != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: %w", kind, attribute.Type.Name(), err) + } + if binding.Owner == "" { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: binding owner must not be empty", kind, attribute.Type.Name()) + } + switch kind { + case GoNamed: + if binding.Type == nil || binding.Union != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go named type %q: binding requires only a type declaration", attribute.Type.Name()) + } + if declarationOwner := binding.Type.PackagePath(); declarationOwner != binding.Owner { + return GoTypeBinding{}, fmt.Errorf( + "plan Go named type %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + case GoUnion: + if binding.Union == nil || binding.Type != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go union %q: binding requires only a union declaration", attribute.Type.Name()) + } + if declarationOwner := binding.Union.PackagePath(); declarationOwner != binding.Owner { + return GoTypeBinding{}, fmt.Errorf( + "plan Go union %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + } + return binding, nil +} + +// walk visits retained plans in stable pre-order without consulting expression +// contents. +func (p *GoTypePlan) walk(visit func(*GoTypePlan)) { + visit(p) + if p.key != nil { + p.key.walk(visit) + } + if p.element != nil { + p.element.walk(visit) + } + for _, field := range p.fields { + field.walk(visit) + } + for _, branch := range p.branches { + branch.walk(visit) + } +} + +// walkImports visits type spellings owned by the referring file. A named +// union's declaration file, not each file that refers to the union, owns the +// imports required by its branch definitions. +func (p *GoTypePlan) walkImports(visit func(*GoTypePlan)) { + visit(p) + if p.kind == GoUnion { + return + } + if p.key != nil { + p.key.walkImports(visit) + } + if p.element != nil { + p.element.walkImports(visit) + } + for _, field := range p.fields { + field.walkImports(visit) + } +} + +// customTypeQualifier returns the package identifier authored in a custom Go +// type. An explicit metadata alias wins; otherwise the first selector supplies +// the identifier while pointer and container syntax remains untouched. +func customTypeQualifier(typeName, alias string) string { + if alias != "" { + return alias + } + dot := strings.IndexByte(typeName, '.') + if dot < 0 { + return "" + } + start := dot + for start > 0 { + char, size := utf8.DecodeLastRuneInString(typeName[:start]) + if !goIdentifierRune(char) { + break + } + start -= size + } + return typeName[start:dot] +} + +// goIdentifierRune reports whether char may occur in a Go identifier. +func goIdentifierRune(char rune) bool { + return char == '_' || unicode.IsLetter(char) || unicode.IsDigit(char) +} + +// qualify resolves one retained external import and rejects an unusable alias. +func (l LinkedGoType) qualify(importPath string) string { + if l.qualifier == nil { + panic(fmt.Sprintf("format retained Go type import %q without qualifier lookup", importPath)) + } + qualifier := l.qualifier(importPath) + if qualifier == "" { + panic(fmt.Sprintf("format retained Go type import %q with empty qualifier", importPath)) + } + return qualifier +} + +// qualifiedDeclaration renders one exact frozen declaration relative to the +// linked output package. +func (l LinkedGoType) qualifiedDeclaration(declaration *NameDeclaration) string { + name := declaration.Name() + if l.plan.owner == l.outputPath { + return name + } + return l.qualify(l.plan.owner) + "." + name +} diff --git a/codegen/go_type_plan_test.go b/codegen/go_type_plan_test.go new file mode 100644 index 0000000000..2e4d6508df --- /dev/null +++ b/codegen/go_type_plan_test.go @@ -0,0 +1,500 @@ +// This file verifies that Go type planning retains every expression-derived +// layout decision before generated package names freeze. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestGoTypePlanRetainsNestedOwners verifies that a binding changes the owner +// inherited by declarations nested beneath the bound occurrence. +func TestGoTypePlanRetainsNestedOwners(t *testing.T) { + const ( + rootOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + branch := goTypeTestUserType("ChoiceText", expr.String) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: branch}}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + unionDeclaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + branchDeclaration := declareGoTypeTestUserType(t, generation, unionOwner, branch) + binder := func(request GoTypeBindingRequest) (GoTypeBinding, error) { + switch request.Attribute.Type { + case union: + require.Equal(t, rootOwner, request.InheritedOwner) + return GoTypeBinding{Owner: unionOwner, Union: unionDeclaration}, nil + case branch: + require.Equal(t, unionOwner, request.InheritedOwner) + return GoTypeBinding{Owner: request.InheritedOwner, Type: branchDeclaration}, nil + default: + return GoTypeBinding{}, fmt.Errorf("unexpected binding for %T", request.Attribute.Type) + } + } + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: rootOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + choice := plan.Fields()[0] + require.Equal(t, GoUnion, choice.Kind()) + require.Equal(t, unionOwner, choice.Owner()) + require.Same(t, unionDeclaration, choice.UnionDeclaration()) + require.Len(t, choice.Branches(), 1) + require.Equal(t, unionOwner, choice.Branches()[0].Owner()) + require.Same(t, branchDeclaration, choice.Branches()[0].TypeDeclaration()) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(rootOwner, goTypeTestQualifier) + require.Equal(t, "struct {\n\tChoice unions.Choice\n}", formatter.Def()) + require.Equal(t, "unions", formatter.Enter(choice).Package()) +} + +// TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport verifies that a +// file referring to a named union does not import packages used only by the +// separate file that defines the union branches. +func TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport(t *testing.T) { + const ( + outputOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + branchOwner = "example.com/branch" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"branch.Value", branchOwner, "branch"}, + }, + }}, + }, + } + declaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + plan, err := PlanGoType(&expr.AttributeExpr{Type: union}, GoTypePlanOptions{ + Owner: outputOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + union: {Owner: unionOwner, Union: declaration}, + }), + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + linked := plan.Link(outputOwner, func(importPath string) string { + require.Equal(t, unionOwner, importPath) + return "unions" + }) + require.Equal(t, "unions.Choice", linked.Name()) + require.Equal(t, []GoTypeImport{{Name: "unions", Path: unionOwner}}, linked.Imports()) +} + +// TestGoTypePlanRetainsFieldMetadata verifies field names, comments, tags, and +// custom primitive import identity are copied during planning. +func TestGoTypePlanRetainsFieldMetadata(t *testing.T) { + field := &expr.AttributeExpr{ + Type: expr.String, + Description: "stored payload bytes", + Meta: expr.MetaExpr{ + "struct:field:name": {"PayloadID"}, + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + "struct:tag:json:name": {"payload_id"}, + "struct:tag:xml": {"payload"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "payload", Attribute: field}, + }} + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plannedField := plan.Fields()[0] + require.Equal(t, "PayloadID", plannedField.FieldName(true)) + require.Equal(t, "payloadID", plannedField.FieldName(false)) + require.Equal(t, "stored payload bytes", plannedField.Description()) + require.Equal(t, " `json:\"payload_id,omitempty\" xml:\"payload\"`", plannedField.Tag()) + goImport, ok := plannedField.Import() + require.True(t, ok) + require.Equal(t, GoTypeImport{Name: "json", Path: "encoding/json"}, goImport) + require.Equal(t, []GoTypeImport{{Name: "json", Path: "encoding/json"}}, plan.ImportPreferences()) + + formatter := plan.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "encoding/json", importPath) + return "json2" + }) + require.Equal(t, "PayloadID", formatter.Enter(plannedField).Field(true)) + require.Equal(t, "struct {\n\t// stored payload bytes\n\tPayloadID *json2.RawMessage `json:\"payload_id,omitempty\" xml:\"payload\"`\n}", formatter.Def()) +} + +// TestGoTypePlanRebindsCustomTypeQualifier verifies that linking changes only +// the imported package identifier and preserves the complete authored Go type. +func TestGoTypePlanRebindsCustomTypeQualifier(t *testing.T) { + const importPath = "example.com/wire" + tests := []struct { + name string + custom string + want string + }{ + {name: "pointer", custom: "*wire.Value", want: "*wire2.Value"}, + {name: "slice", custom: "[]wire.Value", want: "[]wire2.Value"}, + {name: "nested pointer slice", custom: "[]*wire.Value", want: "[]*wire2.Value"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {test.custom, importPath, "wire"}, + }, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "wire2" + }) + require.Equal(t, test.want, linked.Name()) + }) + } +} + +// TestGoTypePlanRetainsServiceErrorImport verifies that the built-in service +// error type uses the frozen alias selected for Goa's runtime package. +func TestGoTypePlanRetainsServiceErrorImport(t *testing.T) { + const ( + owner = "generated.local/gen/service" + goaPath = "goa.design/goa/v3/pkg" + ) + plan, err := PlanGoType(&expr.AttributeExpr{Type: expr.ErrorResult}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Name: "goa", Path: goaPath}}, plan.ImportPreferences()) + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + require.NoError(t, generation.RequireImport(NewImport("goa", "example.com/fixed/goa"))) + for _, preference := range plan.ImportPreferences() { + require.NoError(t, generation.DeclareImport(NewImport(preference.Name, preference.Path))) + } + require.NoError(t, generation.Freeze()) + require.Equal(t, "goa2", generation.ImportName(goaPath)) + + linked := plan.Link(owner, generation.ImportName) + require.Equal(t, "goa2.ServiceError", linked.Name()) + require.Equal(t, "*goa2.ServiceError", linked.Ref()) + require.Equal(t, []GoTypeImport{{Name: "goa2", Path: goaPath}}, linked.Imports()) +} + +// TestGoTypePlanRetainsPointerAndDefaultPolicy verifies field indirection is a +// planning decision rather than a formatting-time expression query. +func TestGoTypePlanRetainsPointerAndDefaultPolicy(t *testing.T) { + withDefault := &expr.AttributeExpr{Type: expr.String, DefaultValue: "ready"} + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "required", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "optional", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + {Name: "defaulted", Attribute: withDefault}, + {Name: "bytes", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"required"}}, + } + + tests := []struct { + name string + pointer bool + want []bool + def string + }{ + { + name: "Goa service policy", + want: []bool{false, true, false, false, true}, + def: "struct {\n\tRequired string\n\tOptional *int\n\tDefaulted string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + { + name: "forced primitive pointers", + pointer: true, + want: []bool{true, true, true, false, true}, + def: "struct {\n\tRequired *string\n\tOptional *int\n\tDefaulted *string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + Pointer: test.pointer, + IgnoreRequired: true, + UseDefault: true, + UnionPointer: true, + SumType: true, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + require.Equal(t, policy, plan.Policy()) + fields := plan.Fields() + for index, want := range test.want { + require.Equal(t, want, fields[index].IsPointer(), fields[index].FieldName(true)) + } + require.Equal(t, test.def, plan.Link(plan.Owner(), goTypeTestQualifier).Def()) + }) + } +} + +// TestGoTypePlanFormatsContainersAndUnions verifies array, map, raw struct, +// named object, and union layouts use their retained child and declaration +// policies after linking. +func TestGoTypePlanFormatsContainersAndUnions(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + item := goTypeTestUserType("Item", &expr.Object{}) + choice := &expr.Union{TypeName: "Choice"} + itemDeclaration := declareGoTypeTestUserType(t, generation, owner, item) + choiceDeclaration := declareGoTypeTestUnion(t, generation, owner, choice) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + item: {Owner: owner, Type: itemDeclaration}, + choice: {Owner: owner, Union: choiceDeclaration}, + }) + tests := []struct { + name string + att *expr.AttributeExpr + kind GoTypeKind + wantName string + wantDef string + wantRef string + }{ + { + name: "array of named objects", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: item}}}, + kind: GoArray, + wantName: "[]*Item", + wantDef: "[]*Item", + wantRef: "[]*Item", + }, + { + name: "map of unions", + att: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: choice}, + }}, + kind: GoMap, + wantName: "map[string]*Choice", + wantDef: "map[string]Choice", + wantRef: "map[string]*Choice", + }, + { + name: "array of raw structs", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.Object{}}}}, + kind: GoArray, + wantName: "[]struct {\n}", + wantDef: "[]*struct {\n}", + wantRef: "[]struct {\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(test.att, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.kind, plan.Kind()) + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, test.wantName, formatter.Name()) + require.Equal(t, test.wantDef, formatter.Def()) + require.Equal(t, test.wantRef, formatter.Ref()) + }) + } +} + +// TestGoTypePlanIgnoresExpressionMutationAfterPlanning verifies formatting +// never revisits type, metadata, descriptions, tags, or required/default state. +func TestGoTypePlanIgnoresExpressionMutationAfterPlanning(t *testing.T) { + const owner = "generated.local/gen/service" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + record := goTypeTestUserType("Record", &expr.Object{}) + declaration := declareGoTypeTestUserType(t, generation, owner, record) + field := &expr.AttributeExpr{ + Type: record, + Description: "the original record", + Meta: expr.MetaExpr{ + "struct:field:name": {"Original"}, + "struct:tag:json:name": {"original"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "record", Attribute: field}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + record: {Owner: owner, Type: declaration}, + }), + }) + require.NoError(t, err) + + field.Type = expr.Int + field.Description = "mutated" + field.DefaultValue = 42 + field.Meta = expr.MetaExpr{ + "struct:field:name": {"Mutated"}, + "struct:field:type": {"time.Time", "time", "time"}, + "struct:tag:json:name": {"mutated"}, + } + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, "struct {\n\t// the original record\n\tOriginal *Record `json:\"original,omitempty\"`\n}", formatter.Def()) + require.Equal(t, "Original", formatter.Enter(plan.Fields()[0]).Field(true)) +} + +// TestGoTypePlanSeparatesImportPreferencesFromLinkedImports verifies planning +// retains every authored alias request while linked files import each path once. +func TestGoTypePlanSeparatesImportPreferencesFromLinkedImports(t *testing.T) { + const importPath = "example.com/shared" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"alpha.Value", importPath, "alpha"}}, + }}, + {Name: "second", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"beta.Value", importPath, "beta"}}, + }}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{ + {Name: "alpha", Path: importPath}, + {Name: "beta", Path: importPath}, + }, plan.ImportPreferences()) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "shared2" + }) + require.Equal(t, []GoTypeImport{{Name: "shared2", Path: importPath}}, linked.Imports()) + require.Equal(t, "struct {\n\tFirst *shared2.Value\n\tSecond *shared2.Value\n}", linked.Def()) +} + +// TestGoTypePlanEquivalence compares symbolic layouts without relying on the +// expression pointers from which independently retained copies were planned. +func TestGoTypePlanEquivalence(t *testing.T) { + firstAttribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Description: "the retained value", + Meta: expr.MetaExpr{ + "struct:field:name": {"ValueID"}, + "struct:tag:json:name": {"value_id"}, + }, + }}, + }} + secondAttribute := expr.DupAtt(firstAttribute) + options := GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + } + first, err := PlanGoType(firstAttribute, options) + require.NoError(t, err) + second, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.True(t, first.Equivalent(second)) + require.True(t, second.Equivalent(first)) + + secondField := (*expr.AsObject(secondAttribute.Type))[0].Attribute + secondField.Meta["struct:field:name"] = []string{"OtherID"} + different, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.False(t, first.Equivalent(different)) + require.False(t, different.Equivalent(first)) +} + +// goTypeTestUserType constructs one named type without running the DSL. +func goTypeTestUserType(name string, dataType expr.DataType) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + } +} + +// declareGoTypeTestUserType adds one exact user type to a generated package. +func declareGoTypeTestUserType(t *testing.T, generation *Generation, owner string, userType expr.UserType) *TypeDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUserType(userType) + require.NoError(t, err) + return declaration +} + +// declareGoTypeTestUnion adds one exact union to a generated package. +func declareGoTypeTestUnion(t *testing.T, generation *Generation, owner string, union *expr.Union) *UnionDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUnion(union) + require.NoError(t, err) + return declaration +} + +// goTypeTestBinder resolves exact test data types to predeclared package records. +func goTypeTestBinder(bindings map[expr.DataType]GoTypeBinding) GoTypeBinder { + return func(request GoTypeBindingRequest) (GoTypeBinding, error) { + binding, ok := bindings[request.Attribute.Type] + if !ok { + return GoTypeBinding{}, fmt.Errorf("no binding for %T %q", request.Attribute.Type, request.Attribute.Type.Name()) + } + return binding, nil + } +} + +// goTypeTestQualifier supplies stable aliases for focused type-plan tests. +func goTypeTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/unions": + return "unions" + case "generated.local/gen/types": + return "types" + default: + return "" + } +} diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go index 71bbdb2f0c..c2896c6790 100644 --- a/codegen/name_declaration.go +++ b/codegen/name_declaration.go @@ -14,6 +14,10 @@ type ( // Types, functions, constants, and variables still share one package namespace. PackageNameKind uint8 + // PackageNameVisibility specifies whether a preferred generated declaration + // is visible outside its Go package. + PackageNameVisibility uint8 + // PackageNameOrder supplies a deterministic total order for preferred names // in one subsystem-owned declaration family. Implementations must be named, // non-pointer value types whose fields recursively contain immutable values. @@ -30,17 +34,18 @@ type ( // NameDeclaration records one package-level Go identifier. Its final name is // unavailable until the owning generation freezes. NameDeclaration struct { - kind PackageNameKind - preferred string - final string - owner *GeneratedPackage - exact bool - order PackageNameOrder - base *NameDeclaration - prefix string - suffix string - hashes []Hasher - frozen bool + kind PackageNameKind + visibility PackageNameVisibility + preferred string + final string + owner *GeneratedPackage + exact bool + order PackageNameOrder + base *NameDeclaration + prefix string + suffix string + hashes []Hasher + frozen bool } ) @@ -55,6 +60,13 @@ const ( NameVariable ) +const ( + // ExportedName makes the preferred generated identifier package-visible. + ExportedName PackageNameVisibility = iota + 1 + // UnexportedName keeps the preferred generated identifier package-private. + UnexportedName +) + // NewExactName creates an authored or external declaration whose exported Go // identifier must not change. The owning generated package rejects collisions. func NewExactName(kind PackageNameKind, preferred string) *NameDeclaration { @@ -69,11 +81,12 @@ func NewExactName(kind PackageNameKind, preferred string) *NameDeclaration { // identifier may receive a deterministic numeric suffix. order must be a // named, non-pointer value whose fields recursively contain immutable values; // the owning package validates that constraint when it accepts the record. -func NewPreferredName(kind PackageNameKind, preferred string, order PackageNameOrder) *NameDeclaration { +func NewPreferredName(kind PackageNameKind, preferred string, visibility PackageNameVisibility, order PackageNameOrder) *NameDeclaration { return &NameDeclaration{ - kind: kind, - preferred: Goify(preferred, true), - order: order, + kind: kind, + visibility: visibility, + preferred: Goify(preferred, visibility == ExportedName), + order: order, } } @@ -142,12 +155,20 @@ func validateNameDeclaration(declaration *NameDeclaration) error { if !declaration.kind.valid() { return fmt.Errorf("invalid package name kind %d", declaration.kind) } + if declaration.base == nil && !declaration.exact && !declaration.visibility.valid() { + return fmt.Errorf("invalid package name visibility %d", declaration.visibility) + } if declaration.preferredName() == "" { return fmt.Errorf("package name must not be empty") } return nil } +// valid reports whether visibility is represented by the preferred-name catalog. +func (v PackageNameVisibility) valid() bool { + return v == ExportedName || v == UnexportedName +} + // validatePackageNameOrder rejects ordering values whose identity or contents // can change after collection. A named value type gives independent generators // a stable family identity without coordinating through caller-chosen strings. diff --git a/codegen/service/client.go b/codegen/service/client.go index 6446e5f72f..fc1ae37ce2 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -6,30 +6,19 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -const ( - // clientStructName is the name of the generated client data structure. - clientStructName = "Client" -) - -// ClientFile returns the client file for the given service. -func ClientFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// clientFile renders the client for the exact service retained by plan. +func clientFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) data := endpointData(svc) path := filepath.Join(codegen.Gendir, svc.PathName, "client.go") - outputPackage := genpkg + "/" + svc.PathName var ( sections []*codegen.SectionTemplate ) { - imports := services.fileImports(outputPackage, []string{ - "context", - "io", - codegen.GoaImport("").Path, - }, serviceReferenceAttributes(service)...) - header := codegen.Header(service.Name+" client", svc.PkgName, imports) + header := codegen.Header(facts.name+" client", svc.PkgName, facts.imports.client.specs) def := &codegen.SectionTemplate{ Name: "client-struct", Source: serviceTemplates.Read(serviceClientT), diff --git a/codegen/service/client_test.go b/codegen/service/client_test.go index 35e454a117..dac329e3f3 100644 --- a/codegen/service/client_test.go +++ b/codegen/service/client_test.go @@ -38,9 +38,9 @@ func TestClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ClientFile("test/gen", root.Services[0], services) + fs := clientFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/conversion_plan.go b/codegen/service/conversion_plan.go new file mode 100644 index 0000000000..f5a69ba987 --- /dev/null +++ b/codegen/service/conversion_plan.go @@ -0,0 +1,563 @@ +// This file retains external Go type conversions before package names freeze. +// The root plan assigns each operation to its generated receiver package, +// declares recursive helpers, and records every reflected package import. +package service + +import ( + "cmp" + "fmt" + "reflect" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // externalConversionDirection identifies which side of a retained mapping + // owns the generated receiver method. + externalConversionDirection uint8 + + // externalConversionNameOrder gives every recursive helper a stable place + // in its generated package without depending on service traversal. + externalConversionNameOrder struct { + receiverID string + externalPkg string + external string + direction externalConversionDirection + source string + target string + occurrence int + required bool + } + + // externalConversionFacts retains one exact reflected mapping and transform + // graph from collection through linked render data. + externalConversionFacts struct { + direction externalConversionDirection + serviceName string + servicePath string + receiverID string + receiverAttribute *expr.AttributeExpr + receiverType *codegen.TypeDeclaration + externalType reflect.Type + externalPath string + externalAlias string + externalAttribute *expr.AttributeExpr + externalPackages map[expr.UserType]string + externalScope *codegen.NameScope + plan *codegen.TransformPlan + methodName string + data *convertData + helpers []*codegen.TransformFunctionData + } + + // externalConversionFileFacts groups the conversion operations and imports + // emitted by one generated receiver package's convert.go file. + externalConversionFileFacts struct { + owner *codegen.GeneratedPackage + operations []*externalConversionFacts + imports retainedFileImports + } + + // externalConversionIdentity identifies one receiver method contract across + // every root in a service planning batch. + externalConversionIdentity struct { + receiver *codegen.TypeDeclaration + direction externalConversionDirection + externalType reflect.Type + externalPath string + } + + // externalConversionResolver qualifies each reflected named type with the + // frozen alias for that type's own Go package. + externalConversionResolver struct { + scope *codegen.AttributeScope + packages map[expr.UserType]string + } +) + +const ( + externalConvertTo externalConversionDirection = iota + 1 + externalCreateFrom +) + +// collectExternalConversions plans every mapping across the complete service +// run once per exact generated receiver package. A relocated receiver shared +// by roots therefore receives one method namespace and one convert.go file. +func collectExternalConversions(roots []*rootFacts, generation *codegen.Generation) error { + files := make(map[*codegen.GeneratedPackage]*externalConversionFileFacts) + fileRoots := make(map[*codegen.GeneratedPackage]*rootFacts) + serviceRoots := make(map[*serviceFacts]*rootFacts) + operations := make(map[externalConversionIdentity]struct{}) + for _, root := range roots { + root.externalConversions = nil + for _, service := range root.services { + serviceRoots[service] = root + } + } + collect := func(mappings []*expr.TypeMap, direction externalConversionDirection) error { + for _, mapping := range mappings { + owners := make(map[*codegen.GeneratedPackage]*serviceFacts) + for _, candidate := range roots { + for _, service := range candidate.services { + if !typeMapMatchesFacts(mapping, service) { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), service.service, codegen.UserTypeLocation(mapping.User), + )) + selected := owners[owner] + if selected == nil || service.packagePath < selected.packagePath { + owners[owner] = service + } + } + } + orderedOwners := make([]*codegen.GeneratedPackage, 0, len(owners)) + for owner := range owners { + orderedOwners = append(orderedOwners, owner) + } + sort.Slice(orderedOwners, func(i, j int) bool { + return orderedOwners[i].ImportPath() < orderedOwners[j].ImportPath() + }) + for _, owner := range orderedOwners { + identity, externalAlias, err := identifyExternalConversion(mapping, owner, direction) + if err != nil { + return err + } + if _, exists := operations[identity]; exists { + return fmt.Errorf( + "duplicate external conversion for receiver %q in package %q and external type %q", + mapping.User.ID(), + identity.receiver.PackagePath(), + identity.externalType.String(), + ) + } + operations[identity] = struct{}{} + operation, err := planExternalConversion( + owners[owner], mapping, owner, identity, externalAlias, generation, + ) + if err != nil { + return err + } + file := files[owner] + if file == nil { + file = &externalConversionFileFacts{owner: owner} + files[owner] = file + } + file.operations = append(file.operations, operation) + candidateRoot := serviceRoots[owners[owner]] + selectedRoot := fileRoots[owner] + if selectedRoot == nil || rootFactsOrder(candidateRoot) < rootFactsOrder(selectedRoot) { + fileRoots[owner] = candidateRoot + } + } + } + return nil + } + for _, root := range roots { + if err := collect(root.root.Conversions, externalConvertTo); err != nil { + return err + } + if err := collect(root.root.Creations, externalCreateFrom); err != nil { + return err + } + } + + for _, file := range files { + if err := finishExternalConversionFile(file, generation); err != nil { + return err + } + owner := fileRoots[file.owner] + owner.externalConversions = append(owner.externalConversions, file) + } + for _, root := range roots { + sort.Slice(root.externalConversions, func(i, j int) bool { + return root.externalConversions[i].owner.ImportPath() < root.externalConversions[j].owner.ImportPath() + }) + } + return nil +} + +// identifyExternalConversion resolves the complete run-wide receiver method +// identity before planning can declare helpers or imports for the operation. +func identifyExternalConversion(mapping *expr.TypeMap, owner *codegen.GeneratedPackage, direction externalConversionDirection) (externalConversionIdentity, string, error) { + externalType := reflect.TypeOf(mapping.External) + if externalType == nil { + return externalConversionIdentity{}, "", fmt.Errorf("external conversion type must not be nil") + } + externalPath, externalAlias, err := getExternalReflectTypeInfo(externalType) + if err != nil { + return externalConversionIdentity{}, "", err + } + receiver, err := owner.Type(mapping.User) + if err != nil { + return externalConversionIdentity{}, "", err + } + return externalConversionIdentity{ + receiver: receiver, + direction: direction, + externalType: externalType, + externalPath: externalPath, + }, externalAlias, nil +} + +// rootFactsOrder returns the stable service identity that owns shared file +// contributions selected from a root. +func rootFactsOrder(facts *rootFacts) string { + paths := make([]string, len(facts.services)) + for index, service := range facts.services { + paths[index] = service.packagePath + } + sort.Strings(paths) + return facts.apiName + "\x00" + strings.Join(paths, "\x00") +} + +// externalConversionFiles returns the package files assigned by batch +// planning and rejects duplicate ownership instead of merging after freeze. +func externalConversionFiles(plans []*Plan) ([]*externalConversionFileFacts, error) { + byOwner := make(map[*codegen.GeneratedPackage]struct{}) + var files []*externalConversionFileFacts + for _, plan := range plans { + for _, retained := range plan.facts.externalConversions { + if _, exists := byOwner[retained.owner]; exists { + return nil, fmt.Errorf( + "external conversion package %q was assigned to more than one service plan", + retained.owner.ImportPath(), + ) + } + byOwner[retained.owner] = struct{}{} + files = append(files, retained) + } + } + sort.Slice(files, func(i, j int) bool { + return files[i].owner.ImportPath() < files[j].owner.ImportPath() + }) + return files, nil +} + +// planExternalConversion reflects one external shape, builds its immutable +// transform graph, and binds each recursive helper to the receiver package. +func planExternalConversion( + service *serviceFacts, + mapping *expr.TypeMap, + owner *codegen.GeneratedPackage, + identity externalConversionIdentity, + externalAlias string, + generation *codegen.Generation, +) (*externalConversionFacts, error) { + externalType := identity.externalType + externalDataType, reflectedTypes, err := buildExternalDesignType(externalType, mapping.User) + if err != nil { + return nil, err + } + externalPackages := make(map[expr.UserType]string, len(reflectedTypes)) + for userType, reflected := range reflectedTypes { + importPath, alias, err := getExternalReflectTypeInfo(reflected) + if err != nil { + return nil, err + } + if err := generation.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { + return nil, err + } + externalPackages[userType.Origin()] = importPath + } + externalPath := identity.externalPath + externalAttribute := &expr.AttributeExpr{Type: externalDataType} + if identity.direction == externalConvertTo { + externalAttribute.AddMeta("struct:type:name", externalDataType.Name()) + } + receiverAttribute := expr.DupAtt(&expr.AttributeExpr{Type: mapping.User}) + source := receiverAttribute + target := externalAttribute + if identity.direction == externalCreateFrom { + source, target = externalAttribute, source + } + transform, err := codegen.NewTransformPlan(source, target) + if err != nil { + return nil, err + } + operation := &externalConversionFacts{ + direction: identity.direction, + serviceName: service.name, + servicePath: service.packagePath, + receiverID: mapping.User.ID(), + receiverAttribute: receiverAttribute, + externalType: externalType, + externalPath: externalPath, + externalAlias: externalAlias, + externalAttribute: externalAttribute, + externalPackages: externalPackages, + externalScope: codegen.NewNameScope(), + plan: transform, + receiverType: identity.receiver, + } + for _, helper := range transform.Helpers() { + sourceName, sourceID := transformDataTypeName(helper.Source.Type) + targetName, targetID := transformDataTypeName(helper.Target.Type) + if identity.direction == externalConvertTo { + targetName = externalAlias + codegen.Goify(targetName, true) + } else { + sourceName = externalAlias + codegen.Goify(sourceName, true) + } + order := externalConversionNameOrder{ + receiverID: mapping.User.ID(), + externalPkg: externalPath, + external: externalType.Name(), + direction: identity.direction, + source: sourceID, + target: targetID, + occurrence: helper.Occurrence, + required: helper.Required, + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "transform"+codegen.Goify(sourceName, true)+"To"+codegen.Goify(targetName, true), + codegen.UnexportedName, + order, + ) + if err := owner.DeclareName(declaration); err != nil { + return nil, err + } + if err := transform.BindHelperDeclaration(helper.ID, declaration); err != nil { + return nil, err + } + } + return operation, nil +} + +// finishExternalConversionFile fixes canonical operation order, assigns +// receiver-scoped method names, and plans the exact imports used by convert.go. +func finishExternalConversionFile(file *externalConversionFileFacts, generation *codegen.Generation) error { + sort.Slice(file.operations, func(i, j int) bool { + return externalConversionOperationLess(file.operations[i], file.operations[j]) + }) + takenByReceiver := make(map[*codegen.TypeDeclaration]map[string]struct{}) + for _, operation := range file.operations { + receiver := operation.receiverType + taken := takenByReceiver[receiver] + if taken == nil { + taken = make(map[string]struct{}) + takenByReceiver[receiver] = taken + } + prefix := "ConvertTo" + if operation.direction == externalCreateFrom { + prefix = "CreateFrom" + } + operation.methodName = uniquify(prefix+operation.externalType.Name(), taken) + } + + definitions := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + references := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + for _, operation := range file.operations { + definitions = append(definitions, operation.receiverAttribute, operation.externalAttribute) + references = append(references, operation.receiverAttribute, operation.externalAttribute) + } + imports, err := retainFileImports( + generation, + file.owner.ImportPath(), + nil, + nil, + definitions, + references, + ) + if err != nil { + return err + } + for _, operation := range file.operations { + for _, importPath := range operation.externalPackages { + addRetainedImportPath(&imports, importPath) + } + } + file.imports = imports + return nil +} + +// externalConversionOperationLess orders retained receiver operations by their +// complete semantic identity independently of root and service traversal. +func externalConversionOperationLess(left, right *externalConversionFacts) bool { + if left.receiverID != right.receiverID { + return left.receiverID < right.receiverID + } + if left.direction != right.direction { + return left.direction < right.direction + } + if left.externalPath != right.externalPath { + return left.externalPath < right.externalPath + } + return left.externalType.Name() < right.externalType.Name() +} + +// linkExternalConversions binds frozen type resolvers and formats every +// retained operation without reflecting types or discovering helpers. +func linkExternalConversions( + facts *rootFacts, + generation *codegen.Generation, + aliases *importAliases, +) error { + for _, file := range facts.externalConversions { + linkFileImports(&file.imports, generation) + for _, operation := range file.operations { + serviceResolver := newRetainedServiceResolver( + generation, + aliases, + operation.serviceName, + operation.servicePath, + file.owner.ImportPath(), + ) + if err := linkExternalConversion(operation, serviceResolver, aliases); err != nil { + return err + } + } + } + return nil +} + +// linkExternalConversion renders one retained graph with frozen service and +// reflected-package aliases selected for its output file. +func linkExternalConversion( + operation *externalConversionFacts, + serviceResolver *declarationResolver, + aliases *importAliases, +) error { + externalResolver := newExternalConversionResolver( + operation.externalScope, + operation.externalPackages, + aliases, + ) + externalContext := &codegen.AttributeContext{ + Scope: externalResolver, + } + serviceContext := &codegen.AttributeContext{ + UseDefault: true, + Scope: serviceResolver, + } + sourceContext, targetContext := serviceContext, externalContext + sourceVar, targetVar := "t", "v" + if operation.direction == externalCreateFrom { + sourceContext, targetContext = externalContext, serviceContext + sourceVar, targetVar = "v", "temp" + } + if err := operation.plan.BindContexts(sourceContext, targetContext); err != nil { + return err + } + code, helpers, err := operation.plan.Render(sourceVar, targetVar, true) + if err != nil { + return err + } + operation.data = &convertData{ + Name: operation.methodName, + ReceiverTypeRef: "*" + operation.receiverType.Name(), + TypeRef: externalResolver.Ref( + operation.externalAttribute, + externalResolver.Package(operation.externalAttribute), + ), + Code: code, + } + if operation.direction == externalConvertTo { + operation.data.TypeName = operation.externalType.Name() + } + operation.helpers = helpers + return nil +} + +// newExternalConversionResolver binds reflected user types to aliases selected +// by the generation-wide import catalog. +func newExternalConversionResolver( + scope *codegen.NameScope, + packages map[expr.UserType]string, + aliases *importAliases, +) *externalConversionResolver { + resolved := make(map[expr.UserType]string, len(packages)) + for userType, importPath := range packages { + resolved[userType.Origin()] = aliases.name(importPath) + } + return &externalConversionResolver{ + scope: codegen.NewAttributeScope(scope), + packages: resolved, + } +} + +// Name renders an external reflected type with the alias for its own package. +func (r *externalConversionResolver) Name(att *expr.AttributeExpr, pkg string, ptr, useDefault bool) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Name(att, pkg, ptr, useDefault) +} + +// Ref renders an external reflected type reference with its own package alias. +func (r *externalConversionResolver) Ref(att *expr.AttributeExpr, pkg string) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Ref(att, pkg) +} + +// Field returns the reflected Go struct field selected for name. +func (r *externalConversionResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return r.scope.Field(att, name, firstUpper) +} + +// Package returns the frozen alias for an external named type. +func (r *externalConversionResolver) Package(att *expr.AttributeExpr) string { + if userType, ok := att.Type.(expr.UserType); ok { + return r.packageName(userType) + } + return "" +} + +// Enter keeps the resolver because each nested named type selects its package +// independently from the attribute currently being transformed. +func (r *externalConversionResolver) Enter(*expr.AttributeExpr) codegen.Attributor { + return r +} + +// IsSumType reports the standard Goa transform representation. +func (r *externalConversionResolver) IsSumType() bool { + return r.scope.IsSumType() +} + +// ValidatorName is not part of external conversion rendering. +func (*externalConversionResolver) ValidatorName(*expr.AttributeExpr, string) string { + panic("external conversion resolver does not own validators") +} + +// Scope returns the lexical scope used for reflected field and local names. +func (r *externalConversionResolver) Scope() *codegen.NameScope { + return r.scope.Scope() +} + +// packageName returns the exact frozen alias for one reflected user type. +func (r *externalConversionResolver) packageName(userType expr.UserType) string { + name, ok := r.packages[userType.Origin()] + if !ok { + panic(fmt.Sprintf("external reflected type %q has no planned package alias", userType.Name())) + } + return name +} + +// ComparePackageName orders external conversion helpers by complete semantic +// operation identity rather than discovery order. +func (o externalConversionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(externalConversionNameOrder) + required := 0 + if o.required != right.required { + if o.required { + required = 1 + } else { + required = -1 + } + } + return cmp.Or( + strings.Compare(o.receiverID, right.receiverID), + strings.Compare(o.externalPkg, right.externalPkg), + strings.Compare(o.external, right.external), + cmp.Compare(o.direction, right.direction), + strings.Compare(o.source, right.source), + strings.Compare(o.target, right.target), + cmp.Compare(o.occurrence, right.occurrence), + required, + ) +} diff --git a/codegen/service/conversion_plan_contract_test.go b/codegen/service/conversion_plan_contract_test.go new file mode 100644 index 0000000000..b71c283c8c --- /dev/null +++ b/codegen/service/conversion_plan_contract_test.go @@ -0,0 +1,387 @@ +// This file verifies external conversions are owned once by their generated +// receiver package and retain every reflected package reference before freeze. +package service + +import ( + "bytes" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" + nestedouter "goa.design/goa/v3/codegen/service/testdata/nested-outer" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestExternalConversionsBelongToGeneratedReceiverPackage catches conversion +// files duplicated by two services that reference one relocated receiver. It +// also compiles two same-named reflected children from distinct Go packages. +func TestExternalConversionsBelongToGeneratedReceiverPackage(t *testing.T) { + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + conversion := renderSingleFileAtPath(t, files, conversionPath) + require.NotContains(t, conversion, "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha") + require.Contains(t, conversion, "nestedalpha.Child") + require.NotContains(t, conversion, "nestedalpha2.Child") + compileGeneratedServiceFiles(t, "generated.local", files) +} + +// TestExternalConversionPlanIgnoresLaterTypeMapMutation proves linked output +// is byte-for-byte determined by facts retained in NewPlan. +func TestExternalConversionPlanIgnoresLaterTypeMapMutation(t *testing.T) { + baseline := retainedServicePlanForPackage(t, externalConversionContractRoot(t), "generated.local/gen") + baselineFiles, err := Files(baseline) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + before := renderSingleFileAtPath(t, baselineFiles, conversionPath) + + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + originalServiceName := root.Services[0].Name + root.Services[0].Name = "MutatedService" + for _, mapping := range append(root.Conversions, root.Creations...) { + mapping.User.Attribute().AddMeta("struct:pkg:path", "mutated/types") + if object := expr.AsObject(mapping.User); object != nil && len(*object) > 0 { + (*object)[0].Attribute.AddMeta( + "struct:field:type", + "mutated.Value", + "mutated.local/value", + "mutated", + ) + } + mapping.User.Rename("Mutated" + mapping.User.Name()) + mapping.User = nil + mapping.External = struct{}{} + } + root.Conversions = nil + root.Creations = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + require.Equal(t, "generated.local/gen/alpha", plan.Services().ServiceImport(originalServiceName).Path) + afterFiles, err := Files(plan) + require.NoError(t, err) + after := renderSingleFileAtPath(t, afterFiles, conversionPath) + require.Equal(t, before, after) + compileGeneratedServiceFiles(t, "generated.local", afterFiles) +} + +// TestExternalConversionOperationsHaveCanonicalOrder catches convert.go output +// that follows TypeMap traversal rather than stable receiver identities. +func TestExternalConversionOperationsHaveCanonicalOrder(t *testing.T) { + forward := externalConversionContractRoot(t) + reverse := externalConversionContractRoot(t) + slices.Reverse(reverse.Conversions) + slices.Reverse(reverse.Creations) + forwardPlan := retainedServicePlanForPackage(t, forward, "generated.local/gen") + reversePlan := retainedServicePlanForPackage(t, reverse, "generated.local/gen") + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Equal( + t, + renderSingleFileAtPath(t, forwardFiles, conversionPath), + renderSingleFileAtPath(t, reverseFiles, conversionPath), + ) +} + +// TestExternalConversionReachabilityCoversEveryServiceValue catches mappings +// omitted when a type is reachable only through a stream or error contract. +func TestExternalConversionReachabilityCoversEveryServiceValue(t *testing.T) { + tests := map[string]func(expr.UserType){ + "streaming payload": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.StreamingPayload(mapped) + dsl.Result(dsl.String) + }) + }) + }, + "mixed streaming result": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(mapped) + }) + }) + }, + "service error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Error("failed", mapped) + dsl.Method("Use", func() {}) + }) + }, + "method error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Error("failed", mapped) + }) + }) + }, + } + for name, use := range tests { + t.Run(name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + mapped := dsl.Type("Mapped", func() { + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + use(mapped) + }) + plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "reach", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + compileGeneratedServiceFiles(t, "generated.local", files) + }) + } +} + +// TestExternalConversionsAggregateAcrossRoots catches root-local conversion +// files that target one generated package and change with root order. +func TestExternalConversionsAggregateAcrossRoots(t *testing.T) { + forwardPlans := convertedRootPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := convertedRootPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *AlphaMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *BetaMapped) ConvertToChild()") + compileGeneratedServiceFiles(t, "generated.local", forwardFiles) +} + +// TestExternalConversionsShareReceiverMethodNamesAcrossRoots catches method +// names assigned independently by roots that contribute operations for the +// same canonical receiver declaration. +func TestExternalConversionsShareReceiverMethodNamesAcrossRoots(t *testing.T) { + forwardPlans := sharedConvertedReceiverPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := sharedConvertedReceiverPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild2()") + compileGeneratedServiceFiles(t, "generated.local", forwardFiles) +} + +// TestNewPlansRejectDuplicateExternalConversionsAcrossRoots proves the batch +// boundary rejects one exact receiver operation instead of inventing X2. +func TestNewPlansRejectDuplicateExternalConversionsAcrossRoots(t *testing.T) { + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedalpha.Child{}, + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "duplicate external conversion") +} + +// externalConversionContractRoot builds one relocated receiver referenced by +// two services and mapped in both conversion directions. +func externalConversionContractRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := dsl.Type("AlphaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + beta := dsl.Type("BetaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + envelope := dsl.Type("Envelope", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedouter.Envelope{}) + dsl.CreateFrom(nestedouter.Envelope{}) + dsl.Attribute("alpha", alpha) + dsl.Attribute("beta", beta) + dsl.Required("alpha", "beta") + }) + for _, service := range []string{"Alpha", "Beta"} { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(envelope) + }) + }) + } + }) +} + +// convertedRootPlans creates two roots whose distinct converted receivers are +// relocated into one generated package, in either discovery order. +func convertedRootPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + roots := []*expr.RootExpr{ + convertedReceiverRoot(t, "Alpha", nestedalpha.Child{}), + convertedReceiverRoot(t, "Beta", nestedbeta.Child{}), + } + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedConvertedReceiverPlans creates two roots that contribute distinct +// same-named external mappings for one exact relocated receiver declaration. +func sharedConvertedReceiverPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedbeta.Child{}, + }) + roots := []*expr.RootExpr{first, second} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// convertedReceiverRoot builds one relocated receiver mapping for the +// multi-root conversion aggregation contract. +func convertedReceiverRoot(t *testing.T, service string, external any) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + mapped := dsl.Type(service+"Mapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(external) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service(service, func() { + dsl.Method("Use", func() { + dsl.Payload(mapped) + }) + }) + }) +} + +// filesAtPath returns every generated file that targets path. +func filesAtPath(files []*codegen.File, path string) []*codegen.File { + var matches []*codegen.File + for _, file := range files { + if file.Path == path { + matches = append(matches, file) + } + } + return matches +} + +// renderSingleFileAtPath renders the unique file targeting path. +func renderSingleFileAtPath(t *testing.T, files []*codegen.File, path string) string { + t.Helper() + matches := filesAtPath(files, path) + require.Len(t, matches, 1) + var rendered bytes.Buffer + for _, section := range matches[0].SectionTemplates { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 58dba41c83..80a3dc3a60 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -10,7 +10,6 @@ import ( "path" "path/filepath" "reflect" - "slices" "strconv" "strings" @@ -32,258 +31,45 @@ type convertData struct { Code string } -// ConvertFiles returns multiple files containing conversion and creation functions, -// grouped by target package as specified by struct:pkg:path metadata. -func ConvertFiles(root *expr.RootExpr, service *expr.ServiceExpr, services *ServicesData) ([]*codegen.File, error) { - // Filter conversion and creation functions that are relevant for this service - svc := services.Get(service.Name) - conversions := relevantTypeMaps(root.Conversions, service, svc) - creations := relevantTypeMaps(root.Creations, service, svc) - - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Group conversions and creations by the package claimed during planning. - allPackages := make(map[*codegen.GeneratedPackage]struct{}) - conversionsByPackage := groupByConvertPackage(conversions, service, services, allPackages) - creationsByPackage := groupByConvertPackage(creations, service, services, allPackages) - - // Generate one file for each owning package. - owners := make([]*codegen.GeneratedPackage, 0, len(allPackages)) - for owner := range allPackages { - owners = append(owners, owner) - } - slices.SortFunc(owners, func(left, right *codegen.GeneratedPackage) int { - return strings.Compare(left.ImportPath(), right.ImportPath()) - }) - files := make([]*codegen.File, 0, len(owners)) - for _, owner := range owners { - file, err := generateConvertFileForPath( - owner, - conversionsByPackage[owner], - creationsByPackage[owner], - service, - services, - ) - if err != nil { - return nil, err - } - if file != nil { - files = append(files, file) - } - } - - return files, nil -} - -// relevantTypeMaps filters the type maps whose user type is a method payload, -// a method result, or a user type of the given service. The returned slice -// drives which ConvertTo/CreateFrom functions ConvertFiles generates. -func relevantTypeMaps(maps []*expr.TypeMap, service *expr.ServiceExpr, svc *Data) []*expr.TypeMap { - var relevant []*expr.TypeMap - for _, c := range maps { - if typeMapMatchesService(c, service, svc) { - relevant = append(relevant, c) - } - } - return relevant -} - -// typeMapMatchesService reports whether the type map's user type is used by -// the service as a method payload, a method result, or a service user type. -func typeMapMatchesService(c *expr.TypeMap, service *expr.ServiceExpr, svc *Data) bool { - for _, m := range service.Methods { - if ut, ok := m.Payload.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - if ut, ok := m.Result.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - } - for _, t := range svc.userTypes { - if c.User.Name() == t.Name { - return true - } - } - return false -} - -// groupByConvertPackage groups type maps by the exact generated package that -// planning assigned to their service type. The owner supplies both the import -// identity and output directory used during rendering. -func groupByConvertPackage(maps []*expr.TypeMap, service *expr.ServiceExpr, services *ServicesData, packages map[*codegen.GeneratedPackage]struct{}) map[*codegen.GeneratedPackage][]*expr.TypeMap { - byPackage := make(map[*codegen.GeneratedPackage][]*expr.TypeMap) - for _, typeMap := range maps { - location := codegen.UserTypeLocation(typeMap.User) - owner := services.generation.Package(generatedPackagePath(services.generation.GenPkg(), service, location)) - byPackage[owner] = append(byPackage[owner], typeMap) - packages[owner] = struct{}{} - } - return byPackage -} - -// generateConvertFileForPath generates a single convert.go file for the given path -// containing the specified conversions and creations -func generateConvertFileForPath( - owner *codegen.GeneratedPackage, - conversions []*expr.TypeMap, - creations []*expr.TypeMap, - service *expr.ServiceExpr, - services *ServicesData, -) (*codegen.File, error) { - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Collect the complete external package paths referenced by this file. - externalPaths := make(map[string]struct{}) - for _, c := range conversions { - pkgImport, _, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err +// convertFiles formats the package-owned external conversion files aggregated +// from every linked root plan. It does not inspect design mappings or allocate +// generated names. +func convertFiles(conversions []*externalConversionFileFacts) []*codegen.File { + files := make([]*codegen.File, len(conversions)) + for index, retained := range conversions { + sections := []*codegen.SectionTemplate{ + codegen.Header( + "External type conversion functions", + codegen.Goify(path.Base(retained.owner.ImportPath()), false), + retained.imports.specs, + ), + } + for _, operation := range retained.operations { + name, source := "convert-to", serviceTemplates.Read(convertT) + if operation.direction == externalCreateFrom { + name, source = "create-from", serviceTemplates.Read(createT) + } + sections = append(sections, &codegen.SectionTemplate{ + Name: name, + Source: source, + Data: operation.data, + }) + } + for _, operation := range retained.operations { + for _, helper := range operation.helpers { + sections = append(sections, &codegen.SectionTemplate{ + Name: "convert-create-helper", + Source: serviceTemplates.Read(transformHelperT), + Data: helper, + }) + } } - externalPaths[pkgImport] = struct{}{} - } - for _, c := range creations { - pkgImport, _, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err + files[index] = &codegen.File{ + Path: filepath.Join(retained.owner.OutputDirectory(), "convert.go"), + SectionTemplates: sections, } - externalPaths[pkgImport] = struct{}{} - } - paths := make([]string, 0, len(externalPaths)) - for importPath := range externalPaths { - paths = append(paths, importPath) - } - - outputPath := owner.ImportPath() - sections := []*codegen.SectionTemplate{ - codegen.Header( - service.Name+" service type conversion functions", - codegen.Goify(path.Base(outputPath), false), - services.fileImports(outputPath, paths), - ), - } - - var ( - names = map[string]struct{}{} - transFuncs []*codegen.TransformFunctionData - ) - - // Build conversion sections if any - for _, c := range conversions { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - pkgImport, _, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err - } - tgtPkg := services.aliases.name(pkgImport) - - srcAtt := &expr.AttributeExpr{Type: c.User} - srcResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(srcAtt) - srcCtx := &codegen.AttributeContext{ - UseDefault: true, - Scope: srcResolver, - } - tgtCtx := codegen.NewAttributeContext(false, false, false, tgtPkg, codegen.NewNameScope()) - tgtAtt := &expr.AttributeExpr{Type: dt} - tgtAtt.AddMeta("struct:type:name", dt.Name()) // Used by transformer to generate the correct type name. - code, tf, err := codegen.GoTransform( - srcAtt, tgtAtt, - "t", "v", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "ConvertTo" + t.Name() - name := uniquify(base, names) - ref := tgtPkg + "." + t.Name() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: srcCtx.Scope.Ref(srcAtt, ""), - TypeName: t.Name(), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-to", - Source: serviceTemplates.Read(convertT), - Data: data, - }) - } - - // Build creation sections if any - for _, c := range creations { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - pkgImport, _, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err - } - srcPkg := services.aliases.name(pkgImport) - srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) - - tgtAtt := &expr.AttributeExpr{Type: c.User} - tgtResolver := newServiceResolver(services.generation, services.aliases, service, outputPath).Enter(tgtAtt) - tgtCtx := &codegen.AttributeContext{ - UseDefault: true, - Scope: tgtResolver, - } - code, tf, err := codegen.GoTransform( - &expr.AttributeExpr{Type: dt}, tgtAtt, - "v", "temp", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "CreateFrom" + t.Name() - name := uniquify(base, names) - ref := srcPkg + "." + t.Name() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: tgtCtx.Scope.Ref(tgtAtt, ""), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "create-from", - Source: serviceTemplates.Read(createT), - Data: data, - }) - } - - // Build transformation helper functions section if any. - seen := make(map[string]struct{}) - for _, tf := range transFuncs { - if _, ok := seen[tf.Name]; ok { - continue - } - seen[tf.Name] = struct{}{} - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-create-helper", - Source: serviceTemplates.Read(transformHelperT), - Data: tf, - }) } - - return &codegen.File{ - Path: filepath.Join(owner.OutputDirectory(), "convert.go"), - SectionTemplates: sections, - }, nil + return files } func commonPath(sep byte, paths ...string) string { @@ -377,12 +163,13 @@ func getPkgImport(pkg, cwd string) string { return pkg } -func getExternalTypeInfo(external any) (string, string, error) { +// getExternalReflectTypeInfo returns the source import path and authored +// package qualifier for one named reflected type. +func getExternalReflectTypeInfo(pkg reflect.Type) (string, string, error) { cwd, err := os.Getwd() if err != nil { return "", "", err } - pkg := reflect.TypeOf(external) pkgImport := getPkgImport(pkg.PkgPath(), cwd) alias := strings.Split(pkg.String(), ".")[0] return pkgImport, alias, nil @@ -406,8 +193,9 @@ func uniquify(base string, taken map[string]struct{}) string { } type dtRec struct { - path string - seen map[string]expr.DataType + path string + seen map[reflect.Type]expr.DataType + named map[expr.UserType]reflect.Type } func appendPath(r dtRec, p string) dtRec { @@ -415,6 +203,22 @@ func appendPath(r dtRec, p string) dtRec { return r } +// buildExternalDesignType returns the reflected design graph and the exact Go +// type behind every named node that may require a package-qualified reference. +func buildExternalDesignType(t reflect.Type, ref expr.DataType) (expr.DataType, map[expr.UserType]reflect.Type, error) { + named := make(map[expr.UserType]reflect.Type) + rec := dtRec{ + path: "", + seen: make(map[reflect.Type]expr.DataType), + named: named, + } + var dataType expr.DataType + if err := buildDesignType(&dataType, t, ref, rec); err != nil { + return nil, nil, err + } + return dataType, named, nil +} + // buildDesignType builds a user type that represents the given external type. // ref is the user type the data type being built is converted to or created // from. It's used to compute the non-generated type field names and can be nil @@ -431,13 +235,14 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs var rec dtRec if recs != nil { rec = recs[0] - if s, ok := rec.seen[t.Name()]; ok { + if s, ok := rec.seen[t]; ok { *dt = s return nil } } else { rec.path = "" - rec.seen = make(map[string]expr.DataType) + rec.seen = make(map[reflect.Type]expr.DataType) + rec.named = make(map[expr.UserType]reflect.Type) } switch t.Kind() { @@ -510,22 +315,26 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs oref = expr.AsObject(ref) } - // Build list of fields that should not be ignored. + // Retain only fields represented by the matching design object. External + // structs may contain additional fields, but generated transforms neither + // read nor write them and therefore must not reserve their package imports. var fields []reflect.StructField for i := 0; i < t.NumField(); i++ { f := t.FieldByIndex([]int{i}) atn, _ := attributeName(oref, f.Name) if oref != nil { - if at := oref.Attribute(atn); at != nil { - if m := at.Meta["struct:field:external"]; len(m) > 0 { - if m[0] == "-" { - continue - } + at := oref.Attribute(atn) + if at == nil { + continue + } + if m := at.Meta["struct:field:external"]; len(m) > 0 { + if m[0] == "-" { + continue } - if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. - if m[0] == "-" { - continue - } + } + if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. + if m[0] == "-" { + continue } } } @@ -540,7 +349,8 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs UID: t.PkgPath() + "#" + t.Name(), } *dt = ut - rec.seen[t.Name()] = ut + rec.seen[t] = ut + rec.named[ut] = t var required []string for i, f := range fields { recf := appendPath(rec, "."+f.Name) diff --git a/codegen/service/convert_test.go b/codegen/service/convert_test.go index c4080b501c..e7f915a42e 100644 --- a/codegen/service/convert_test.go +++ b/codegen/service/convert_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen/service/testdata" + aliasd "goa.design/goa/v3/codegen/service/testdata/alias-external" + "goa.design/goa/v3/codegen/service/testdata/external" "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -345,32 +347,113 @@ func TestConvertFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) + retained, err := externalConversionFiles([]*Plan{plan}) + require.NoError(t, err) + files := convertFiles(retained) - for _, svc := range root.Services { - files, err := ConvertFiles(root, svc, services) - require.NoError(t, err) + // Check expected number of files + require.Equal(t, len(c.ExpectedFiles), len(files)) - // Check expected number of files - require.Equal(t, len(c.ExpectedFiles), len(files)) - - // Verify each expected file - for expectedPath, expectedSections := range c.ExpectedFiles { - found := false - // Normalize expected path for cross-platform compatibility - normalizedExpected := filepath.FromSlash(expectedPath) - for _, file := range files { - if strings.HasSuffix(file.Path, normalizedExpected) { - found = true - require.Equal(t, expectedSections, len(file.SectionTemplates)) - // First section should be header - require.Equal(t, "source-header", file.SectionTemplates[0].Name) - break - } + // Verify each expected file + for expectedPath, expectedSections := range c.ExpectedFiles { + found := false + // Normalize expected path for cross-platform compatibility + normalizedExpected := filepath.FromSlash(expectedPath) + for _, file := range files { + if strings.HasSuffix(file.Path, normalizedExpected) { + found = true + require.Equal(t, expectedSections, len(file.SectionTemplates)) + // First section should be header + require.Equal(t, "source-header", file.SectionTemplates[0].Name) + break } - require.True(t, found, "Expected file %s not found", expectedPath) } + require.True(t, found, "Expected file %s not found", expectedPath) + } + }) + } +} + +// TestConversionPlanSharesHelperDeclarations proves recursive call edges and +// emitted helper definitions use the same package declaration retained by the +// external conversion operation. +func TestConversionPlanSharesHelperDeclarations(t *testing.T) { + root := runDSL(t, func() { + recursive := dsl.Type("Recursive", func() { + dsl.ConvertTo(objRecursiveT{}) + dsl.CreateFrom(objRecursiveT{}) + dsl.Attribute("Foo", dsl.String) + dsl.Attribute("Bar", dsl.Int) + dsl.Attribute("Goo", dsl.Float32) + dsl.Attribute("Goo2", dsl.UInt) + dsl.Attribute("Rec", "Recursive") + dsl.Required("Foo", "Bar", "Goo", "Goo2") + }) + dsl.Service("RecursiveService", func() { + dsl.Method("Read", func() { + dsl.Payload(recursive) + }) + }) + }) + plan := mustServicePlan(t, root) + files := plan.facts.externalConversions + require.Len(t, files, 1) + require.Len(t, files[0].operations, 2) + for _, operation := range files[0].operations { + planned := operation.plan.Helpers() + require.NotEmpty(t, planned) + require.Len(t, operation.helpers, len(planned)) + for index := range planned { + require.Equal(t, planned[index].ID, operation.helpers[index].ID) + require.Same(t, planned[index].Declaration, operation.helpers[index].Declaration) + } + } +} + +// TestConversionMethodNamesUseReceiverNamespaces verifies different receiver +// types may use the same method spelling while collisions on one receiver are +// resolved in stable external-package order. +func TestConversionMethodNamesUseReceiverNamespaces(t *testing.T) { + root := runDSL(t, func() { + foo := dsl.Type("Foo", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.Attribute("Foo", dsl.String) + }) + bar := dsl.Type("Bar", func() { + dsl.ConvertTo(aliasd.ConvertModel{}) + dsl.Attribute("Bar", dsl.String) + }) + empty := dsl.Type("Empty", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.ConvertTo(aliasd.ConvertModel{}) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"Foo", foo}, + {"Bar", bar}, + {"Empty", empty}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + }) } }) + }) + plan := mustServicePlan(t, root) + var operations []*externalConversionFacts + for _, file := range plan.facts.externalConversions { + operations = append(operations, file.operations...) + } + names := make(map[string]string) + for _, operation := range operations { + names[operation.receiverType.Name()+":"+operation.externalPath] = operation.methodName } + require.Equal(t, "ConvertToConvertModel", names["Foo:goa.design/goa/v3/codegen/service/testdata/external"]) + require.Equal(t, "ConvertToConvertModel", names["Bar:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel", names["Empty:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel2", names["Empty:goa.design/goa/v3/codegen/service/testdata/external"]) } diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index aa5b89c220..8cb99d806a 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -19,10 +19,11 @@ type ( declarationResolver struct { generation *codegen.Generation aliases *importAliases - service *expr.ServiceExpr + serviceName string currentPath string outputPath string derived map[expr.UserType]codegen.DerivedTypeID + validators map[validatorKey]*codegen.NameDeclaration view bool } ) @@ -30,11 +31,23 @@ type ( // newServiceResolver resolves declarations starting in service's generated // package and qualifies names relative to outputPath. func newServiceResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, outputPath string) *declarationResolver { + return newRetainedServiceResolver( + generation, + aliases, + service.Name, + servicePackagePath(generation.GenPkg(), service), + outputPath, + ) +} + +// newRetainedServiceResolver starts from a service package identity copied +// during planning rather than reading the service expression after freeze. +func newRetainedServiceResolver(generation *codegen.Generation, aliases *importAliases, serviceName, servicePath, outputPath string) *declarationResolver { return &declarationResolver{ generation: generation, aliases: aliases, - service: service, - currentPath: servicePackagePath(generation.GenPkg(), service), + serviceName: serviceName, + currentPath: servicePath, outputPath: outputPath, } } @@ -44,10 +57,16 @@ func newServiceResolver(generation *codegen.Generation, aliases *importAliases, // identities. func newViewResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" + return newRetainedViewResolver(generation, aliases, service.Name, viewsPath, derived) +} + +// newRetainedViewResolver starts from the views package identity copied during +// planning and never derives it from a mutable service name. +func newRetainedViewResolver(generation *codegen.Generation, aliases *importAliases, serviceName, viewsPath string, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { return &declarationResolver{ generation: generation, aliases: aliases, - service: service, + serviceName: serviceName, currentPath: viewsPath, outputPath: viewsPath, derived: derived, @@ -91,13 +110,13 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe owner := r.owner(att) declaration, err := r.generation.Package(owner).Union(actual) if err != nil { - panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.service.Name, owner, err)) + panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.serviceName, owner, err)) } return r.qualify(owner, declaration.Name()) case expr.CompositeExpr: return r.Name(actual.Attribute(), "", ptr, useDefault) default: - panic(fmt.Sprintf("resolve service type %T for service %q", actual, r.service.Name)) + panic(fmt.Sprintf("resolve service type %T for service %q", actual, r.serviceName)) } } @@ -149,7 +168,7 @@ func (r *declarationResolver) Def(att *expr.AttributeExpr, ptr, useDefault bool) case expr.CompositeExpr: return r.Def(actual.Attribute(), ptr, useDefault) default: - panic(fmt.Sprintf("define service type %T for service %q", actual, r.service.Name)) + panic(fmt.Sprintf("define service type %T for service %q", actual, r.serviceName)) } } @@ -228,11 +247,45 @@ func (r *declarationResolver) bindDerived(origin expr.UserType, identity codegen return &bound } +// withValidators returns a resolver that maps nested validation calls to the +// exact dependent declarations collected by the retained service plan. +func (r *declarationResolver) withValidators(validators map[validatorKey]*codegen.NameDeclaration) *declarationResolver { + bound := *r + bound.validators = validators + return &bound +} + // IsSumType reports that service unions use Goa's generated sum-type structs. func (*declarationResolver) IsSumType() bool { return true } +// ValidatorName returns the exact package-level validator declared for att +// and view before generation names froze. +func (r *declarationResolver) ValidatorName(att *expr.AttributeExpr, view string) string { + declaration := r.validatorDeclaration(att, view) + return r.qualify(r.owner(att), declaration.Name()) +} + +// validatorDeclaration returns the exact retained validator record for att and +// the canonical selected view. +func (r *declarationResolver) validatorDeclaration(att *expr.AttributeExpr, view string) *codegen.NameDeclaration { + userType, ok := att.Type.(expr.UserType) + if !ok { + panic(fmt.Sprintf("resolve validator for non-user type %T", att.Type)) + } + owner := r.owner(att) + declaration := r.userType(owner, userType) + validator := r.validators[validatorKey{declaration: declaration, view: canonicalValidatorView(view)}] + if validator == nil { + panic(fmt.Sprintf( + "validator for type %q view %q was not retained in generated package %q", + userType.Name(), view, owner, + )) + } + return validator +} + // Scope returns the frozen name scope owned by the resolver's current package. func (r *declarationResolver) Scope() *codegen.NameScope { return r.generation.Package(r.currentPath).Scope() @@ -256,13 +309,13 @@ func (r *declarationResolver) userType(owner string, userType expr.UserType) *co if identity, ok := r.derived[userType.Origin()]; ok { declaration, err := generatedPackage.DerivedType(identity) if err != nil { - panic(fmt.Sprintf("resolve derived type %q for service %q in package %q: %v", userType.Name(), r.service.Name, owner, err)) + panic(fmt.Sprintf("resolve derived type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) } return declaration } declaration, err := generatedPackage.Type(userType) if err != nil { - panic(fmt.Sprintf("resolve user type %q for service %q in package %q: %v", userType.Name(), r.service.Name, owner, err)) + panic(fmt.Sprintf("resolve user type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) } return declaration } diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index 1acbad3697..121430766c 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -108,6 +108,13 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( servicePackage := mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) localDeclaration, err := servicePackage.DeclareUserType(local) require.NoError(t, err) + errorConstructor := codegen.NewPreferredName( + codegen.NameFunction, + "MakeFault", + codegen.ExportedName, + serviceNameOrder{role: serviceErrorConstructorNameRole, subject: "fault"}, + ) + require.NoError(t, servicePackage.DeclareName(errorConstructor)) errorsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/errors") _, err = errorsPackage.DeclareUserType(relocated) require.NoError(t, err) @@ -130,21 +137,6 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( require.Equal(t, "Fault", localDeclaration.Name()) require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) - errorData := buildErrorInitData(&expr.ErrorExpr{ - AttributeExpr: &expr.AttributeExpr{Type: relocated}, - Name: "fault", - }, resolver) - require.Equal(t, "errors_.Fault", errorData.TypeName) - require.Equal(t, "errors_.Fault", errorData.TypeRef) - - attributes := collectAttributes( - &expr.AttributeExpr{Type: &expr.Object{ - {Name: "fault", Attribute: &expr.AttributeExpr{Type: expr.String}}, - }}, - &expr.AttributeExpr{Type: container}, - resolver, - ) - require.Equal(t, "errors_.Fault", attributes[0].TypeRef) } // TestDeclarationResolverPanicsWhenPlanOmittedType verifies render analysis diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index 83fe57d496..aa5801c681 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -8,23 +8,30 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type ( // EndpointsData contains the data necessary to render the // service endpoints struct template. EndpointsData struct { + // EndpointsDeclaration is the exact package-level endpoint collection. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor. + NewClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface. + ServiceDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact server interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration // Name is the service name. Name string // Description is the service description. Description string - // VarName is the endpoint struct name. - VarName string - // ClientVarName is the client struct name. - ClientVarName string - // ServiceVarName is the service interface name. - ServiceVarName string // Methods lists the endpoint struct methods. Methods []*EndpointMethodData // ClientInitArgs lists the arguments needed to instantiate the client. @@ -43,6 +50,10 @@ type ( // EndpointMethodData describes a single endpoint method. EndpointMethodData struct { *MethodData + // ClientDeclaration is the exact package-level client used as the method receiver. + ClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface accepted by the endpoint constructor. + ServiceDeclaration *codegen.NameDeclaration // ArgName is the name of the argument used to initialize the client // struct method field. ArgName string @@ -51,42 +62,23 @@ type ( // // It is only set when HasMixedResults is true. StreamArgName string - // ClientVarName is the corresponding client struct field name. - ClientVarName string // ServiceName is the name of the owner service. ServiceName string - // ServiceVarName is the name of the owner service Go interface. - ServiceVarName string } ) -const ( - // endpointsStructName is the name of the generated endpoints data - // structure. - endpointsStructName = "Endpoints" - - // serviceInterfaceName is the name of the generated service interface. - serviceInterfaceName = "Service" -) - -// EndpointFile returns the endpoint file for the given service. -func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// endpointFile renders the endpoints for the exact service retained by plan. +func endpointFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName path := filepath.Join(codegen.Gendir, svcName, "endpoints.go") - outputPackage := genpkg + "/" + svcName data := endpointData(svc) var ( sections []*codegen.SectionTemplate ) { - imports := services.fileImports(outputPackage, []string{ - "context", - "io", - codegen.GoaImport("").Path, - codegen.GoaImport("security").Path, - }, serviceReferenceAttributes(service)...) - header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) + header := codegen.Header(facts.name+" endpoints", svc.PkgName, facts.imports.endpoint.specs) def := &codegen.SectionTemplate{ Name: "endpoints-struct", Source: serviceTemplates.Read(serviceEndpointsT), @@ -158,26 +150,30 @@ func endpointData(svc *Data) *EndpointsData { names = append(names, streamArgName) } methods[i] = &EndpointMethodData{ - MethodData: m, - ArgName: argName, - StreamArgName: streamArgName, - ServiceName: svc.Name, - ServiceVarName: serviceInterfaceName, - ClientVarName: clientStructName, + MethodData: m, + ClientDeclaration: svc.ClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ArgName: argName, + StreamArgName: streamArgName, + ServiceName: svc.Name, } } - desc := fmt.Sprintf("%s wraps the %q service endpoints.", endpointsStructName, svc.Name) + desc := fmt.Sprintf("%s wraps the %q service endpoints.", svc.EndpointsDeclaration.Name(), svc.Name) return &EndpointsData{ - Name: svc.Name, - Description: desc, - VarName: endpointsStructName, - ClientVarName: clientStructName, - ServiceVarName: serviceInterfaceName, - ClientInitArgs: strings.Join(names, ", "), - Methods: methods, - Schemes: svc.Schemes, - HasServerInterceptors: len(svc.ServerInterceptors) > 0, - HasClientInterceptors: len(svc.ClientInterceptors) > 0, + EndpointsDeclaration: svc.EndpointsDeclaration, + NewEndpointsDeclaration: svc.NewEndpointsDeclaration, + ClientDeclaration: svc.ClientDeclaration, + NewClientDeclaration: svc.NewClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ServerInterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + ClientInterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + Name: svc.Name, + Description: desc, + ClientInitArgs: strings.Join(names, ", "), + Methods: methods, + Schemes: svc.Schemes, + HasServerInterceptors: len(svc.ServerInterceptors) > 0, + HasClientInterceptors: len(svc.ClientInterceptors) > 0, } } diff --git a/codegen/service/endpoint_test.go b/codegen/service/endpoint_test.go index aa07cfb44d..0bc3e05d1d 100644 --- a/codegen/service/endpoint_test.go +++ b/codegen/service/endpoint_test.go @@ -40,9 +40,9 @@ func TestEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("goa.design/goa/example", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/example_generator_test.go b/codegen/service/example_generator_test.go index 9de44669a4..e9a8e61162 100644 --- a/codegen/service/example_generator_test.go +++ b/codegen/service/example_generator_test.go @@ -24,12 +24,12 @@ func TestServicesDataRetainsRunExampleGenerator(t *testing.T) { root.API.RandomizerFactory = expr.NewDeterministicRandomizerFactory() generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, Plan(root, generation)) - require.NoError(t, generation.Freeze()) examples := expr.NewExampleGenerator(root.API.RandomizerFactory) - - services, err := NewServicesData(root, generation, examples) - + plan, err := NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() require.NoError(t, err) attribute := &expr.AttributeExpr{Type: expr.String} method := root.Services[0].Methods[0] @@ -38,7 +38,7 @@ func TestServicesDataRetainsRunExampleGenerator(t *testing.T) { require.Equal(t, "abc123", services.FieldExample(attribute, attribute, "value", owner)) } -func TestRepeatedServiceAnalysisKeepsAnonymousExamplesStable(t *testing.T) { +func TestRepeatedServiceReadsKeepAnonymousExamplesStable(t *testing.T) { root := codegen.RunDSL(t, func() { dsl.Service("Values", func() { dsl.Method("Primitive", func() { @@ -57,14 +57,13 @@ func TestRepeatedServiceAnalysisKeepsAnonymousExamplesStable(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, Plan(root, generation)) - require.NoError(t, generation.Freeze()) examples := expr.NewExampleGenerator(root.API.RandomizerFactory) - - first, err := NewServicesData(root, generation, examples) - require.NoError(t, err) - second, err := NewServicesData(root, generation, examples) + plan, err := NewPlan(root, generation, examples) require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + first := plan.Services() + second := plan.Services() require.Len(t, first.Get("Values").Methods, 3) require.Len(t, second.Get("Values").Methods, 3) for index, firstMethod := range first.Get("Values").Methods { diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index 0ba4a7fa81..f609eaf847 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -9,48 +9,63 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ExampleInterceptorsFiles returns the files for the example server and client interceptors. -func ExampleInterceptorsFiles(genpkg string, r *expr.RootExpr, services *ServicesData) []*codegen.File { +type ( + // exampleInterceptorData contains the canonical declarations and service + // metadata rendered by one starter interceptor implementation. + exampleInterceptorData struct { + // ServiceName is the design service name described by the comments. + ServiceName string + // ServicePkg is the generated service package qualifier. + ServicePkg string + // StructDeclaration is the starter interceptor implementation type. + StructDeclaration *codegen.NameDeclaration + // ConstructorDeclaration creates StructDeclaration. + ConstructorDeclaration *codegen.NameDeclaration + // Interceptors contains the interceptor methods implemented by the type. + Interceptors []*InterceptorData + } +) + +// ExampleInterceptorsFiles returns starter server and client interceptor files +// for every service retained by plan. +func ExampleInterceptorsFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range r.Services { - if f := exampleInterceptorsFile(genpkg, svc, services); f != nil { + for _, facts := range plan.facts.services { + if f := exampleInterceptorsFile(plan, facts); f != nil { fw = append(fw, f...) } } return fw } -// exampleInterceptorsFile returns the example interceptors for the given service. -func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *ServicesData) []*codegen.File { - sdata := services.Get(svc.Name) +// exampleInterceptorsFile renders starter interceptors from one retained +// service. +func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { + genpkg := plan.generation.GenPkg() + services := plan.Services() + sdata := services.Get(facts.name) servicePath := path.Join(genpkg, sdata.PathName) - data := map[string]any{ - "ServiceName": sdata.Name, - "StructName": sdata.StructName, - "PkgName": services.aliases.name(servicePath), - "ServerInterceptors": sdata.ServerInterceptors, - "ClientInterceptors": sdata.ClientInterceptors, - } + servicePkg := services.aliases.name(servicePath) var files []*codegen.File // Generate server interceptor if needed and file doesn't exist if len(sdata.ServerInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleServerStruct, + ConstructorDeclaration: facts.exampleServerConstructor, + Interceptors: sdata.ServerInterceptors, + } serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") if _, err := os.Stat(serverPath); os.IsNotExist(err) { - imports := services.fileImports("", []string{ - "context", - "goa.design/clue/log", - codegen.GoaImport("").Path, - servicePath, - }) files = append(files, &codegen.File{ Path: serverPath, SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", imports), + codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", facts.imports.exampleServerInterceptors.specs), { Name: "example-server-interceptor", Source: serviceTemplates.Read(exampleServerInterceptorT), @@ -63,18 +78,19 @@ func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *Ser // Generate client interceptor if needed and file doesn't exist if len(sdata.ClientInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleClientStruct, + ConstructorDeclaration: facts.exampleClientConstructor, + Interceptors: sdata.ClientInterceptors, + } clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") if _, err := os.Stat(clientPath); os.IsNotExist(err) { - imports := services.fileImports("", []string{ - "context", - "goa.design/clue/log", - codegen.GoaImport("").Path, - servicePath, - }) files = append(files, &codegen.File{ Path: clientPath, SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", imports), + codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", facts.imports.exampleClientInterceptors.specs), { Name: "example-client-interceptor", Source: serviceTemplates.Read(exampleClientInterceptorT), diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index 5cf1252233..4a3d2ff2de 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" ) @@ -86,12 +87,13 @@ func TestExampleInterceptorsFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { // Run DSL root := runDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.NotNil(t, root) // Generate files - fs := ExampleInterceptorsFiles(services.generation.GenPkg(), root, services) + fs := ExampleInterceptorsFiles(plan) require.Len(t, fs, len(c.ExpectedFiles)) + assertExampleInterceptorDeclarations(t, plan, fs) // Verify file paths paths := make([]string, len(fs)) @@ -123,3 +125,25 @@ func TestExampleInterceptorsFiles(t *testing.T) { }) } } + +// assertExampleInterceptorDeclarations verifies that starter definitions and +// constructor bodies use the exact declarations retained by the service plan. +func assertExampleInterceptorDeclarations(t *testing.T, plan *Plan, files []*codegen.File) { + t.Helper() + retained := make(map[*codegen.NameDeclaration]*codegen.NameDeclaration) + for _, facts := range plan.facts.services { + if facts.exampleServerStruct != nil { + retained[facts.exampleServerStruct] = facts.exampleServerConstructor + } + if facts.exampleClientStruct != nil { + retained[facts.exampleClientStruct] = facts.exampleClientConstructor + } + } + for _, file := range files { + data, ok := file.SectionTemplates[1].Data.(*exampleInterceptorData) + require.True(t, ok) + constructor, ok := retained[data.StructDeclaration] + require.True(t, ok, "starter interceptor struct was not retained by the plan") + require.Same(t, constructor, data.ConstructorDeclaration) + } +} diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index f87b6d489d..32789c48c1 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -5,10 +5,8 @@ package service import ( "os" "path" - "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type ( @@ -32,6 +30,8 @@ type ( // StreamInterface is the stream interface in the service package used // by the endpoint implementation. StreamInterface string + // ExampleStructDeclaration is the starter implementation receiver. + ExampleStructDeclaration *codegen.NameDeclaration } // exampleServiceData separates the generated service package declaration @@ -43,32 +43,23 @@ type ( } ) -// ExampleServiceFiles returns a basic service implementation for every -// service expression. -func ExampleServiceFiles(genpkg string, root *expr.RootExpr, services *ServicesData) []*codegen.File { - // determine the unique API package name different from the service names - scope := codegen.NewNameScope() - for _, svc := range root.Services { - s := services.Get(svc.Name) - if s == nil { - panic("unknown service, " + svc.Name) // bug - } - scope.Unique(s.PkgName) - } - apipkg := scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") - +// ExampleServiceFiles returns a basic implementation for every service +// retained by plan. +func ExampleServiceFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range root.Services { - if f := exampleServiceFile(genpkg, root, svc, services, apipkg); f != nil { + for _, facts := range plan.facts.services { + if f := exampleServiceFile(plan, facts, plan.facts.examplePackageName); f != nil { fw = append(fw, f) } } return fw } -// exampleServiceFile returns a basic implementation of the given service. -func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, services *ServicesData, apipkg string) *codegen.File { - data := services.Get(svc.Name) +// exampleServiceFile renders a basic implementation from one retained service. +func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen.File { + genpkg := plan.generation.GenPkg() + services := plan.Services() + data := services.Get(facts.name) svcName := data.PathName servicePath := path.Join(genpkg, svcName) servicePkg := services.aliases.name(servicePath) @@ -77,17 +68,8 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, if _, err := os.Stat(fpath); !os.IsNotExist(err) { return nil // file already exists, skip it. } - specs := services.fileImports(path.Dir(genpkg), []string{ - "io", - "context", - "fmt", - "strings", - servicePath, - "goa.design/clue/log", - codegen.GoaImport("security").Path, - }, serviceReferenceAttributes(svc)...) sections := []*codegen.SectionTemplate{ - codegen.Header("", apipkg, specs), + codegen.Header("", apipkg, facts.imports.exampleService.specs), { Name: "basic-service-struct", Source: serviceTemplates.Read(exampleServiceStructT), @@ -105,9 +87,9 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, Data: renderData, }) } - resolver := newServiceResolver(services.generation, services.aliases, svc, path.Dir(genpkg)) - for _, m := range svc.Methods { - sections = append(sections, basicEndpointSection(m, data, resolver, servicePkg)) + outputPath := path.Dir(genpkg) + for _, method := range facts.orderedMethods { + sections = append(sections, basicEndpointSection(method, data, outputPath, services.aliases, servicePkg)) } // Add HandleStream method for JSON-RPC WebSocket services (not SSE) @@ -128,29 +110,27 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, // basicEndpointSection returns a starter implementation whose payload and // result references come from the method's frozen generated-package records. -func basicEndpointSection(m *expr.MethodExpr, svcData *Data, resolver *declarationResolver, servicePkg string) *codegen.SectionTemplate { - md := svcData.Method(m.Name) +func basicEndpointSection(facts *methodFacts, svcData *Data, outputPath string, aliases *importAliases, servicePkg string) *codegen.SectionTemplate { + md := svcData.Method(facts.name) ed := &basicEndpointData{ - MethodData: md, - ServiceVarName: svcData.VarName, + MethodData: md, + ServiceVarName: svcData.VarName, + ExampleStructDeclaration: svcData.ExampleStructDeclaration, } - if m.Payload.Type != expr.Empty { - ed.PayloadFullRef = resolver.Ref(m.Payload, "") + if facts.payload != nil && facts.payload.layout.Kind() != codegen.GoEmpty { + ed.PayloadFullRef = facts.payload.layout.Link(outputPath, retainedTypeQualifier(aliases)).Ref() } - if m.Result.Type != expr.Empty { - ed.ResultFullName = resolver.Name(m.Result, "", false, true) - ed.ResultFullRef = resolver.Ref(m.Result, "") - ed.ResultIsStruct = expr.IsObject(m.Result.Type) + if facts.result != nil && facts.result.layout.Kind() != codegen.GoEmpty { + linked := facts.result.layout.Link(outputPath, retainedTypeQualifier(aliases)) + ed.ResultFullName = linked.Name() + ed.ResultFullRef = linked.Ref() + ed.ResultIsStruct = facts.result.isObject if md.ViewedResult != nil { - view := expr.DefaultView - if v, ok := m.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v - } - ed.ResultView = view + ed.ResultView = facts.viewedResult.viewName } } if md.ServerStream != nil { - ed.StreamInterface = servicePkg + "." + md.ServerStream.Interface + ed.StreamInterface = servicePkg + "." + md.ServerStreamDeclaration.Name() } return &codegen.SectionTemplate{ Name: "basic-endpoint", diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 9a332e9c19..64329d7733 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -34,9 +34,9 @@ func TestExampleServiceFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 3) - fs := ExampleServiceFiles(services.generation.GenPkg(), root, services) + fs := ExampleServiceFiles(plan) require.Len(t, fs, 3) for _, f := range fs { require.Greater(t, len(f.SectionTemplates), 0) diff --git a/codegen/service/generated_emission.go b/codegen/service/generated_emission.go new file mode 100644 index 0000000000..845067c392 --- /dev/null +++ b/codegen/service/generated_emission.go @@ -0,0 +1,124 @@ +// This file attaches linked service render data to the exact relocated type +// and union emission records selected by batch planning before names freeze. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" +) + +// generatedPackage returns the retained render data for one exact generated +// package, creating the render container without changing package ownership. +func (d *ServicesData) generatedPackage(importPath string) *generatedPackageData { + owner := d.generation.Package(importPath) + if generatedPackage, ok := d.packages[owner]; ok { + return generatedPackage + } + generatedPackage := &generatedPackageData{ + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), + } + d.packages[owner] = generatedPackage + return generatedPackage +} + +// registerPackageData attaches linked template data to the exact type and +// union emission records selected by the batch planner before freeze. +func (d *ServicesData) registerPackageData() { + for _, emission := range d.facts.generatedTypes { + section, errorSection := generatedTypeSections(emission) + generatedPackage := d.generatedPackage(emission.declaration.PackagePath()) + generatedPackage.types[emission.declaration] = &generatedTypeData{ + declaration: emission.declaration, + location: emission.location, + imports: emission.service.generatedTypeImports[emission.declaration].specs, + section: section, + error: errorSection, + } + } + for _, emission := range d.facts.generatedUnions { + generatedPackage := d.generatedPackage(emission.union.declaration.PackagePath()) + generatedPackage.unions[emission.union.declaration] = emission.union.data + generatedPackage.unionImports = appendImportSpecs( + generatedPackage.unionImports, + emission.union.imports.specs, + ) + } +} + +// generatedTypeSections formats the selected template family from linked data +// retained on the owning service and method facts. +func generatedTypeSections(emission *generatedTypeEmissionFacts) (*codegen.SectionTemplate, *codegen.SectionTemplate) { + if emission.method != nil { + var methodData *MethodData + for index, method := range emission.service.orderedMethods { + if method == emission.method { + methodData = emission.service.data.Methods[index] + break + } + } + switch emission.kind { + case generatedPayloadEmission: + return &codegen.SectionTemplate{Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: methodData}, nil + case generatedStreamingPayloadEmission: + return &codegen.SectionTemplate{Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: methodData}, nil + case generatedResultEmission: + return &codegen.SectionTemplate{Name: "service-result", Source: serviceTemplates.Read(resultT), Data: methodData}, nil + case generatedStreamingResultEmission: + return &codegen.SectionTemplate{ + Name: "service-streaming-result", + Source: serviceTemplates.Read(resultT), + Data: map[string]any{ + "Result": methodData.StreamingResult, + "ResultDef": methodData.StreamingResultDef, + "ResultDesc": methodData.StreamingResultDesc, + }, + }, nil + } + } + data := generatedUserTypeData(emission) + name := "service-user-type" + if emission.kind == generatedErrorTypeEmission { + name = "error-user-type" + } + section := &codegen.SectionTemplate{Name: name, Source: serviceTemplates.Read(userTypeT), Data: data} + if !emission.error { + return section, nil + } + return section, &codegen.SectionTemplate{Name: "service-error", Source: serviceTemplates.Read(errorT), Data: data} +} + +// generatedUserTypeData returns the linked record for one retained authored +// type declaration. +func generatedUserTypeData(emission *generatedTypeEmissionFacts) *UserTypeData { + candidates := emission.service.data.userTypes + if emission.kind == generatedErrorTypeEmission { + candidates = emission.service.data.errorTypes + } + for _, candidate := range candidates { + if candidate.Declaration == emission.declaration { + return candidate + } + } + panic(fmt.Sprintf("generated type %q has no linked render data", emission.declaration.Name())) +} + +// copyGeneratedLocation retains location metadata before callers can mutate +// the expression graph after planning. +func copyGeneratedLocation(location *codegen.Location) *codegen.Location { + if location == nil { + return nil + } + copy := *location + return © +} + +// sameGeneratedLocation compares the explicit package and file selected for a +// generated declaration. +func sameGeneratedLocation(left, right *codegen.Location) bool { + if left == nil || right == nil { + return left == right + } + return left.RelImportPath == right.RelImportPath && left.FilePath == right.FilePath +} diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index e0114f31dc..9f94af1cea 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -8,6 +8,7 @@ import ( "fmt" "path" "slices" + "sort" "strings" "goa.design/goa/v3/codegen" @@ -15,6 +16,32 @@ import ( ) type ( + // generatedTypeEmissionKind identifies the template family that defines one + // retained relocated type declaration. + generatedTypeEmissionKind uint8 + + // generatedTypeEmissionFacts selects one canonical relocated declaration + // before names freeze and retains the linked data owner used by rendering. + generatedTypeEmissionFacts struct { + kind generatedTypeEmissionKind + declaration *codegen.TypeDeclaration + location *codegen.Location + root *rootFacts + service *serviceFacts + method *methodFacts + attribute *methodAttributeFacts + userType *userTypeFacts + error bool + } + + // generatedUnionEmissionFacts selects one canonical union definition before + // names freeze and retains its linked render data. + generatedUnionEmissionFacts struct { + root *rootFacts + service *serviceFacts + union *unionFacts + } + // plannedAttribute identifies one service attribute and the package inherited // by nested types that do not select their own struct:pkg:path location. plannedAttribute struct { @@ -46,38 +73,49 @@ type ( // generatedPackageData owns the render data emitted into one Go package. generatedPackageData struct { - types map[*codegen.TypeDeclaration]*generatedTypeData - unions map[codegen.UnionTypeID]*UnionTypeData + types map[*codegen.TypeDeclaration]*generatedTypeData + unions map[*codegen.UnionDeclaration]*UnionTypeData + unionImports []*codegen.ImportSpec } // generatedTypeData owns one relocated user-type declaration and optional // error behavior at its metadata-selected file. generatedTypeData struct { declaration *codegen.TypeDeclaration - userType expr.UserType location *codegen.Location + imports []*codegen.ImportSpec section *codegen.SectionTemplate error *codegen.SectionTemplate } ) -// Plan declares every relocated user type and union reachable from root. +const ( + generatedPayloadEmission generatedTypeEmissionKind = iota + 1 + generatedStreamingPayloadEmission + generatedResultEmission + generatedStreamingResultEmission + generatedUserTypeEmission + generatedErrorTypeEmission +) + +// collectServiceDeclarations declares every relocated user type and union reachable from root. // User types are declared across the complete root before any union so exact // user-authored names always take precedence over generated union names. -func Plan(root *expr.RootExpr, generation *codegen.Generation) error { - if !generation.HasRoot(root) { - return rootMembershipError(root) - } - inputs := planningInputs(root) - rootTypes := newRootTypeSet(root) - for _, service := range root.Services { +func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation) error { + if !generation.HasRoot(facts.root) { + return rootMembershipError(facts.root) + } + inputs := planningInputs(facts) + rootTypes := facts.rootTypes + for _, serviceFacts := range facts.services { + service := serviceFacts.service // The service package record makes NewServicesData a render-only contract: // its scope is unavailable until the generation freezes. if _, err := generation.ClaimPackage(servicePackagePath(generation.GenPkg(), service)); err != nil { return err } } - methodTypes, err := planMethodTypes(root, generation) + methodTypes, err := planMethodTypes(facts, generation) if err != nil { return err } @@ -95,10 +133,305 @@ func Plan(root *expr.RootExpr, generation *codegen.Generation) error { return err } } - if err := planViews(root, generation, rootTypes); err != nil { + if err := planViews(facts, generation); err != nil { + return err + } + return nil +} + +// collectGeneratedPackageEmissions selects one owner for every relocated type +// and union declaration across all roots before the generation freezes. +func collectGeneratedPackageEmissions(roots []*rootFacts) error { + types := make(map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts) + unions := make(map[*codegen.UnionDeclaration]*generatedUnionEmissionFacts) + for _, root := range roots { + root.generatedTypes = nil + root.generatedUnions = nil + for _, service := range root.services { + for _, union := range service.unions { + emission := &generatedUnionEmissionFacts{root: root, service: service, union: union} + if existing := unions[union.declaration]; existing != nil { + if err := validateGeneratedUnionEmission(existing, emission); err != nil { + return err + } + if generatedUnionEmissionLess(emission, existing) { + unions[union.declaration] = emission + } + } else { + unions[union.declaration] = emission + } + } + for _, method := range service.orderedMethods { + candidates := []struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{ + {generatedPayloadEmission, method.payload}, + {generatedStreamingPayloadEmission, method.streamingPayload}, + {generatedResultEmission, method.result}, + } + if method.hasMixedResults { + candidates = append(candidates, struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{generatedStreamingResultEmission, method.streamingResult}) + } + for _, candidate := range candidates { + attribute := candidate.attribute + if attribute == nil || attribute.location == nil || !attribute.normalized || attribute.definition == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: candidate.kind, + declaration: attribute.layout.TypeDeclaration(), + location: copyGeneratedLocation(attribute.location), + root: root, + service: service, + method: method, + attribute: attribute, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + for _, userType := range service.userTypes { + if userType.location == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: generatedUserTypeEmission, + declaration: userType.declaration, + location: copyGeneratedLocation(userType.location), + root: root, + service: service, + userType: userType, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + for _, errorType := range service.errorTypes { + if errorType.location == nil || errorType.serviceError { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: generatedErrorTypeEmission, + declaration: errorType.declaration, + location: copyGeneratedLocation(errorType.location), + root: root, + service: service, + userType: errorType, + error: true, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + } + for _, emission := range types { + emission.root.generatedTypes = append(emission.root.generatedTypes, emission) + } + for _, emission := range unions { + emission.root.generatedUnions = append(emission.root.generatedUnions, emission) + } + for _, root := range roots { + sort.Slice(root.generatedTypes, func(i, j int) bool { + return generatedTypeEmissionLess(root.generatedTypes[i], root.generatedTypes[j]) + }) + sort.Slice(root.generatedUnions, func(i, j int) bool { + return generatedUnionEmissionLess(root.generatedUnions[i], root.generatedUnions[j]) + }) + } + return nil +} + +// selectGeneratedTypeEmission coalesces identical declaration candidates and +// rejects candidates that would give one declaration two emitted contracts. +func selectGeneratedTypeEmission(selected map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts, candidate *generatedTypeEmissionFacts) error { + existing := selected[candidate.declaration] + if existing == nil { + selected[candidate.declaration] = candidate + return nil + } + if err := validateGeneratedTypeEmission(existing, candidate); err != nil { return err } - return planImports(root, inputs, generation) + if generatedTypeEmissionLess(candidate, existing) { + selected[candidate.declaration] = candidate + } + return nil +} + +// validateGeneratedTypeEmission enforces the one-definition contract for one +// canonical generated type declaration. +func validateGeneratedTypeEmission(left, right *generatedTypeEmissionFacts) error { + if left.kind != right.kind || !sameGeneratedLocation(left.location, right.location) || + !sameGeneratedTypeEmissionSource(left, right) || + !sameGeneratedTypeEmissionContent(left, right) || + !generatedTypeEmissionLayout(left).Equivalent(generatedTypeEmissionLayout(right)) || + !slices.Equal( + left.service.generatedTypeImports[left.declaration].paths, + right.service.generatedTypeImports[right.declaration].paths, + ) { + return fmt.Errorf( + "conflicting generated type emission in package %q: roles %d and %d, sources %q and %q", + left.declaration.PackagePath(), + left.kind, + right.kind, + generatedTypeEmissionName(left), + generatedTypeEmissionName(right), + ) + } + return nil +} + +// generatedTypeEmissionLayout returns the exact retained definition written by +// one emission candidate. References belong to consuming service files and do +// not participate in ownership of this declaration's definition. +func generatedTypeEmissionLayout(emission *generatedTypeEmissionFacts) *codegen.GoTypePlan { + if emission.userType != nil { + return emission.userType.layout + } + return emission.attribute.definition +} + +// sameGeneratedTypeEmissionContent compares the retained comments and error +// behavior that can change the bytes emitted for one declaration. +func sameGeneratedTypeEmissionContent(left, right *generatedTypeEmissionFacts) bool { + if left.error != right.error { + return false + } + if left.userType != nil || right.userType != nil { + return left.userType != nil && right.userType != nil && + left.userType.name == right.userType.name && + left.userType.description == right.userType.description && + left.userType.errorName == right.userType.errorName && + left.userType.serviceError == right.userType.serviceError + } + return left.service.packagePath == right.service.packagePath && + left.method.name == right.method.name && + left.attribute.description == right.attribute.description +} + +// sameGeneratedTypeEmissionSource compares exact authored sources while +// recognizing generated union branch aliases already proven compatible by the +// package's canonical branch declaration. +func sameGeneratedTypeEmissionSource(left, right *generatedTypeEmissionFacts) bool { + if generatedTypeEmissionOrigin(left) == generatedTypeEmissionOrigin(right) { + return true + } + return left.userType != nil && right.userType != nil && + !left.root.rootTypes.contains(left.userType.userType) && + !right.root.rootTypes.contains(right.userType.userType) +} + +// generatedTypeEmissionName describes the exact authored or normalized source +// in a planning conflict diagnostic. +func generatedTypeEmissionName(emission *generatedTypeEmissionFacts) string { + origin := generatedTypeEmissionOrigin(emission) + if origin == nil { + return "" + } + return origin.Name() +} + +// validateGeneratedUnionEmission enforces one location and shape for a +// canonical generated union declaration. +func validateGeneratedUnionEmission(left, right *generatedUnionEmissionFacts) error { + if left.union.declaration != right.union.declaration || + left.union.identity != right.union.identity || + left.union.typeKey != right.union.typeKey || + left.union.valueKey != right.union.valueKey || + generatedLocationPath(left.union.location) != generatedLocationPath(right.union.location) || + !sameGeneratedUnionBranches(left.union.branches, right.union.branches) || + !slices.Equal(left.union.imports.paths, right.union.imports.paths) { + return fmt.Errorf( + "conflicting generated union emission in package %q: declarations equal=%t, keys %q/%q and %q/%q", + left.union.declaration.PackagePath(), + left.union.declaration == right.union.declaration, + left.union.typeKey, + left.union.valueKey, + right.union.typeKey, + right.union.valueKey, + ) + } + return nil +} + +// sameGeneratedUnionBranches compares every fact that changes one canonical +// union declaration's type, constructors, validation, or JSON helpers. +func sameGeneratedUnionBranches(left, right []*unionBranchFacts) bool { + if len(left) != len(right) { + return false + } + for index := range left { + leftBranch, rightBranch := left[index], right[index] + if leftBranch.name != rightBranch.name || + leftBranch.fieldName != rightBranch.fieldName || + leftBranch.declaration != rightBranch.declaration || + !leftBranch.layout.Equivalent(rightBranch.layout) || + leftBranch.nilable != rightBranch.nilable || + leftBranch.emitPrimitiveAlias != rightBranch.emitPrimitiveAlias || + leftBranch.primitiveAliasType != rightBranch.primitiveAliasType { + return false + } + } + return true +} + +// generatedLocationPath returns the generated package selected by location. +// Union declarations always emit in unions.go, so their enclosing type's file +// name is not part of union ownership. +func generatedLocationPath(location *codegen.Location) string { + if location == nil { + return "" + } + return location.RelImportPath +} + +// generatedTypeEmissionOrigin returns the exact authored or normalized source +// whose layout defines an emitted declaration. +func generatedTypeEmissionOrigin(emission *generatedTypeEmissionFacts) expr.UserType { + if emission.userType != nil { + return emission.userType.userType.Origin() + } + if userType, ok := emission.attribute.attribute.Type.(expr.UserType); ok { + return userType.Origin() + } + return nil +} + +// generatedTypeEmissionLess orders equivalent candidates by stable service +// and method facts, never by root traversal position. +func generatedTypeEmissionLess(left, right *generatedTypeEmissionFacts) bool { + if left.declaration.PackagePath() != right.declaration.PackagePath() { + return left.declaration.PackagePath() < right.declaration.PackagePath() + } + if left.location.FilePath != right.location.FilePath { + return left.location.FilePath < right.location.FilePath + } + if left.service.packagePath != right.service.packagePath { + return left.service.packagePath < right.service.packagePath + } + leftMethod, rightMethod := "", "" + if left.method != nil { + leftMethod = left.method.name + } + if right.method != nil { + rightMethod = right.method.name + } + return leftMethod < rightMethod +} + +// generatedUnionEmissionLess orders equivalent union candidates by their +// stable package and service ownership facts. +func generatedUnionEmissionLess(left, right *generatedUnionEmissionFacts) bool { + if left.union.declaration.PackagePath() != right.union.declaration.PackagePath() { + return left.union.declaration.PackagePath() < right.union.declaration.PackagePath() + } + return left.service.packagePath < right.service.packagePath } // rootMembershipError reports an attempt to plan or analyze a design root @@ -110,11 +443,12 @@ func rootMembershipError(root *expr.RootExpr) error { // planMethodTypes declares the semantic wrappers created when NewGeneration // takes ownership of raw method objects. Exact user types in the same package // are planned separately and therefore keep their authored names. -func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { +func planMethodTypes(facts *rootFacts, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { planned := make(map[expr.UserType]codegen.DerivedTypeID) - for _, service := range root.Services { + for _, serviceFacts := range facts.services { + service := serviceFacts.service generatedPackage := generation.Package(servicePackagePath(generation.GenPkg(), service)) - for _, method := range service.Methods { + for _, method := range serviceFacts.methods { attributes := []*expr.AttributeExpr{ method.Payload, method.StreamingPayload, @@ -145,13 +479,14 @@ func planMethodTypes(root *expr.RootExpr, generation *codegen.Generation) (map[e // planningInputs returns the service attributes that can cause service types // to be emitted. Unused root types are deliberately excluded. -func planningInputs(root *expr.RootExpr) []plannedAttribute { +func planningInputs(facts *rootFacts) []plannedAttribute { var inputs []plannedAttribute - for _, service := range root.Services { - for _, serviceError := range service.Errors { + for _, serviceFacts := range facts.services { + service := serviceFacts.service + for _, serviceError := range serviceFacts.errors { inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: service}) } - for _, method := range service.Methods { + for _, method := range serviceFacts.methods { inputs = append(inputs, plannedAttribute{attribute: method.Payload, service: service}, plannedAttribute{attribute: method.StreamingPayload, service: service}, @@ -164,7 +499,7 @@ func planningInputs(root *expr.RootExpr) []plannedAttribute { inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: service}) } } - for _, userType := range root.Types { + for _, userType := range facts.types { services, ok := userType.Attribute().Meta["type:generate:force"] if !ok || len(services) > 0 && !slices.Contains(services, service.Name) { continue @@ -307,37 +642,69 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati // planViews rebuilds the same projected expression graph used by rendering, // declares every derived view type, and then declares view-local union // families after the derived type names have been recorded. -func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *rootTypeSet) error { - for _, service := range root.Services { +func planViews(facts *rootFacts, generation *codegen.Generation) error { + for _, serviceFacts := range facts.services { + service := serviceFacts.service viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" views, err := generation.ClaimPackage(viewsPath) if err != nil { return err } seenProjected := make(map[expr.UserType]expr.UserType) + projectedFactsByOrigin := make(map[expr.UserType]*projectedTypeFacts) derived := make(map[expr.UserType]codegen.DerivedTypeID) var projectedRoots []*expr.AttributeExpr - for _, method := range service.Methods { + for _, method := range serviceFacts.methods { if !hasResultType(method.Result) { continue } projected, source := projectedResultRoot(generation, method) pairs := projectTypePairs(projected, source, seenProjected) + projection := &projectionFacts{pairs: pairs} + serviceFacts.projections[method] = projection + serviceFacts.methodByExpr[method].projection = projection for _, pair := range pairs { identity := codegen.NewProjectedTypeID(pair.source) if _, err := views.DeclareDerivedType(identity, codegen.Goify(pair.projected.Name(), true)); err != nil { return err } derived[pair.projected.Origin()] = identity + projectedFacts, err := collectProjectedTypeFacts(pair) + if err != nil { + return err + } + projection.types = append(projection.types, projectedFacts) + projectedFactsByOrigin[pair.source.Origin()] = projectedFacts + } + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + projectedType := seenProjected[resultType.Origin()] + if len(pairs) > 0 { + projectedType = pairs[0].projected + } + if projectedType != nil { + viewName := "" + if !resultType.HasMultipleViews() { + viewName = expr.DefaultView + } + if selected, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + viewName = selected + } + serviceFacts.methodByExpr[method].viewedResult = &viewedResultFacts{ + wrapped: wrapProjected(projectedType), + origin: resultType.Origin(), + source: serviceFacts.methodByExpr[method].result, + viewName: viewName, + views: projectedFactsByOrigin[resultType.Origin()].views, + conversions: projectedFactsByOrigin[resultType.Origin()].conversions, + projected: projectedFactsByOrigin[resultType.Origin()], + isCollection: expr.IsArray(method.Result.Type), + } + } } removeMeta(projected) projectedRoots = append(projectedRoots, projected) if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { - serviceTypes := generation.Package(servicePackagePath(generation.GenPkg(), service)) - if _, err := serviceTypes.Type(rootTypes.canonical(resultType)); err != nil { - return err - } if _, err := views.DeclareDerivedType( codegen.NewViewedResultTypeID(resultType), codegen.Goify(resultType.Name(), true), @@ -347,8 +714,9 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r } } seenUnions := make(map[expr.UserType]struct{}) + retainedUnions := make(map[codegen.UnionTypeID]struct{}) for _, projected := range projectedRoots { - if err := planViewUnions(projected, views, derived, seenUnions); err != nil { + if err := planViewUnions(projected, views, derived, seenUnions, retainedUnions, &serviceFacts.viewUnions); err != nil { return err } } @@ -356,15 +724,166 @@ func planViews(root *expr.RootExpr, generation *codegen.Generation, rootTypes *r return nil } +// collectProjectedTypeFacts selects validators and view-narrowed conversions +// from one projected graph before package names freeze. +func collectProjectedTypeFacts(pair *projectedTypePair) (*projectedTypeFacts, error) { + facts := &projectedTypeFacts{ + pair: pair, + projectedType: pair.projected, + validations: collectValidationFacts(pair.projectedAttribute), + } + resultType, viewed := pair.projected.(*expr.ResultTypeExpr) + if !viewed { + return facts, nil + } + for _, view := range resultType.Views { + object := expr.AsObject(view.Type) + attributes := make([]string, len(*object)) + for index, field := range *object { + attributes[index] = field.Name + } + facts.views = append(facts.views, &viewRenderFacts{ + name: view.Name, + description: view.Description, + attributes: attributes, + }) + } + for _, toResult := range []bool{true, false} { + conversions, err := collectViewConversionFacts(pair.projectedAttribute, pair.sourceAttribute, toResult) + if err != nil { + return nil, err + } + facts.conversions = append(facts.conversions, conversions...) + } + return facts, nil +} + +// collectValidationFacts retains the exact attributes and child validators +// selected for each projected type view. +func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { + userType := projected.Type.(expr.UserType) + resultType, viewed := userType.(*expr.ResultTypeExpr) + if !viewed { + return []*validationFacts{{ + attribute: userType.Attribute(), + alias: expr.IsAlias(userType), + pointer: !expr.IsPrimitive(projected.Type), + }} + } + facts := make([]*validationFacts, 0, len(resultType.Views)) + array := expr.AsArray(projected.Type) + for _, view := range resultType.Views { + validation := &validationFacts{viewName: view.Name, pointer: true} + if array != nil { + validation.collectionElem = array.ElemType + facts = append(facts, validation) + continue + } + object := &expr.Object{} + walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attribute, viewAttribute *expr.AttributeExpr) { + if nested, ok := attribute.Type.(*expr.ResultTypeExpr); ok { + selectedView := "" + if explicit, ok := viewAttribute.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + selectedView = explicit + } + validation.fields = append(validation.fields, &validationFieldFacts{ + name: name, + attribute: attribute, + view: selectedView, + required: nested.Attribute().IsRequired(name), + }) + return + } + object.Set(name, attribute) + }) + validation.attribute = &expr.AttributeExpr{Type: object, Validation: resultType.Validation} + facts = append(facts, validation) + } + return facts +} + +// collectViewConversionFacts narrows a projected result to each declared view +// and retains the transform operation used in the selected direction. +func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult bool) ([]*viewConversionFacts, error) { + views := service.Type.(*expr.ResultTypeExpr).Views + projectedObject := expr.AsObject(projected.Type) + projectedArray := expr.AsArray(projected.Type) + if projectedArray != nil { + projectedObject = expr.AsObject(projectedArray.ElemType.Type) + } + result := make([]*viewConversionFacts, 0, len(views)) + for _, view := range views { + object := &expr.Object{} + walkViewAttrs(projectedObject, view, func(name string, attribute, _ *expr.AttributeExpr) { + object.Set(name, attribute) + }) + var narrowedType expr.DataType = object + if projectedArray != nil { + narrowedType = &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: projectedArray.ElemType.Type.Name(), + }, + }}} + } + narrowed := &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: narrowedType}, + TypeName: projected.Type.Name(), + }, + Views: views, + Identifier: service.Type.(*expr.ResultTypeExpr).Identifier, + }} + source, target := service, narrowed + if toResult { + source, target = narrowed, service + } + conversion := &viewConversionFacts{ + toResult: toResult, + viewName: view.Name, + source: source, + target: target, + } + if projectedArray == nil { + conversion.transformTarget = expr.DupAtt(target) + targetObject := expr.AsObject(conversion.transformTarget.Type) + for _, field := range *targetObject { + if _, nested := field.Attribute.Type.(*expr.ResultTypeExpr); !nested { + continue + } + nestedView := "" + if selected := source.Type.(*expr.ResultTypeExpr).View(view.Name).Find(field.Name); selected != nil { + if explicit, ok := selected.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + nestedView = explicit + } + } + conversion.fields = append(conversion.fields, &viewConversionFieldFacts{ + name: field.Name, + attribute: field.Attribute, + view: nestedView, + }) + targetObject.Delete(field.Name) + } + plan, err := codegen.NewTransformPlan(source, conversion.transformTarget) + if err != nil { + return nil, err + } + conversion.plan = plan + } + result = append(result, conversion) + } + return result, nil +} + // planViewUnions declares every union family reachable from one projected // graph. Projected user types already own their derived declarations; only a // branch without one is a generated alias owned by its union family. -func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}) error { +func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}, retained map[codegen.UnionTypeID]struct{}, unions *[]*unionFacts) error { if attribute == nil || attribute.Type == expr.Empty { return nil } recurse := func(attribute *expr.AttributeExpr) error { - return planViewUnions(attribute, generatedPackage, derived, seen) + return planViewUnions(attribute, generatedPackage, derived, seen, retained, unions) } switch actual := attribute.Type.(type) { case expr.UserType: @@ -391,6 +910,22 @@ func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.Gen if _, err := generatedPackage.DeclareUnion(actual); err != nil { return err } + identity := codegen.NewUnionTypeID(actual) + if _, exists := retained[identity]; !exists { + retained[identity] = struct{}{} + declaration, err := generatedPackage.Union(actual) + if err != nil { + return err + } + *unions = append(*unions, &unionFacts{ + union: actual, + identity: identity, + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: &codegen.Location{RelImportPath: "views"}, + declaration: declaration, + }) + } for _, branch := range actual.Values { if userType, ok := branch.Attribute.Type.(expr.UserType); ok { if _, projected := derived[userType.Origin()]; !projected { @@ -484,125 +1019,3 @@ func generatedPackagePath(genpkg string, service *expr.ServiceExpr, location *co func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { return path.Join(genpkg, codegen.SnakeCase(codegen.Goify(service.Name, false))) } - -// generatedPackage returns the root-owned render data for the package selected -// by location, creating that owner on first use. -func (d *ServicesData) generatedPackage(service *expr.ServiceExpr, location *codegen.Location) *generatedPackageData { - importPath := generatedPackagePath(d.generation.GenPkg(), service, location) - owner := d.generation.Package(importPath) - if generatedPackage, ok := d.packages[owner]; ok { - return generatedPackage - } - generatedPackage := &generatedPackageData{ - types: make(map[*codegen.TypeDeclaration]*generatedTypeData), - unions: make(map[codegen.UnionTypeID]*UnionTypeData), - } - d.packages[owner] = generatedPackage - return generatedPackage -} - -// registerPackageData gives each relocated user type's canonical declaration -// record one render section at its metadata-selected file. -func (d *ServicesData) registerPackageData(service *expr.ServiceExpr, data *Data) error { - for i, method := range service.Methods { - methodData := data.Methods[i] - if err := d.registerMethodType(service, method.Payload, methodData.PayloadLoc, methodData.PayloadDef, &codegen.SectionTemplate{ - Name: "service-payload", - Source: serviceTemplates.Read(payloadT), - Data: methodData, - }); err != nil { - return err - } - if method.StreamingPayload != nil { - if err := d.registerMethodType(service, method.StreamingPayload, codegen.UserTypeLocation(method.StreamingPayload.Type), methodData.StreamingPayloadDef, &codegen.SectionTemplate{ - Name: "service-streaming-payload", - Source: serviceTemplates.Read(streamingPayloadT), - Data: methodData, - }); err != nil { - return err - } - } - if err := d.registerMethodType(service, method.Result, methodData.ResultLoc, methodData.ResultDef, &codegen.SectionTemplate{ - Name: "service-result", - Source: serviceTemplates.Read(resultT), - Data: methodData, - }); err != nil { - return err - } - if method.HasMixedResults() && method.StreamingResult != nil { - if err := d.registerMethodType(service, method.StreamingResult, codegen.UserTypeLocation(method.StreamingResult.Type), methodData.StreamingResultDef, &codegen.SectionTemplate{ - Name: "service-streaming-result", - Source: serviceTemplates.Read(resultT), - Data: map[string]any{ - "Result": methodData.StreamingResult, - "ResultDef": methodData.StreamingResultDef, - "ResultDesc": methodData.StreamingResultDesc, - }, - }); err != nil { - return err - } - } - } - for _, userType := range data.userTypes { - if userType.Loc == nil { - continue - } - d.registerType(service, userType.Declaration, userType.Type, userType.Loc, &codegen.SectionTemplate{ - Name: "service-user-type", - Source: serviceTemplates.Read(userTypeT), - Data: userType, - }) - } - for _, errorType := range data.errorTypes { - if errorType.Loc == nil || errorType.Type == expr.ErrorResult { - continue - } - d.registerType(service, errorType.Declaration, errorType.Type, errorType.Loc, &codegen.SectionTemplate{ - Name: "error-user-type", - Source: serviceTemplates.Read(userTypeT), - Data: errorType, - }) - generatedType := d.generatedPackage(service, errorType.Loc).types[errorType.Declaration] - if generatedType.error == nil { - generatedType.error = &codegen.SectionTemplate{ - Name: "service-error", - Source: serviceTemplates.Read(errorT), - FuncMap: map[string]any{"errorName": errorName}, - Data: errorType, - } - } - } - return nil -} - -// registerMethodType records one relocated method payload or result when it -// has a generated declaration body. -func (d *ServicesData) registerMethodType(service *expr.ServiceExpr, attribute *expr.AttributeExpr, location *codegen.Location, definition string, section *codegen.SectionTemplate) error { - if location == nil || definition == "" { - return nil - } - userType := attribute.Type.(expr.UserType) - declaration, err := d.generation.Package( - generatedPackagePath(d.generation.GenPkg(), service, location), - ).UserType(d.rootTypes.canonical(userType)) - if err != nil { - return err - } - d.registerType(service, declaration, userType, location, section) - return nil -} - -// registerType stores section under declaration. Repeated uses of the same -// canonical record retain the first root-order section and emit once. -func (d *ServicesData) registerType(service *expr.ServiceExpr, declaration *codegen.TypeDeclaration, userType expr.UserType, location *codegen.Location, section *codegen.SectionTemplate) { - generatedPackage := d.generatedPackage(service, location) - if _, ok := generatedPackage.types[declaration]; ok { - return - } - generatedPackage.types[declaration] = &generatedTypeData{ - declaration: declaration, - userType: userType, - location: location, - section: section, - } -} diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 0c399e4d69..41663829cb 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -5,6 +5,7 @@ package service import ( "path" + "slices" "sort" "strings" @@ -26,6 +27,30 @@ type ( genpkg string outputPackage string paths map[string]struct{} + planning bool + err error + } + + // retainedFileImports stores the complete package paths selected for one + // emitted file and their frozen import declarations after linking. + retainedFileImports struct { + paths []string + specs []*codegen.ImportSpec + } + + // serviceFileImports keeps imports separate for files that emit different + // subsets of one service's types and runtime helpers. + serviceFileImports struct { + service retainedFileImports + endpoint retainedFileImports + client retainedFileImports + views retainedFileImports + serverInterceptors retainedFileImports + clientInterceptors retainedFileImports + interceptorWrappers retainedFileImports + exampleService retainedFileImports + exampleServerInterceptors retainedFileImports + exampleClientInterceptors retainedFileImports } ) @@ -41,39 +66,6 @@ func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*exp return collector.imports() } -// fileImports returns one canonical import per complete path used by a single -// generated file. Explicit paths and attribute-derived paths are deduplicated -// before their frozen aliases are materialized. -func (d *ServicesData) fileImports(outputPackage string, paths []string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { - collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) - for _, importPath := range paths { - collector.addPath(importPath) - } - for _, attribute := range attributes { - collector.collectDefinition(attribute) - } - return collector.imports() -} - -// serviceReferenceAttributes returns the method and error attributes whose -// named declarations are referenced by service, endpoint, and client files. -func serviceReferenceAttributes(service *expr.ServiceExpr) []*expr.AttributeExpr { - attributes := make([]*expr.AttributeExpr, 0, len(service.Methods)*4+len(service.Errors)) - for _, serviceError := range service.Errors { - attributes = append(attributes, serviceError.AttributeExpr) - } - for _, method := range service.Methods { - attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result) - if method.HasMixedResults() { - attributes = append(attributes, method.StreamingResult) - } - for _, methodError := range method.Errors { - attributes = append(attributes, methodError.AttributeExpr) - } - } - return attributes -} - // newImportAliases returns the generation-owned frozen alias binding used by // service analysis and rendering. func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { @@ -83,105 +75,6 @@ func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*imp return &importAliases{generation: generation}, nil } -// planImports registers every fixed package and every external or generated -// package referenced by declarations selected for emission from root. -func planImports(root *expr.RootExpr, inputs []plannedAttribute, generation *codegen.Generation) error { - fixed := []*codegen.ImportSpec{ - codegen.SimpleImport("bytes"), - codegen.SimpleImport("context"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - codegen.SimpleImport("io"), - codegen.SimpleImport("strings"), - codegen.SimpleImport("unicode/utf8"), - codegen.SimpleImport("goa.design/clue/log"), - codegen.GoaImport(""), - codegen.GoaImport("security"), - } - for _, spec := range fixed { - if err := generation.RequireImport(spec); err != nil { - return err - } - } - for _, service := range root.Services { - servicePath := servicePackagePath(generation.GenPkg(), service) - serviceName := strings.ToLower(codegen.Goify(service.Name, false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(serviceName, servicePath)); err != nil { - return err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(serviceName+"views", servicePath+"/views")); err != nil { - return err - } - } - seen := make(map[expr.UserType]struct{}) - for _, input := range inputs { - if err := planAttributeImports(input.attribute, generation, seen); err != nil { - return err - } - } - for _, typeMap := range append(append([]*expr.TypeMap(nil), root.Conversions...), root.Creations...) { - importPath, alias, err := getExternalTypeInfo(typeMap.External) - if err != nil { - return err - } - if err := generation.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { - return err - } - } - return nil -} - -// planAttributeImports recursively records every explicit generated location and -// struct:field:type import reachable from attribute. -func planAttributeImports(attribute *expr.AttributeExpr, generation *codegen.Generation, seen map[expr.UserType]struct{}) error { - if attribute == nil || attribute.Type == expr.Empty { - return nil - } - if _, spec := codegen.GetMetaType(attribute); spec != nil { - if err := generation.DeclareImport(spec); err != nil { - return err - } - } - switch actual := attribute.Type.(type) { - case expr.UserType: - if location := codegen.UserTypeLocation(actual); location != nil { - owner := generation.Package(path.Join(generation.GenPkg(), location.RelImportPath)) - if err := generation.DeclareImport(codegen.NewImport( - strings.ToLower(codegen.Goify(path.Base(owner.ImportPath()), false)), - owner.ImportPath(), - )); err != nil { - return err - } - } - origin := actual.Origin() - if _, ok := seen[origin]; ok { - return nil - } - seen[origin] = struct{}{} - return planAttributeImports(actual.Attribute(), generation, seen) - case *expr.Object: - for _, named := range *actual { - if err := planAttributeImports(named.Attribute, generation, seen); err != nil { - return err - } - } - case *expr.Array: - return planAttributeImports(actual.ElemType, generation, seen) - case *expr.Map: - if err := planAttributeImports(actual.KeyType, generation, seen); err != nil { - return err - } - return planAttributeImports(actual.ElemType, generation, seen) - case *expr.Union: - for _, named := range actual.Values { - if err := planAttributeImports(named.Attribute, generation, seen); err != nil { - return err - } - } - } - return nil -} - // name returns the frozen qualifier for importPath and panics when rendering // asks for a package that was absent from alias planning. func (a *importAliases) name(importPath string) string { @@ -204,6 +97,19 @@ func newImportCollector(aliases *importAliases, genpkg, outputPackage string) *i } } +// newPlanningImportCollector creates the same path walker used by rendering +// and additionally declares each discovered metadata or generated-package +// preference in the generation alias catalog. +func newPlanningImportCollector(generation *codegen.Generation, outputPackage string) *importCollector { + return &importCollector{ + aliases: &importAliases{generation: generation}, + genpkg: generation.GenPkg(), + outputPackage: outputPackage, + paths: make(map[string]struct{}), + planning: true, + } +} + // addPath records an explicitly referenced package unless it is the package // currently being emitted. func (c *importCollector) addPath(importPath string) { @@ -267,6 +173,12 @@ func (c *importCollector) addLocation(location *codegen.Location) { importPath := c.aliases.generation.Package(path.Join(c.genpkg, location.RelImportPath)).ImportPath() if importPath != c.outputPackage { c.paths[importPath] = struct{}{} + if c.planning && c.err == nil { + c.err = c.aliases.generation.ReserveGeneratedImport(codegen.NewImport( + strings.ToLower(codegen.Goify(path.Base(importPath), false)), + importPath, + )) + } } } @@ -276,7 +188,418 @@ func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { _, spec := codegen.GetMetaType(attribute) if spec != nil && spec.Path != c.outputPackage { c.paths[spec.Path] = struct{}{} + if c.planning && c.err == nil { + c.err = c.aliases.generation.DeclareImport(spec) + } + } +} + +// retainFileImports collects one emitted file's exact fixed, generated, type +// definition, and recursive-reference package paths before names freeze. +func retainFileImports( + generation *codegen.Generation, + outputPackage string, + fixed, generated []*codegen.ImportSpec, + definitions, references []*expr.AttributeExpr, +) (retainedFileImports, error) { + collector := newPlanningImportCollector(generation, outputPackage) + for _, spec := range fixed { + collector.addPath(spec.Path) + if err := generation.RequireImport(spec); err != nil { + return retainedFileImports{}, err + } + } + for _, spec := range generated { + collector.addPath(spec.Path) + if err := generation.ReserveGeneratedImport(spec); err != nil { + return retainedFileImports{}, err + } + } + for _, attribute := range definitions { + collector.collectDefinition(attribute) + } + seen := make(map[expr.UserType]struct{}) + for _, attribute := range references { + collector.collectReferences(attribute, seen) + } + if collector.err != nil { + return retainedFileImports{}, collector.err + } + paths := make([]string, 0, len(collector.paths)) + for importPath := range collector.paths { + paths = append(paths, importPath) + } + sort.Strings(paths) + return retainedFileImports{paths: paths}, nil +} + +// linkFileImports resolves one retained path list through the frozen +// Generation alias catalog without traversing service attributes. +func linkFileImports(imports *retainedFileImports, generation *codegen.Generation) { + imports.specs = make([]*codegen.ImportSpec, len(imports.paths)) + for index, importPath := range imports.paths { + imports.specs[index] = generation.Import(importPath) + } +} + +// addRetainedImportPath adds one explicitly declared package to a file's +// retained path set while preserving deterministic order. +func addRetainedImportPath(imports *retainedFileImports, importPath string) { + index, found := slices.BinarySearch(imports.paths, importPath) + if found { + return + } + imports.paths = slices.Insert(imports.paths, index, importPath) +} + +// planServiceFileImports selects the package paths used by each concrete file +// emitted for one retained service and declares their alias preferences before +// generation freezes. +func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + service := facts.service + servicePath := servicePackagePath(generation.GenPkg(), service) + serviceImport := codegen.NewImport(strings.ToLower(codegen.Goify(service.Name, false)), servicePath) + viewsImport := codegen.NewImport(serviceImport.Name+"views", servicePath+"/views") + facts.generatedTypeImports = make(map[*codegen.TypeDeclaration]*retainedFileImports) + + definitions := serviceDefinitionAttributes(facts) + serviceDefinitions := append([]*expr.AttributeExpr(nil), facts.referenceAttributes...) + serviceDefinitions = append(serviceDefinitions, definitions...) + viewDefinitions := viewDefinitionAttributes(facts) + + contextImport := codegen.SimpleImport("context") + ioImport := codegen.SimpleImport("io") + goaImport := codegen.GoaImport("") + securityImport := codegen.GoaImport("security") + logImport := codegen.SimpleImport("goa.design/clue/log") + + serviceFixed := []*codegen.ImportSpec{contextImport} + if serviceUsesIO(facts) { + serviceFixed = append(serviceFixed, ioImport) + } + if serviceUsesGoaErrors(facts) { + serviceFixed = append(serviceFixed, goaImport) + } + if serviceHasSchemes(facts) { + serviceFixed = append(serviceFixed, securityImport) + } + var serviceGenerated []*codegen.ImportSpec + if len(facts.projections) > 0 { + serviceGenerated = append(serviceGenerated, viewsImport) + } + var err error + facts.imports.service, err = retainFileImports( + generation, servicePath, serviceFixed, serviceGenerated, serviceDefinitions, nil, + ) + if err != nil { + return err + } + + endpointFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + endpointFixed = append(endpointFixed, ioImport) + } + if serviceHasSchemes(facts) { + endpointFixed = append(endpointFixed, securityImport) + } + facts.imports.endpoint, err = retainFileImports( + generation, servicePath, endpointFixed, nil, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + clientFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + clientFixed = append(clientFixed, ioImport) + } + facts.imports.client, err = retainFileImports( + generation, servicePath, clientFixed, nil, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + if len(facts.projections) > 0 { + viewsFixed := []*codegen.ImportSpec{goaImport, codegen.SimpleImport("unicode/utf8")} + if len(facts.viewUnions) > 0 { + viewsFixed = append(viewsFixed, + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + ) + } + facts.imports.views, err = retainFileImports( + generation, servicePath+"/views", viewsFixed, nil, viewDefinitions, nil, + ) + if err != nil { + return err + } + } + + interceptorFixed := []*codegen.ImportSpec{contextImport, goaImport} + if len(facts.serverInterceptors) > 0 { + facts.imports.serverInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, nil, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.imports.clientInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, nil, + ) + if err != nil { + return err + } + } + if len(facts.serverInterceptors) > 0 || len(facts.clientInterceptors) > 0 { + facts.imports.interceptorWrappers, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, nil, + ) + if err != nil { + return err + } + } + + exampleFixed := []*codegen.ImportSpec{contextImport, logImport} + if serviceUsesIO(facts) { + exampleFixed = append(exampleFixed, ioImport) + } + if serviceUsesResponseBody(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("strings")) + } + if serviceHasSchemes(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("fmt"), securityImport) + } + facts.imports.exampleService, err = retainFileImports( + generation, path.Dir(generation.GenPkg()), exampleFixed, + []*codegen.ImportSpec{serviceImport}, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + exampleInterceptorFixed := []*codegen.ImportSpec{contextImport, logImport, goaImport} + if len(facts.serverInterceptors) > 0 { + facts.imports.exampleServerInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.imports.exampleClientInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + if userType.location == nil { + continue + } + userType.imports, err = retainFileImports( + generation, + userType.declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[userType.declaration] = &userType.imports + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) == nil { + continue + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + if _, normalized := generation.NormalizedMethodType(userType); !normalized { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.service, codegen.UserTypeLocation(userType), + )) + declaration, err := owner.UserType(rootTypes.canonical(userType)) + if err != nil { + return err + } + if _, exists := facts.generatedTypeImports[declaration]; exists { + continue + } + retained, err := retainFileImports( + generation, + declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[declaration] = &retained + } + } + unionFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + goaImport, + } + for _, union := range facts.unions { + definitions := make([]*expr.AttributeExpr, 0, len(union.union.Values)*2) + for _, branch := range union.union.Values { + definitions = append(definitions, branch.Attribute) + if userType, ok := branch.Attribute.Type.(expr.UserType); ok { + definitions = append(definitions, userType.Attribute()) + } + } + union.imports, err = retainFileImports( + generation, + union.declaration.PackagePath(), + unionFixed, + nil, + definitions, + nil, + ) + if err != nil { + return err + } + } + return nil +} + +// linkServiceFileImports resolves every concrete file contribution after the +// generation alias catalog freezes. +func linkServiceFileImports(facts *serviceFacts, generation *codegen.Generation) { + imports := []*retainedFileImports{ + &facts.imports.service, + &facts.imports.endpoint, + &facts.imports.client, + &facts.imports.views, + &facts.imports.serverInterceptors, + &facts.imports.clientInterceptors, + &facts.imports.interceptorWrappers, + &facts.imports.exampleService, + &facts.imports.exampleServerInterceptors, + &facts.imports.exampleClientInterceptors, + } + for _, retained := range imports { + linkFileImports(retained, generation) + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + linkFileImports(&userType.imports, generation) + } + for _, union := range facts.unions { + linkFileImports(&union.imports, generation) + } + for _, imports := range facts.generatedTypeImports { + linkFileImports(imports, generation) + } +} + +// serviceDefinitionAttributes returns the exact named definitions written to +// service.go in addition to method references. +func serviceDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + var definitions []*expr.AttributeExpr + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) != nil { + continue + } + if userType, ok := attribute.Type.(expr.UserType); ok { + definitions = append(definitions, userType.Attribute()) + } + } + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + if userType.location == nil { + definitions = append(definitions, userType.userType.Attribute()) + } + } + return definitions +} + +// viewDefinitionAttributes returns each projected definition emitted in the +// service views file exactly once by expression identity. +func viewDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + seen := make(map[*expr.AttributeExpr]struct{}) + var definitions []*expr.AttributeExpr + for _, projection := range facts.projections { + for _, projected := range projection.types { + attribute := projected.pair.projectedAttribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + if _, ok := seen[attribute]; ok { + continue + } + seen[attribute] = struct{}{} + definitions = append(definitions, attribute) + } + } + return definitions +} + +// serviceUsesIO reports whether generated method signatures expose a raw +// request or response body stream. +func serviceUsesIO(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipRequestBodyEncodeDecode || method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesResponseBody reports whether the starter implementation creates +// a raw response body from a string reader. +func serviceUsesResponseBody(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesGoaErrors reports whether service.go emits a constructor that +// calls the Goa service-error runtime. +func serviceUsesGoaErrors(facts *serviceFacts) bool { + for _, serviceError := range facts.errors { + if serviceError.Type == expr.ErrorResult { + return true + } + } + for _, method := range facts.methods { + for _, methodError := range method.Errors { + if methodError.Type == expr.ErrorResult { + return true + } + } } + return false } // imports returns a deterministic snapshot of the packages collected for one diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index cab360a910..007499221a 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -32,10 +32,8 @@ func TestPlanRejectsUnregisteredRoot(t *testing.T) { }) }) generation := mustTestGeneration(t, "generated.local/gen", nil) - require.ErrorContains(t, Plan(root, generation), "does not belong") + require.ErrorContains(t, planTestServices(root, generation), "does not belong") require.NoError(t, generation.Freeze()) - _, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.ErrorContains(t, err, "does not belong") } // TestPlanUsesCopiedGenerationRoots verifies that mutating root slices outside @@ -58,16 +56,42 @@ func TestPlanUsesCopiedGenerationRoots(t *testing.T) { returnedRoots := generation.Roots() returnedRoots[0] = second - require.NoError(t, Plan(first, generation)) - require.ErrorContains(t, Plan(second, generation), "does not belong") + firstPlan, err := NewPlan(first, generation, expr.NewExampleGenerator(first.API.RandomizerFactory)) + require.NoError(t, err) + require.ErrorContains(t, planTestServices(second, generation), "does not belong") require.NoError(t, generation.Freeze()) roots[0] = nil returnedRoots = generation.Roots() returnedRoots[0] = second - _, err := NewServicesData(first, generation, expr.NewExampleGenerator(first.API.RandomizerFactory)) + require.NoError(t, firstPlan.Link()) + require.NotNil(t, firstPlan.Services().Get("First")) +} + +// TestFileImportsAreRetainedBeforeFreeze verifies that rendering uses the +// exact package paths selected with the file contribution, not a later walk +// over mutable service-analysis slices. +func TestFileImportsAreRetainedBeforeFreeze(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - _, err = NewServicesData(second, generation, expr.NewExampleGenerator(second.API.RandomizerFactory)) - require.ErrorContains(t, err, "does not belong") + plan.facts.services[0].referenceAttributes = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + file := endpointFile(plan, plan.facts.services[0]) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"generated.local/gen/types"`) } // TestImportAliasesUsePathAsIdentity verifies that generator-owned imports @@ -119,17 +143,22 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { firstRoot := rootWithPreference("First", "FirstPayload", "zeta") secondRoot := rootWithPreference("Second", "SecondPayload", "alpha") generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{firstRoot, secondRoot}) - require.NoError(t, Plan(firstRoot, generation)) - require.NoError(t, Plan(secondRoot, generation)) - require.NoError(t, generation.Freeze()) - first, err := NewServicesData(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) - require.NoError(t, err) - second, err := NewServicesData(secondRoot, generation, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] + require.NoError(t, generation.Freeze()) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + first := firstPlan.Services() + second := secondPlan.Services() require.Equal(t, "alpha", first.aliases.name("example.com/shared/value")) require.Equal(t, first.aliases.name("example.com/shared/value"), second.aliases.name("example.com/shared/value")) - files := Files(generation.GenPkg(), []*ServicesData{first, second}) + files := mustServiceFiles(t, firstPlan, secondPlan) for _, name := range []string{"first_payload.go", "second_payload.go"} { file := findFile(files, path.Join("gen", "types", name)) require.NotNil(t, file) @@ -139,12 +168,15 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { } } -// TestImportAliasesReserveFixedJSON verifies that the union codec's +// TestEmittedUnionReservesFixedJSON verifies that an emitted union codec's // encoding/json qualifier wins before a metadata package requests the same -// preferred name. -func TestImportAliasesReserveFixedJSON(t *testing.T) { +// preferred name. Files without a union do not reserve this runtime import. +func TestEmittedUnionReservesFixedJSON(t *testing.T) { root := codegen.RunDSL(t, func() { payload := dsl.Type("Payload", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + }) dsl.Attribute("value", dsl.String, func() { dsl.Meta("struct:field:type", "json.Value", "example.com/custom/json", "json") }) @@ -156,7 +188,7 @@ func TestImportAliasesReserveFixedJSON(t *testing.T) { }) }) generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + require.NoError(t, planTestServices(root, generation)) require.NoError(t, generation.Freeze()) aliases, err := newImportAliases(root, generation) require.NoError(t, err) @@ -176,16 +208,16 @@ func TestFixedTemplateAliasesBeatGeneratedPackages(t *testing.T) { }) } }) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) + services := plan.Services() require.Equal(t, "goa", services.aliases.name(codegen.GoaImport("").Path)) require.Equal(t, "goa2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) require.Equal(t, "log", services.aliases.name("goa.design/clue/log")) require.Equal(t, "log2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) } -// TestDocumentedJSONMetadataUsesCanonicalAlias verifies that an alternate -// metadata spelling for encoding/json produces one import and one canonical -// qualifier in the generated service definition. +// TestMetadataImportKeepsItsPreferredAlias verifies that an import used only +// by design metadata is not renamed by an unused runtime package. func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { root := codegen.RunDSL(t, func() { payload := dsl.Type("Payload", func() { @@ -199,13 +231,13 @@ func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { }) }) }) - services := mustServicesData(t, root) - file := findFile(Files(services.generation.GenPkg(), []*ServicesData{services}), path.Join("gen", "values", "service.go")) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) - require.Contains(t, code, "json.RawMessage") + require.Contains(t, code, "jason.RawMessage") require.Equal(t, 1, strings.Count(code, `"encoding/json"`), code) - require.NotContains(t, code, "jason.RawMessage") + require.NotContains(t, code, "json.RawMessage") } // TestExampleServiceUsesCanonicalGeneratedPackageQualifier verifies that a @@ -221,12 +253,13 @@ func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { }) }) }) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) + services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) require.Equal(t, "values", services.aliases.name(servicePath)) require.Equal(t, "values2", services.aliases.name("example.com/custom/values")) - files := ExampleServiceFiles(services.generation.GenPkg(), root, services) + files := ExampleServiceFiles(plan) require.Len(t, files, 1) code := renderSections(t, files[0].SectionTemplates) _, err := format.Source([]byte(code)) @@ -240,21 +273,33 @@ func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { // metadata or names request the same spelling. func TestExampleServiceReservesFixedQualifiers(t *testing.T) { root := codegen.RunDSL(t, func() { + result := dsl.Type("Result", func() { + dsl.Attribute("length", dsl.Int) + }) dsl.Service("Fmt", func() { dsl.Method("Read", func() { dsl.Payload(dsl.String, func() { dsl.Meta("struct:field:type", "strings.Value", "example.com/custom/strings", "strings") }) + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) }) }) }) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) + services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Fmt")) servicePkg := services.aliases.name(servicePath) require.NotEqual(t, "fmt", servicePkg) require.Equal(t, "strings2", services.aliases.name("example.com/custom/strings")) - files := ExampleServiceFiles(services.generation.GenPkg(), root, services) + files := ExampleServiceFiles(plan) require.Len(t, files, 1) code := renderSections(t, files[0].SectionTemplates) _, err := format.Source([]byte(code)) @@ -282,13 +327,14 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { }) }) }) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) + services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) viewsPath := servicePath + "/views" require.Equal(t, "valuesviews", services.aliases.name(viewsPath)) require.Equal(t, "valuesviews2", services.aliases.name("example.com/custom/views")) - file := findFile(Files(services.generation.GenPkg(), []*ServicesData{services}), path.Join("gen", "values", "service.go")) + file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) _, err := format.Source([]byte(code)) @@ -305,7 +351,6 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) require.NoError(t, generation.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) - service := &expr.ServiceExpr{Name: "Values"} branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, }} @@ -317,23 +362,19 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { }}, } generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") - _, err := generatedPackage.DeclareUnion(union) + declaration, err := generatedPackage.DeclareUnion(union) require.NoError(t, err) + facts := &unionFacts{ + union: union, + identity: codegen.NewUnionTypeID(union), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, generatedPackage)) require.NoError(t, generation.Freeze()) aliases := &importAliases{generation: generation} - declaration, err := generatedPackage.Union(union) - require.NoError(t, err) - data, err := buildUnionTypeData( - union, - declaration, - newServiceResolver(generation, aliases, service, "generated.local/gen/values"), - nil, - false, - func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { - return generatedPackage.UnionBranch(union, branch.Name) - }, - ) - require.NoError(t, err) + data := buildRetainedUnionTypeData(facts, aliases) require.Equal(t, "json2.Value", data.Fields[0].FieldType) collector := newImportCollector(aliases, generation.GenPkg(), "generated.local/gen/values") diff --git a/codegen/service/interceptor_data.go b/codegen/service/interceptor_data.go new file mode 100644 index 0000000000..f1ef1b615c --- /dev/null +++ b/codegen/service/interceptor_data.go @@ -0,0 +1,147 @@ +// This file formats retained interceptor applicability and access facts for interceptor templates. +package service + +import ( + "goa.design/goa/v3/codegen" +) + +// buildInterceptorData creates the data needed to generate interceptor code. +func buildInterceptorData(service *serviceFacts, facts *interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) *InterceptorData { + lookup := func(role serviceNameRole, method, subject string) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, method: method, subject: subject, + }].declaration + } + data := &InterceptorData{ + InfoDeclaration: lookup(serviceInterceptorInfoNameRole, "", facts.name), + PayloadDeclaration: lookup(serviceInterceptorPayloadNameRole, "", facts.name), + ResultDeclaration: lookup(serviceInterceptorResultNameRole, "", facts.name), + StreamingPayloadDeclaration: lookup(serviceInterceptorStreamingPayloadNameRole, "", facts.name), + StreamingResultDeclaration: lookup(serviceInterceptorStreamingResultNameRole, "", facts.name), + Name: codegen.Goify(facts.name, true), + DesignName: facts.name, + Description: facts.description, + } + if len(facts.methods) == 0 { + return data + } + data.ReadPayload = formatInterceptorAccess(facts.readPayloadFields, resolver) + data.WritePayload = formatInterceptorAccess(facts.writePayloadFields, resolver) + data.ReadResult = formatInterceptorAccess(facts.readResultFields, resolver) + data.WriteResult = formatInterceptorAccess(facts.writeResultFields, resolver) + data.ReadStreamingPayload = formatInterceptorAccess(facts.readStreamingPayloadFields, resolver) + data.WriteStreamingPayload = formatInterceptorAccess(facts.writeStreamingPayloadFields, resolver) + data.ReadStreamingResult = formatInterceptorAccess(facts.readStreamingResultFields, resolver) + data.WriteStreamingResult = formatInterceptorAccess(facts.writeStreamingResultFields, resolver) + data.HasPayloadAccess = len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 + data.HasResultAccess = len(data.ReadResult) > 0 || len(data.WriteResult) > 0 + data.HasStreamingPayloadAccess = len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 + data.HasStreamingResultAccess = len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 + for _, method := range facts.methods { + md := methods[method] + data.Methods = append(data.Methods, buildInterceptorMethodData(service, facts.name, md)) + if server { + md.ServerInterceptors = append(md.ServerInterceptors, facts.name) + } else { + md.ClientInterceptors = append(md.ClientInterceptors, facts.name) + } + } + return data +} + +// formatInterceptorAccess resolves the frozen type spelling for fields chosen +// during interceptor planning. +func formatInterceptorAccess(facts []*interceptorAccessFacts, resolver *declarationResolver) []*AttributeData { + if len(facts) == 0 { + return nil + } + data := make([]*AttributeData, len(facts)) + for index, field := range facts { + data[index] = &AttributeData{ + Name: field.name, + TypeRef: field.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)).Ref(), + Pointer: field.pointer, + } + } + return data +} + +// buildInterceptorMethodData creates the data needed to generate interceptor +// method code. +func buildInterceptorMethodData(service *serviceFacts, interceptorName string, md *MethodData) *MethodInterceptorData { + declaration := func(role serviceNameRole) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, method: md.VarName, subject: interceptorName, + }].declaration + } + var serverStream, clientStream *StreamInterceptorData + if md.ServerStream != nil { + serverStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ServerStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceServerStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ServerStream.Interface, + SendName: md.ServerStream.SendName, + SendWithContextName: md.ServerStream.SendWithContextName, + SendTypeRef: md.ServerStream.SendTypeRef, + RecvName: md.ServerStream.RecvName, + RecvWithContextName: md.ServerStream.RecvWithContextName, + RecvTypeRef: md.ServerStream.RecvTypeRef, + MustClose: md.ServerStream.MustClose, + EndpointStruct: md.ServerStream.EndpointStruct, + } + } + if md.ClientStream != nil { + clientStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ClientStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceClientStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ClientStream.Interface, + SendName: md.ClientStream.SendName, + SendWithContextName: md.ClientStream.SendWithContextName, + SendTypeRef: md.ClientStream.SendTypeRef, + RecvName: md.ClientStream.RecvName, + RecvWithContextName: md.ClientStream.RecvWithContextName, + RecvTypeRef: md.ClientStream.RecvTypeRef, + MustClose: md.ClientStream.MustClose, + } + } + payloadAccessDeclaration := declaration(serviceInterceptorPayloadAccessNameRole) + resultAccessDeclaration := declaration(serviceInterceptorResultAccessNameRole) + streamingPayloadAccessDeclaration := declaration(serviceInterceptorStreamingPayloadAccessNameRole) + streamingResultAccessDeclaration := declaration(serviceInterceptorStreamingResultAccessNameRole) + var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string + if payloadAccessDeclaration != nil { + payloadAccess = payloadAccessDeclaration.Name() + } + if resultAccessDeclaration != nil { + resultAccess = resultAccessDeclaration.Name() + } + if streamingPayloadAccessDeclaration != nil { + streamingPayloadAccess = streamingPayloadAccessDeclaration.Name() + } + if streamingResultAccessDeclaration != nil { + streamingResultAccess = streamingResultAccessDeclaration.Name() + } + return &MethodInterceptorData{ + PayloadAccessDeclaration: payloadAccessDeclaration, + ResultAccessDeclaration: resultAccessDeclaration, + StreamingPayloadAccessDeclaration: streamingPayloadAccessDeclaration, + StreamingResultAccessDeclaration: streamingResultAccessDeclaration, + ServerWrapperDeclaration: declaration(serviceServerInterceptorWrapperNameRole), + ClientWrapperDeclaration: declaration(serviceClientInterceptorWrapperNameRole), + MethodName: md.VarName, + PayloadAccess: payloadAccess, + ResultAccess: resultAccess, + PayloadRef: md.PayloadRef, + ResultRef: md.ResultRef, + StreamingPayloadAccess: streamingPayloadAccess, + StreamingPayloadRef: md.StreamingPayloadRef, + StreamingResultAccess: streamingResultAccess, + StreamingResultRef: md.ResultRef, + ClientStream: clientStream, + ServerStream: serverStream, + } +} diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index 855c9c4fa6..ef71d26b66 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -6,26 +6,46 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// InterceptorsFiles returns the interceptors files for the given service. -func InterceptorsFiles(genpkg string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { +type ( + // endpointInterceptorWrapperData binds one public endpoint wrapper to the + // exact private interceptor wrappers it applies in order. + endpointInterceptorWrapperData struct { + Declaration *codegen.NameDeclaration + InterceptorsDeclaration *codegen.NameDeclaration + Method string + Service string + Wrappers []*codegen.NameDeclaration + } + + // interceptorWrappersData supplies one side's exact interceptor interface + // and retained interceptor operations to its wrapper template. + interceptorWrappersData struct { + Service string + InterceptorsDeclaration *codegen.NameDeclaration + Interceptors []*InterceptorData + } +) + +// interceptorsFiles renders interceptors for the exact service retained by +// plan. +func interceptorsFiles(plan *Plan, facts *serviceFacts) []*codegen.File { var files []*codegen.File - svc := services.Get(service.Name) - outputPackage := genpkg + "/" + svc.PathName + services := plan.Services() + svc := services.Get(facts.name) // Generate service-specific interceptor files if len(svc.ServerInterceptors) > 0 { - files = append(files, interceptorFile(svc, services, outputPackage, true)) + files = append(files, interceptorFile(svc, facts.imports.serverInterceptors.specs, true)) } if len(svc.ClientInterceptors) > 0 { - files = append(files, interceptorFile(svc, services, outputPackage, false)) + files = append(files, interceptorFile(svc, facts.imports.clientInterceptors.specs, false)) } // Generate wrapper file if this service has any interceptors if len(svc.ServerInterceptors) > 0 || len(svc.ClientInterceptors) > 0 { - files = append(files, wrapperFile(svc, services, outputPackage)) + files = append(files, wrapperFile(svc, facts.imports.interceptorWrappers.specs)) } return files @@ -33,7 +53,7 @@ func InterceptorsFiles(genpkg string, service *expr.ServiceExpr, services *Servi // interceptorFile returns the file defining the interceptors. // This method is called twice, once for the server and once for the client. -func interceptorFile(svc *Data, services *ServicesData, outputPackage string, server bool) *codegen.File { +func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *codegen.File { filename := "client_interceptors.go" template := clientInterceptorsT section := "client-interceptors-type" @@ -51,6 +71,7 @@ func interceptorFile(svc *Data, services *ServicesData, outputPackage string, se if !server { interceptors = svc.ClientInterceptors } + appliedInterceptors := interceptors // We don't want to generate duplicate interceptor info data structures for // interceptors that are both server and client side so remove interceptors @@ -70,10 +91,7 @@ func interceptorFile(svc *Data, services *ServicesData, outputPackage string, se } sections := []*codegen.SectionTemplate{ - codegen.Header(desc, svc.PkgName, services.fileImports(outputPackage, []string{ - "context", - codegen.GoaImport("").Path, - })), + codegen.Header(desc, svc.PkgName, imports), { Name: section, Source: serviceTemplates.Read(template), @@ -99,20 +117,33 @@ func interceptorFile(svc *Data, services *ServicesData, outputPackage string, se } for _, m := range svc.Methods { ints := m.ServerInterceptors + declaration := m.ServerEndpointWrapperDeclaration + interceptorsDeclaration := svc.ServerInterceptorsDeclaration if !server { ints = m.ClientInterceptors + declaration = m.ClientEndpointWrapperDeclaration + interceptorsDeclaration = svc.ClientInterceptorsDeclaration } if len(ints) == 0 { continue } + wrappers := make([]*codegen.NameDeclaration, len(ints)) + for index, name := range ints { + interceptor := interceptorMethod(appliedInterceptors, name, m.VarName) + wrappers[index] = interceptor.ServerWrapperDeclaration + if !server { + wrappers[index] = interceptor.ClientWrapperDeclaration + } + } sections = append(sections, &codegen.SectionTemplate{ Name: section, Source: serviceTemplates.Read(template), - Data: map[string]any{ - "MethodVarName": m.VarName, - "Method": m.Name, - "Service": svc.Name, - "Interceptors": ints, + Data: &endpointInterceptorWrapperData{ + Declaration: declaration, + InterceptorsDeclaration: interceptorsDeclaration, + Method: m.Name, + Service: svc.Name, + Wrappers: wrappers, }, }) } @@ -133,14 +164,11 @@ func interceptorFile(svc *Data, services *ServicesData, outputPackage string, se } // wrapperFile returns the file containing the interceptor wrappers. -func wrapperFile(svc *Data, services *ServicesData, outputPackage string) *codegen.File { +func wrapperFile(svc *Data, imports []*codegen.ImportSpec) *codegen.File { path := filepath.Join(codegen.Gendir, svc.PathName, "interceptor_wrappers.go") var sections []*codegen.SectionTemplate - sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, services.fileImports(outputPackage, []string{ - "context", - codegen.GoaImport("").Path, - }))) + sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, imports)) // Generate any interceptor stream wrapper struct types first var wrappedServerStreams, wrappedClientStreams []*StreamInterceptorData @@ -174,9 +202,10 @@ func wrapperFile(svc *Data, services *ServicesData, outputPackage string) *codeg sections = append(sections, &codegen.SectionTemplate{ Name: "server-interceptor-wrappers", Source: serviceTemplates.Read(serverInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ServerInterceptors": svc.ServerInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + Interceptors: svc.ServerInterceptors, }, }) } @@ -184,9 +213,10 @@ func wrapperFile(svc *Data, services *ServicesData, outputPackage string) *codeg sections = append(sections, &codegen.SectionTemplate{ Name: "client-interceptor-wrappers", Source: serviceTemplates.Read(clientInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ClientInterceptors": svc.ClientInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + Interceptors: svc.ClientInterceptors, }, }) } @@ -217,6 +247,22 @@ func wrapperFile(svc *Data, services *ServicesData, outputPackage string) *codeg } } +// interceptorMethod returns the retained method record for one named +// interceptor application. Planning guarantees that both identities exist. +func interceptorMethod(interceptors []*InterceptorData, name, method string) *MethodInterceptorData { + for _, interceptor := range interceptors { + if interceptor.DesignName != name { + continue + } + for _, candidate := range interceptor.Methods { + if candidate.MethodName == method { + return candidate + } + } + } + panic("retained interceptor method is missing") +} + // hasPrivateImplementationTypes returns true if any of the interceptors have // private implementation types. func hasPrivateImplementationTypes(interceptors []*InterceptorData) bool { @@ -249,14 +295,14 @@ func collectWrappedStreams(interceptors []*InterceptorData, server bool) []*Stre if intr.HasStreamingPayloadAccess || intr.HasStreamingResultAccess { for _, method := range intr.Methods { if server { - if _, ok := streamNames[method.ServerStream.Interface]; !ok { + if _, ok := streamNames[method.ServerStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ServerStream) - streamNames[method.ServerStream.Interface] = struct{}{} + streamNames[method.ServerStream.InterfaceDeclaration.Name()] = struct{}{} } } else { - if _, ok := streamNames[method.ClientStream.Interface]; !ok { + if _, ok := streamNames[method.ClientStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ClientStream) - streamNames[method.ClientStream.Interface] = struct{}{} + streamNames[method.ClientStream.InterfaceDeclaration.Name()] = struct{}{} } } } diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index 77a2e33671..cedd07bd4e 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -15,9 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" - "goa.design/goa/v3/expr" ) var updateGolden = flag.Bool("update-interceptors", false, "update golden files for interceptor tests") @@ -49,10 +47,10 @@ func TestInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := InterceptorsFiles("goa.design/goa/example", root.Services[0], services) + fs := interceptorsFiles(plan, plan.facts.services[0]) require.Len(t, fs, c.expectedFileCount) for _, f := range fs { @@ -93,137 +91,6 @@ func TestInvalidInterceptors(t *testing.T) { } } -func TestCollectAttributes(t *testing.T) { - cases := []struct { - name string - attrNames *expr.AttributeExpr - parent *expr.AttributeExpr - want []*AttributeData - panics bool - }{ - { - name: "nil-attributes", - attrNames: nil, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "non-object-attributes", - attrNames: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "simple-string-attribute", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - }, - }, - { - name: "pointer-primitive", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - }, - want: []*AttributeData{ - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "multiple-attributes", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "attribute-not-in-parent", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "missing", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - panics: true, - }, - { - name: "user-type-with-package", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{Type: expr.String}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{ - Type: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Meta: map[string][]string{ - "struct:pkg:path": {"goa.design/goa/example/user"}, - }, - }, - TypeName: "User", - }, - }}, - }, - }, - want: []*AttributeData{ - {Name: "User", TypeRef: "*user.User", Pointer: false}, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - attributor := codegen.NewAttributeScope(codegen.NewNameScope()) - if tc.panics { - assert.Panics(t, func() { collectAttributes(tc.attrNames, tc.parent, attributor) }) - return - } - got := collectAttributes(tc.attrNames, tc.parent, attributor) - assert.Equal(t, tc.want, got) - }) - } -} - func compareOrUpdateGolden(t *testing.T, code, golden string) { t.Helper() if *updateGolden { diff --git a/codegen/service/method_data.go b/codegen/service/method_data.go new file mode 100644 index 0000000000..48fff66ad7 --- /dev/null +++ b/codegen/service/method_data.go @@ -0,0 +1,277 @@ +// This file formats retained service method and stream facts for service and transport templates. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// buildMethodData creates the data needed to render the given endpoint. It +// records the user types needed by the service definition in userTypes. +func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declarationResolver, serviceFacts *serviceFacts) (*MethodData, error) { + var ( + vname string + desc string + payloadName string + payloadLoc *codegen.Location + payloadDef string + payloadRef string + payloadDesc string + payloadEx any + rname string + resultLoc *codegen.Location + resultDef string + resultRef string + resultDesc string + resultEx any + errors []*ErrorInitData + errorLocs map[string]*codegen.Location + isJSONRPC bool + reqs = facts.requirements + schemes = facts.schemes + ) + vname = facts.varName + desc = facts.description + if desc == "" { + desc = codegen.Goify(facts.name, true) + " implements " + facts.name + "." + } + if facts.payload != nil && facts.payload.present { + payloadLoc = facts.payload.location + payloadName, payloadDef, payloadRef = retainedMethodTypeData(facts.payload, resolver) + payloadDesc = facts.payload.description + if payloadDesc == "" { + payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", + payloadName, serviceFacts.name, facts.name) + } + payloadEx = facts.payload.example + } + if facts.result != nil && facts.result.present { + resultLoc = facts.result.location + rname, resultDef, resultRef = retainedMethodTypeData(facts.result, resolver) + resultDesc = facts.result.description + if resultDesc == "" { + resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", + rname, serviceFacts.name, facts.name) + } + resultEx = facts.result.example + } + if len(facts.errors) > 0 { + errors = make([]*ErrorInitData, len(facts.errors)) + errorLocs = make(map[string]*codegen.Location, len(facts.errors)) + for i, errorFacts := range facts.errors { + errors[i] = buildRetainedErrorInitData(errorFacts, resolver, serviceFacts.errorConstructors[errorFacts.name]) + errorLocs[errorFacts.name] = errorFacts.location + } + } + isJSONRPC = facts.isJSONRPC + + data := &MethodData{ + EndpointDeclaration: serviceFacts.names.declaration(serviceSymbolID{ + role: serviceMethodEndpointNameRole, service: serviceFacts.name, method: facts.varName, + }), + EndpointInputDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceEndpointInputNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + EventDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceMethodEventNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + RequestDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceRequestNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ResponseDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceResponseNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + Name: facts.name, + VarName: vname, + Description: desc, + Idempotent: facts.idempotent, + Payload: payloadName, + PayloadLoc: payloadLoc, + PayloadDef: payloadDef, + PayloadRef: payloadRef, + PayloadDeclaration: facts.payload.layout.TypeDeclaration(), + PayloadDesc: payloadDesc, + PayloadEx: payloadEx, + PayloadDefault: facts.payload.defaultValue, + Result: rname, + ResultLoc: resultLoc, + ResultDef: resultDef, + ResultRef: resultRef, + ResultDeclaration: facts.result.layout.TypeDeclaration(), + ResultDesc: resultDesc, + ResultEx: resultEx, + Errors: errors, + ErrorLocs: errorLocs, + IsJSONRPC: isJSONRPC, + IsJSONRPCSSE: facts.isJSONRPCSSE, + IsJSONRPCWebSocket: facts.isJSONRPCWebSocket, + Requirements: reqs, + Schemes: schemes, + StreamKind: facts.streamKind, + HasMixedResults: facts.hasMixedResults, + SkipRequestBodyEncodeDecode: facts.skipRequestBodyEncodeDecode, + SkipResponseBodyEncodeDecode: facts.skipResponseBodyEncodeDecode, + RequestStruct: vname + "RequestData", + ResponseStruct: vname + "ResponseData", + EndpointField: facts.endpointField, + StreamEndpointField: facts.streamEndpointField, + } + + if err := d.initStreamData(data, facts, vname, rname, resultRef, resolver); err != nil { + return nil, err + } + return data, nil +} + +// initStreamData initializes the streaming payload data structures and methods. +func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vname, rname, resultRef string, resolver *declarationResolver) error { + if !facts.isStreaming && !facts.hasMixedResults { + return nil + } + var ( + spayloadName string + spayloadRef string + spayloadDef string + spayloadDesc string + spayloadEx any + srname = rname // streaming result name + srref = resultRef // streaming result ref + ) + + // If StreamingResult is different from Result, use it for streaming + if facts.hasMixedResults && facts.streamingResult != nil && facts.streamingResult.present { + srname, data.StreamingResultDef, srref = retainedMethodTypeData(facts.streamingResult, resolver) + data.StreamingResult = srname + data.StreamingResultRef = srref + data.StreamingResultDeclaration = facts.streamingResult.layout.TypeDeclaration() + data.StreamingResultDesc = facts.streamingResult.description + if data.StreamingResultDesc == "" { + data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", + srname, facts.serviceName, facts.name) + } + data.StreamingResultEx = facts.streamingResult.example + } + + if facts.streamingPayload != nil && facts.streamingPayload.present { + spayloadName, spayloadDef, spayloadRef = retainedMethodTypeData(facts.streamingPayload, resolver) + data.StreamingPayloadDeclaration = facts.streamingPayload.layout.TypeDeclaration() + spayloadDesc = facts.streamingPayload.description + if spayloadDesc == "" { + spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", + spayloadName, facts.serviceName, facts.name) + } + spayloadEx = facts.streamingPayload.example + } + // For JSON-RPC WebSocket: + // - Client streaming (no result streaming): no endpoint struct needed, just payload + // - Bidirectional streaming: endpoint struct needed for both payload and stream + var endpointStruct string + if data.EndpointInputDeclaration != nil { + endpointStruct = data.EndpointInputDeclaration.Name() + } + // For mixed results with SSE, treat as server streaming + streamKind := facts.streamKind + if facts.hasMixedResults && !facts.isStreaming { + // Mixed results with SSE should be treated as server streaming + streamKind = expr.ServerStreamKind + } + svrStream := &StreamData{ + Interface: data.ServerStreamDeclaration.Name(), + VarName: facts.serverStreamVarName, + EndpointStruct: endpointStruct, + Kind: streamKind, + SendName: "Send", + SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), + SendWithContextName: "SendWithContext", + SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), + SendTypeName: srname, + SendTypeRef: srref, + MustClose: true, + } + cliStream := &StreamData{ + Interface: data.ClientStreamDeclaration.Name(), + VarName: facts.clientStreamVarName, + Kind: streamKind, + RecvName: "Recv", + RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), + RecvWithContextName: "RecvWithContext", + RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), + RecvTypeName: srname, + RecvTypeRef: srref, + } + // For SSE server streaming, we need both Send (for notifications) and SendAndClose (for final response) + if data.IsJSONRPCSSE && facts.streamKind == expr.ServerStreamKind && resultRef != "" { + svrStream.SendAndCloseName = "SendAndClose" + svrStream.SendAndCloseDesc = fmt.Sprintf("SendAndClose sends a final response with %q and closes the stream.", srname) + // For JSON-RPC SSE, methods take context directly; align names accordingly + svrStream.SendWithContextName = "Send" + svrStream.RecvWithContextName = "Recv" + // Update Send description to clarify it's for notifications only + svrStream.SendDesc = fmt.Sprintf("Send streams JSON-RPC notifications with %q. Notifications do not expect a response.", srname) + } + if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { + switch streamKind { + case expr.ClientStreamKind: + if srref != "" { + svrStream.SendName = "SendAndClose" + svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) + svrStream.SendWithContextName = "SendAndCloseWithContext" + svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) + svrStream.MustClose = false + cliStream.RecvName = "CloseAndRecv" + cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) + cliStream.RecvWithContextName = "CloseAndRecvWithContext" + cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) + } else { + cliStream.MustClose = true + } + case expr.BidirectionalStreamKind: + cliStream.MustClose = true + } + svrStream.RecvName = "Recv" + svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) + svrStream.RecvWithContextName = "RecvWithContext" + svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) + svrStream.RecvTypeName = spayloadName + svrStream.RecvTypeRef = spayloadRef + cliStream.SendName = "Send" + cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) + cliStream.SendWithContextName = "SendWithContext" + cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) + cliStream.SendTypeName = spayloadName + cliStream.SendTypeRef = spayloadRef + } + data.ClientStream = cliStream + data.ServerStream = svrStream + data.StreamingPayload = spayloadName + data.StreamingPayloadDef = spayloadDef + data.StreamingPayloadRef = spayloadRef + data.StreamingPayloadDesc = spayloadDesc + data.StreamingPayloadEx = spayloadEx + return nil +} + +// retainedMethodTypeData formats one preplanned method type relative to the +// service output package without consulting its source expression. +func retainedMethodTypeData(facts *methodAttributeFacts, resolver *declarationResolver) (string, string, string) { + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + definition := "" + if facts.definition != nil { + definition = facts.definition.Link(facts.layout.Owner(), retainedTypeQualifier(resolver.aliases)).Def() + } + return linked.Name(), definition, linked.Ref() +} diff --git a/codegen/service/plan.go b/codegen/service/plan.go new file mode 100644 index 0000000000..aaca38a8d9 --- /dev/null +++ b/codegen/service/plan.go @@ -0,0 +1,943 @@ +// This file owns the service analysis retained by one generation run. It +// collects declarations before names freeze, links those exact records into +// immutable template data afterward, and exposes that data to service and +// transport renderers without rebuilding the expression graph. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput supplies one evaluated Goa root and the example generator whose + // stable identities belong to that root. + PlanInput struct { + // Root is one service design root owned by the generation. + Root *expr.RootExpr + // Examples produces the examples retained in that root's render plan. + Examples *expr.ExampleGenerator + } + + // Plan retains one design root's service declarations and linked render + // model from collection through generated file rendering. + Plan struct { + generation *codegen.Generation + facts *rootFacts + services *ServicesData + } + + // rootFacts retains the exact service membership selected during collection. + // Expression nodes are immutable after generator preparation; copying the + // containing slices prevents linking from rediscovering later membership. + rootFacts struct { + root *expr.RootExpr + apiName string + apiVersion string + examplePackageName string + services []*serviceFacts + serviceByID map[string]*serviceFacts + types []expr.UserType + rootTypes *rootTypeSet + examples *expr.ExampleGenerator + externalConversions []*externalConversionFileFacts + generatedTypes []*generatedTypeEmissionFacts + generatedUnions []*generatedUnionEmissionFacts + } + + // serviceFacts retains the exact service inputs selected during collection. + serviceFacts struct { + service *expr.ServiceExpr + name string + description string + packagePath string + viewsPath string + methods []*expr.MethodExpr + orderedMethods []*methodFacts + methodByExpr map[*expr.MethodExpr]*methodFacts + errors []*expr.ErrorExpr + errorFacts []*errorRenderFacts + serverInterceptors []*expr.InterceptorExpr + clientInterceptors []*expr.InterceptorExpr + serverInterceptorFacts []*interceptorFacts + clientInterceptorFacts []*interceptorFacts + referenceAttributes []*expr.AttributeExpr + reachableTypes map[expr.UserType]struct{} + projections map[*expr.MethodExpr]*projectionFacts + userTypes []*userTypeFacts + errorTypes []*userTypeFacts + unions []*unionFacts + viewUnions []*unionFacts + names serviceNames + validators map[validatorKey]*codegen.NameDeclaration + errorConstructors map[string]*codegen.NameDeclaration + generatedTypeImports map[*codegen.TypeDeclaration]*retainedFileImports + exampleStruct *codegen.NameDeclaration + exampleConstructor *codegen.NameDeclaration + exampleServerStruct *codegen.NameDeclaration + exampleServerConstructor *codegen.NameDeclaration + exampleClientStruct *codegen.NameDeclaration + exampleClientConstructor *codegen.NameDeclaration + imports serviceFileImports + data *Data + } + + // methodFacts retains transport decisions that belong to one service method. + methodFacts struct { + method *expr.MethodExpr + serviceName string + name string + description string + idempotent bool + payload *methodAttributeFacts + result *methodAttributeFacts + streamingPayload *methodAttributeFacts + streamingResult *methodAttributeFacts + errors []*errorRenderFacts + requirements RequirementsData + schemes SchemesData + streamKind expr.StreamKind + isStreaming bool + hasMixedResults bool + isJSONRPC bool + varName string + serverStreamVarName string + clientStreamVarName string + endpointField string + streamEndpointField string + viewedResult *viewedResultFacts + projection *projectionFacts + isJSONRPCSSE bool + isJSONRPCWebSocket bool + skipRequestBodyEncodeDecode bool + skipResponseBodyEncodeDecode bool + } + + // methodAttributeFacts retains one method value's top-level contract and + // example while its nested Go layout is owned by codegen.GoTypePlan. + methodAttributeFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + definition *codegen.GoTypePlan + normalized bool + present bool + isObject bool + location *codegen.Location + description string + defaultValue any + example any + } + + // errorRenderFacts retains the exact error behavior and type selected for + // service, client, and endpoint output. + errorRenderFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + name string + description string + location *codegen.Location + temporary bool + timeout bool + fault bool + serviceType bool + } + + // interceptorFacts retains the exact methods to which one interceptor + // applies on one side of the service boundary. + interceptorFacts struct { + name string + description string + readPayload *expr.AttributeExpr + writePayload *expr.AttributeExpr + readResult *expr.AttributeExpr + writeResult *expr.AttributeExpr + readStreamingPayload *expr.AttributeExpr + writeStreamingPayload *expr.AttributeExpr + readStreamingResult *expr.AttributeExpr + writeStreamingResult *expr.AttributeExpr + readPayloadFields []*interceptorAccessFacts + writePayloadFields []*interceptorAccessFacts + readResultFields []*interceptorAccessFacts + writeResultFields []*interceptorAccessFacts + readStreamingPayloadFields []*interceptorAccessFacts + writeStreamingPayloadFields []*interceptorAccessFacts + readStreamingResultFields []*interceptorAccessFacts + writeStreamingResultFields []*interceptorAccessFacts + methods []*methodFacts + } + + // interceptorAccessFacts retains one generated accessor field and its exact + // type layout from the first method to which the interceptor applies. + interceptorAccessFacts struct { + name string + pointer bool + layout *codegen.GoTypePlan + } + + // projectionFacts owns the single projected graph built for one method. + // Planning declares names from this graph; linking formats the same nodes. + projectionFacts struct { + pairs []*projectedTypePair + types []*projectedTypeFacts + } + + // projectedTypeFacts retains one projected declaration graph and the exact + // validation and conversion operations selected from it. + projectedTypeFacts struct { + pair *projectedTypePair + projectedType expr.UserType + projected *codegen.GoTypePlan + definition *codegen.GoTypePlan + source *codegen.GoTypePlan + resultType bool + views []*viewRenderFacts + validations []*validationFacts + conversions []*viewConversionFacts + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + } + + // viewRenderFacts retains the authored view text and ordered field names + // used by service and views templates. + viewRenderFacts struct { + name string + description string + attributes []string + } + + // validationFacts retains one projected validator's selected fields and + // nested validator calls without resolving function names. + validationFacts struct { + viewName string + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + plan *codegen.ValidationPlan + declaration *codegen.NameDeclaration + alias bool + pointer bool + collectionElem *expr.AttributeExpr + collectionCall *codegen.NameDeclaration + fields []*validationFieldFacts + } + + // validationFieldFacts retains one nested result-type field call. + validationFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + required bool + call *codegen.NameDeclaration + } + + // viewConversionFacts retains one view-narrowed conversion and its exact + // recursive transform plan. + viewConversionFacts struct { + toResult bool + viewName string + source *expr.AttributeExpr + target *expr.AttributeExpr + transformTarget *expr.AttributeExpr + fields []*viewConversionFieldFacts + plan *codegen.TransformPlan + targetLayout *codegen.GoTypePlan + collection bool + contextType expr.UserType + contextIdentity codegen.DerivedTypeID + elementType expr.UserType + elementIdentity codegen.DerivedTypeID + constructor *codegen.NameDeclaration + elementCall *codegen.NameDeclaration + } + + // viewConversionFieldFacts retains one nested result constructor call that + // is emitted outside the general type transform. + viewConversionFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + call *codegen.NameDeclaration + } + + // viewedResultFacts retains the wrapper type and selected view behavior for + // one method result. + viewedResultFacts struct { + wrapped expr.UserType + wrappedLayout *codegen.GoTypePlan + wrappedDef *codegen.GoTypePlan + projected *projectedTypeFacts + origin expr.UserType + source *methodAttributeFacts + viewName string + views []*viewRenderFacts + conversions []*viewConversionFacts + toViewed *codegen.NameDeclaration + toResult *codegen.NameDeclaration + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + validator *codegen.NameDeclaration + validationCalls []*codegen.NameDeclaration + isCollection bool + } + + // userTypeFacts binds one selected expression type to the exact package + // declaration and inherited output location chosen during collection. + userTypeFacts struct { + userType expr.UserType + name string + description string + errorName string + serviceError bool + location *codegen.Location + declaration *codegen.TypeDeclaration + layout *codegen.GoTypePlan + reference *codegen.GoTypePlan + imports retainedFileImports + } + + // unionFacts binds one selected sum type to its exact package declaration. + unionFacts struct { + union *expr.Union + identity codegen.UnionTypeID + typeKey string + valueKey string + branches []*unionBranchFacts + location *codegen.Location + declaration *codegen.UnionDeclaration + imports retainedFileImports + data *UnionTypeData + } + + // unionBranchFacts retains one emitted union branch and its exact generated + // declaration and Go layout. + unionBranchFacts struct { + name string + fieldName string + declaration *codegen.UnionBranchDeclaration + layout *codegen.GoTypePlan + nilable bool + emitPrimitiveAlias bool + primitiveAliasType string + } + + // validatorKey identifies the exact generated type and result view whose + // validation function is called by projected validation code. + validatorKey struct { + declaration *codegen.TypeDeclaration + view string + } + + // viewConversionCallKey identifies one private constructor by the source + // result declaration, selected view, and conversion direction. + viewConversionCallKey struct { + origin expr.UserType + view string + toResult bool + } + + // streamWrapperKey identifies one side of a retained method stream. + streamWrapperKey struct { + method *expr.MethodExpr + server bool + } +) + +// collectServiceNames declares every package-level symbol emitted for one +// core service and its views package before generation names freeze. +func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + service := facts.service + serviceName := service.Name + servicePackage := generation.Package(servicePackagePath(generation.GenPkg(), service)) + viewsPackage := generation.Package(servicePackagePath(generation.GenPkg(), service) + "/views") + examplePackage, err := generation.ClaimOutputPackage(path.Dir(generation.GenPkg()), ".") + if err != nil { + return err + } + exampleInterceptorsPackage, err := generation.ClaimOutputPackage( + path.Join(path.Dir(generation.GenPkg()), "interceptors"), + "interceptors", + ) + if err != nil { + return err + } + facts.names = make(serviceNames) + facts.validators = make(map[validatorKey]*codegen.NameDeclaration) + facts.errorConstructors = make(map[string]*codegen.NameDeclaration) + declare := func(pkg *codegen.GeneratedPackage, role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = serviceName + _, err := facts.names.declare(pkg, id, preferred) + return err + } + static := []struct { + role serviceNameRole + preferred string + }{ + {serviceInterfaceNameRole, "Service"}, + {serviceAPINameRole, "APIName"}, + {serviceAPIVersionNameRole, "APIVersion"}, + {serviceNameConstantRole, "ServiceName"}, + {serviceMethodNamesRole, "MethodNames"}, + {serviceEndpointsNameRole, "Endpoints"}, + {serviceNewEndpointsNameRole, "NewEndpoints"}, + {serviceClientNameRole, "Client"}, + {serviceNewClientNameRole, "NewClient"}, + } + if serviceHasSchemes(facts) { + static = append(static, struct { + role serviceNameRole + preferred string + }{serviceAutherNameRole, "Auther"}) + } + for _, symbol := range static { + if err := declare(servicePackage, symbol.role, symbol.preferred, serviceSymbolID{}); err != nil { + return err + } + } + facts.exampleStruct, err = facts.names.declare(examplePackage, serviceSymbolID{ + role: serviceExampleStructNameRole, service: serviceName, + }, codegen.Goify(serviceName, false)+"srvc") + if err != nil { + return err + } + facts.exampleConstructor, err = facts.names.declare(examplePackage, serviceSymbolID{ + role: serviceExampleConstructorNameRole, service: serviceName, + }, "New"+codegen.Goify(serviceName, true)) + if err != nil { + return err + } + structName := codegen.Goify(serviceName, true) + if len(facts.serverInterceptors) > 0 { + facts.exampleServerStruct, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsStructNameRole, service: serviceName, + }, structName+"ServerInterceptors") + if err != nil { + return err + } + facts.exampleServerConstructor, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ServerInterceptors") + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.exampleClientStruct, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsStructNameRole, service: serviceName, + }, structName+"ClientInterceptors") + if err != nil { + return err + } + facts.exampleClientConstructor, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ClientInterceptors") + if err != nil { + return err + } + } + if hasRetainedJSONRPCStreaming(facts) { + if err := declare(servicePackage, serviceStreamNameRole, "Stream", serviceSymbolID{}); err != nil { + return err + } + if hasRetainedJSONRPCSSEResults(facts) { + if err := declare(servicePackage, serviceEventNameRole, "Event", serviceSymbolID{}); err != nil { + return err + } + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodID := serviceSymbolID{method: methodFacts.varName} + if method.IsStreaming() || method.HasMixedResults() { + if err := declare(servicePackage, serviceServerStreamNameRole, methodFacts.varName+"ServerStream", methodID); err != nil { + return err + } + if err := declare(servicePackage, serviceClientStreamNameRole, methodFacts.varName+"ClientStream", methodID); err != nil { + return err + } + if !methodFacts.isJSONRPCWebSocket || method.Stream != expr.ClientStreamKind { + if err := declare(servicePackage, serviceEndpointInputNameRole, methodFacts.varName+"EndpointInput", methodID); err != nil { + return err + } + } + } + if methodFacts.isJSONRPCSSE { + if err := declare(servicePackage, serviceMethodEventNameRole, methodFacts.varName+"Event", methodID); err != nil { + return err + } + } + if err := declare(servicePackage, serviceMethodEndpointNameRole, "New"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + if methodFacts.skipRequestBodyEncodeDecode { + if err := declare(servicePackage, serviceRequestNameRole, methodFacts.varName+"RequestData", methodID); err != nil { + return err + } + } + if methodFacts.skipResponseBodyEncodeDecode { + if err := declare(servicePackage, serviceResponseNameRole, methodFacts.varName+"ResponseData", methodID); err != nil { + return err + } + } + if len(method.ServerInterceptors) > 0 { + if err := declare(servicePackage, serviceServerEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + } + if len(method.ClientInterceptors) > 0 { + if err := declare(servicePackage, serviceClientEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"ClientEndpoint", methodID); err != nil { + return err + } + } + } + if err := collectErrorNames(facts, servicePackage); err != nil { + return err + } + if err := collectInterceptorNames(facts, servicePackage); err != nil { + return err + } + return collectViewNames(facts, servicePackage, viewsPackage, rootTypes, generation) +} + +// hasRetainedJSONRPCSSEResults reports whether the SSE service template emits +// its package-level Event interface for at least one concrete result. +func hasRetainedJSONRPCSSEResults(facts *serviceFacts) bool { + for method, retained := range facts.methodByExpr { + if retained.isJSONRPCSSE && method.Result.Type != expr.Empty { + return true + } + } + return false +} + +// serviceHasSchemes reports whether any retained method requires generated +// authorization functions. +func serviceHasSchemes(facts *serviceFacts) bool { + for _, method := range facts.methods { + if len(method.Requirements) > 0 { + return true + } + } + return false +} + +// collectErrorNames declares the constructors emitted for distinct Goa +// service errors shared by service-level and method-level declarations. +func collectErrorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + seen := make(map[string]struct{}) + errors := append([]*expr.ErrorExpr(nil), facts.errors...) + for _, method := range facts.methods { + errors = append(errors, method.Errors...) + } + for _, serviceError := range errors { + if serviceError.Type != expr.ErrorResult { + continue + } + if _, exists := seen[serviceError.Name]; exists { + continue + } + seen[serviceError.Name] = struct{}{} + declaration, err := facts.names.declare(servicePackage, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: facts.service.Name, + subject: serviceError.Name, + }, "Make"+codegen.Goify(serviceError.Name, true)) + if err != nil { + return err + } + facts.errorConstructors[serviceError.Name] = declaration + } + return nil +} + +// collectInterceptorNames declares interceptor interfaces, typed accessors, +// wrappers, and stream wrapper structs in the service package. +func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + declare := func(role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = facts.service.Name + _, err := facts.names.declare(servicePackage, id, preferred) + return err + } + if len(facts.serverInterceptors) > 0 { + if err := declare(serviceServerInterceptorsNameRole, "ServerInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + if err := declare(serviceClientInterceptorsNameRole, "ClientInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + interceptors := append(append([]*expr.InterceptorExpr(nil), facts.serverInterceptors...), facts.clientInterceptors...) + seenInterceptors := make(map[string]struct{}) + seenStreams := make(map[streamWrapperKey]struct{}) + for _, interceptor := range interceptors { + if _, exists := seenInterceptors[interceptor.Name]; !exists { + seenInterceptors[interceptor.Name] = struct{}{} + base := codegen.Goify(interceptor.Name, true) + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorInfoNameRole, "Info", true}, + {serviceInterceptorPayloadNameRole, "Payload", interceptor.ReadPayload != nil || interceptor.WritePayload != nil}, + {serviceInterceptorResultNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadNameRole, "StreamingPayload", interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil}, + {serviceInterceptorStreamingResultNameRole, "StreamingResult", interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, serviceSymbolID{subject: interceptor.Name}); err != nil { + return err + } + } + } + for _, method := range facts.methods { + server := interceptorNamed(method.ServerInterceptors, interceptor.Name) + client := interceptorNamed(method.ClientInterceptors, interceptor.Name) + if !server && !client { + continue + } + methodName := facts.methodByExpr[method].varName + base := codegen.Goify(interceptor.Name, false) + methodName + methodID := serviceSymbolID{method: facts.methodByExpr[method].varName, subject: interceptor.Name} + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorPayloadAccessNameRole, "Payload", interceptor.ReadPayload != nil || interceptor.WritePayload != nil}, + {serviceInterceptorResultAccessNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadAccessNameRole, "StreamingPayload", interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil}, + {serviceInterceptorStreamingResultAccessNameRole, "StreamingResult", interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, methodID); err != nil { + return err + } + } + if server { + if err := declare(serviceServerInterceptorWrapperNameRole, "wrap"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if client { + if err := declare(serviceClientInterceptorWrapperNameRole, "wrapClient"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if (!method.IsStreaming() && !method.HasMixedResults()) || !interceptorHasStreamingAccess(interceptor) { + continue + } + for _, side := range []struct { + server bool + role serviceNameRole + name string + }{ + {true, serviceServerStreamWrapperNameRole, "wrapped" + methodName + "ServerStream"}, + {false, serviceClientStreamWrapperNameRole, "wrapped" + methodName + "ClientStream"}, + } { + key := streamWrapperKey{method: method, server: side.server} + if _, exists := seenStreams[key]; exists || side.server && !server || !side.server && !client { + continue + } + seenStreams[key] = struct{}{} + if err := declare(side.role, side.name, serviceSymbolID{method: methodName}); err != nil { + return err + } + } + } + } + return nil +} + +// collectViewNames declares validators and constructor/map companions from +// the exact service and view type declarations allocated during view planning. +func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen.GeneratedPackage, rootTypes *rootTypeSet, generation *codegen.Generation) error { + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + declaration, err := viewsPackage.DerivedType(codegen.NewProjectedTypeID(pair.source)) + if err != nil { + return err + } + projectedFacts.declaration = declaration + views := []string{""} + if resultType, ok := pair.projected.(*expr.ResultTypeExpr); ok { + views = views[:0] + for _, view := range resultType.Views { + views = append(views, view.Name) + } + } + for _, view := range views { + view = canonicalValidatorView(view) + suffix := "" + if view != "" { + suffix = codegen.Goify(view, true) + } + key := validatorKey{declaration: declaration, view: view} + if facts.validators[key] != nil { + continue + } + id := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: view, + side: "projected", + } + validator, err := facts.names.declareDependent(viewsPackage, id, declaration.Declaration(), "Validate", suffix) + if err != nil { + return err + } + facts.validators[key] = validator + for _, validation := range projectedFacts.validations { + if canonicalValidatorView(validation.viewName) == view { + validation.declaration = validator + break + } + } + } + if _, ok := pair.projected.(*expr.ResultTypeExpr); ok { + projectedFacts.mapDeclaration, err = facts.names.declare(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + }, codegen.Goify(pair.source.Name(), true)+"Map") + if err != nil { + return err + } + } + for _, conversion := range projectedFacts.conversions { + side := "to-projected" + preferredBase := codegen.Goify(pair.projected.Name(), true) + if conversion.toResult { + side = "to-result" + preferredBase = codegen.Goify(pair.source.Name(), true) + } + suffix := "" + if conversion.viewName != expr.DefaultView { + suffix = codegen.Goify(conversion.viewName, true) + } + conversion.constructor, err = facts.names.declare(servicePackage, serviceSymbolID{ + role: servicePrivateProjectionConstructorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: canonicalValidatorView(conversion.viewName), + side: side, + }, "new"+preferredBase+suffix) + if err != nil { + return err + } + if conversion.plan == nil { + continue + } + for _, helper := range conversion.plan.Helpers() { + sourceName, sourceID := transformDataTypeName(helper.Source.Type) + targetName, targetID := transformDataTypeName(helper.Target.Type) + sourcePreferred := sourceName + targetPreferred := targetName + viewsPackageName := strings.ToLower(codegen.Goify(facts.service.Name, false)) + "views" + if conversion.toResult { + sourcePreferred = viewsPackageName + codegen.Goify(sourceName, true) + } else { + targetPreferred = viewsPackageName + codegen.Goify(targetName, true) + } + declaration, err := facts.names.declare(servicePackage, serviceSymbolID{ + role: serviceTransformHelperNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + view: canonicalValidatorView(conversion.viewName), + source: sourceID, + target: targetID, + side: side, + occurrence: helper.Occurrence, + required: helper.Required, + }, "transform"+codegen.Goify(sourcePreferred, true)+"To"+codegen.Goify(targetPreferred, true)) + if err != nil { + return err + } + if err := conversion.plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return err + } + } + } + } + resultType, hasViews := method.Result.Type.(*expr.ResultTypeExpr) + if !hasViews { + continue + } + for _, projected := range projection.types { + if projected.pair.source.Origin() == resultType.Origin() { + facts.methodByExpr[method].viewedResult.conversions = projected.conversions + break + } + } + viewedDeclaration, err := viewsPackage.DerivedType(codegen.NewViewedResultTypeID(resultType)) + if err != nil { + return err + } + facts.methodByExpr[method].viewedResult.declaration = viewedDeclaration + viewedValidatorKey := validatorKey{declaration: viewedDeclaration} + if facts.validators[viewedValidatorKey] == nil { + validatorID := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + method: facts.methodByExpr[method].varName, + subject: resultType.ID(), + source: resultType.Name(), + side: "viewed", + } + validator, err := facts.names.declareDependent(viewsPackage, validatorID, viewedDeclaration.Declaration(), "Validate", "") + if err != nil { + return err + } + facts.validators[viewedValidatorKey] = validator + } + facts.methodByExpr[method].viewedResult.validator = facts.validators[viewedValidatorKey] + for _, symbol := range []struct { + role serviceNameRole + prefix string + side string + }{ + {serviceViewConstructorNameRole, "NewViewed", "to-viewed"}, + {serviceViewConstructorNameRole, "New", "to-result"}, + } { + constructor, err := facts.names.declare(servicePackage, serviceSymbolID{ + role: symbol.role, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + side: symbol.side, + }, symbol.prefix+codegen.Goify(resultType.Name(), true)) + if err != nil { + return err + } + if symbol.side == "to-viewed" { + facts.methodByExpr[method].viewedResult.toViewed = constructor + } else { + facts.methodByExpr[method].viewedResult.toResult = constructor + } + } + facts.methodByExpr[method].viewedResult.mapDeclaration, err = facts.names.declare(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + }, codegen.Goify(resultType.Name(), true)+"Map") + if err != nil { + return err + } + viewedFacts := facts.methodByExpr[method].viewedResult + for _, view := range viewedFacts.views { + declaration := facts.validators[validatorKey{ + declaration: viewedFacts.projected.declaration, + view: canonicalValidatorView(view.name), + }] + if declaration == nil { + return fmt.Errorf("validator for viewed result %q view %q was not declared", resultType.Name(), view.name) + } + viewedFacts.validationCalls = append(viewedFacts.validationCalls, declaration) + } + } + linkViewConversionCalls(facts) + return planServiceValidations(facts, rootTypes, generation) +} + +// linkViewConversionCalls binds collection and nested constructor calls to the +// exact retained function records selected for their projected type and view. +func linkViewConversionCalls(facts *serviceFacts) { + lookup := make(map[viewConversionCallKey]*codegen.NameDeclaration) + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + origin := projected.pair.projected.Origin() + if conversion.toResult { + origin = projected.pair.source.Origin() + } + lookup[viewConversionCallKey{ + origin: origin, + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] = conversion.constructor + } + } + } + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + collection := projected.pair.projected + if conversion.toResult { + collection = projected.pair.source + } + if array := expr.AsArray(collection); array != nil { + userType := array.ElemType.Type.(expr.UserType) + conversion.elementCall = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] + } + for _, field := range conversion.fields { + userType := field.attribute.Type.(expr.UserType) + field.call = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(field.view), + toResult: conversion.toResult, + }] + } + } + } + } +} + +// interceptorHasStreamingAccess reports whether interceptor causes a wrapped +// stream implementation to be emitted. +func interceptorHasStreamingAccess(interceptor *expr.InterceptorExpr) bool { + return interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil || + interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil +} + +// hasRetainedJSONRPCStreaming reports whether the retained methods emit the +// package-level JSON-RPC Stream declaration. +func hasRetainedJSONRPCStreaming(facts *serviceFacts) bool { + for _, method := range facts.methods { + if _, jsonRPC := method.Meta["jsonrpc"]; jsonRPC && (method.IsStreaming() || method.HasMixedResults()) { + return true + } + } + return false +} + +// interceptorNamed reports whether interceptors contains name. +func interceptorNamed(interceptors []*expr.InterceptorExpr, name string) bool { + for _, interceptor := range interceptors { + if interceptor.Name == name { + return true + } + } + return false +} diff --git a/codegen/service/plan_lifecycle.go b/codegen/service/plan_lifecycle.go new file mode 100644 index 0000000000..da475d6bf7 --- /dev/null +++ b/codegen/service/plan_lifecycle.go @@ -0,0 +1,118 @@ +// This file owns the service planning lifecycle across every Goa design root +// in one generation. It validates complete input membership, collects each +// root once, and assigns files shared by multiple roots before names freeze. +package service + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// NewPlans collects every service root owned by generation in one operation. +// Root-local facts remain in separate plans, while declarations and files that +// can be shared across roots are assigned once across the complete input set. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if _, ok := owned[input.Root]; !ok { + return nil, rootMembershipError(input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("service root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf( + "service planning requires all %d generation roots, got %d", + len(owned), + len(inputs), + ) + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + facts, err := collectRootFacts(input.Root, generation, input.Examples) + if err != nil { + return nil, err + } + plans[index] = &Plan{generation: generation, facts: facts} + } + allFacts := make([]*rootFacts, len(plans)) + for index, plan := range plans { + allFacts[index] = plan.facts + } + if err := collectGeneratedPackageEmissions(allFacts); err != nil { + return nil, err + } + if err := collectExternalConversions(allFacts, generation); err != nil { + return nil, err + } + return plans, nil +} + +// NewPlan collects the only service root owned by generation. Generations +// containing multiple service roots must use NewPlans so shared package files +// and receiver methods are planned once across the complete run. +func NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*Plan, error) { + plans, err := NewPlans(generation, PlanInput{Root: root, Examples: examples}) + if err != nil { + return nil, err + } + return plans[0], nil +} + +// collectRootFacts retains one root's service facts and declares its +// root-owned symbols before run-wide file ownership is assigned. +func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*rootFacts, error) { + examplePackageScope := codegen.NewNameScope() + for _, service := range root.Services { + examplePackageScope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + facts := &rootFacts{ + root: root, + apiName: root.API.Name, + apiVersion: root.API.Version, + examplePackageName: examplePackageScope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api"), + serviceByID: make(map[string]*serviceFacts, len(root.Services)), + types: append([]expr.UserType(nil), root.Types...), + rootTypes: newRootTypeSet(root), + examples: examples, + } + for _, service := range root.Services { + serviceFacts := collectServiceFacts(root, service, examples) + serviceFacts.packagePath = servicePackagePath(generation.GenPkg(), service) + serviceFacts.viewsPath = serviceFacts.packagePath + "/views" + facts.services = append(facts.services, serviceFacts) + facts.serviceByID[service.Name] = serviceFacts + } + if err := collectServiceDeclarations(facts, generation); err != nil { + return nil, err + } + for _, serviceFacts := range facts.services { + if err := collectServiceNames(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceTypeFacts(serviceFacts, facts.types, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceUnionFacts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceTypeLayouts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceFileImports(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + } + return facts, nil +} diff --git a/codegen/service/retained_expression_mutation_contract_test.go b/codegen/service/retained_expression_mutation_contract_test.go new file mode 100644 index 0000000000..4e527d6cd3 --- /dev/null +++ b/codegen/service/retained_expression_mutation_contract_test.go @@ -0,0 +1,167 @@ +// This file proves service rendering uses only facts collected before the +// generation freezes, even if callers later mutate the evaluated expressions. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type retainedExpressionFixture struct { + root *expr.RootExpr + service *expr.ServiceExpr + method *expr.MethodExpr + result *expr.ResultTypeExpr + interceptor *expr.InterceptorExpr +} + +// TestServicePlanIgnoresRetainedExpressionMutation catches linking and +// rendering that reread mutable service, method, view, error, interceptor, +// security, example, or stream expressions after NewPlan returns. +func TestServicePlanIgnoresRetainedExpressionMutation(t *testing.T) { + baselineFixture := retainedExpressionMutationFixture(t) + baselinePlan := retainedServicePlanForPackage(t, baselineFixture.root, "generated.local/gen") + baseline := renderedPlanAndExamples(t, baselinePlan) + baselineMethod := baselinePlan.Services().Get("RetainedMutable").Methods[0] + + tests := []struct { + name string + mutate func(*retainedExpressionFixture) + }{ + {"service and method", func(f *retainedExpressionFixture) { + f.service.Name = "MutatedService" + f.service.Description = "mutated service" + f.method.Name = "MutatedMethod" + f.method.Description = "mutated method" + f.method.Idempotent = !f.method.Idempotent + }}, + {"errors", func(f *retainedExpressionFixture) { + f.service.Errors[0].Description = "mutated service error" + f.method.Errors[0].Description = "mutated method error" + f.method.Errors[0].Meta = expr.MetaExpr{"goa:error:fault": nil} + }}, + {"interceptor", func(f *retainedExpressionFixture) { + f.interceptor.Description = "mutated interceptor" + f.interceptor.ReadPayload = nil + f.interceptor.ReadStreamingPayload = nil + }}, + {"security", func(f *retainedExpressionFixture) { + f.method.Requirements[0].Scopes[0] = "mutated" + f.method.Requirements[0].Schemes[0].Scopes[0].Name = "mutated" + }}, + {"examples", func(f *retainedExpressionFixture) { + f.method.Payload.UserExamples[0].Value = map[string]any{"key": "mutated"} + f.method.StreamingPayload.UserExamples[0].Value = map[string]any{"chunk": "mutated"} + }}, + {"stream", func(f *retainedExpressionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Description = "mutated streaming payload" + f.method.StreamingResult.Description = "mutated streaming result" + }}, + {"type layout", func(f *retainedExpressionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("key") + field.Description = "mutated field" + field.Meta = expr.MetaExpr{"struct:field:name": []string{"MutatedKey"}} + }}, + {"result and view", func(f *retainedExpressionFixture) { + f.method.Result.Description = "mutated result" + f.result.Views[0].Description = "mutated view" + viewObject := expr.AsObject(f.result.Views[0].Type) + viewObject.Set("extra", &expr.AttributeExpr{Type: expr.String}) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := retainedExpressionMutationFixture(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{fixture.root}) + plan, err := NewPlan(fixture.root, generation, expr.NewExampleGenerator(fixture.root.API.RandomizerFactory)) + require.NoError(t, err) + test.mutate(fixture) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + requireRenderedServiceFilesEqual(t, baseline, renderedPlanAndExamples(t, plan)) + + method := plan.Services().Get("RetainedMutable").Methods[0] + require.Equal(t, baselineMethod.PayloadEx, method.PayloadEx) + require.Equal(t, baselineMethod.StreamingPayloadEx, method.StreamingPayloadEx) + }) + } +} + +// retainedExpressionMutationFixture builds one service that exercises every +// expression family the retained core service plan must finish collecting. +func retainedExpressionMutationFixture(t *testing.T) *retainedExpressionFixture { + t.Helper() + fixture := new(retainedExpressionFixture) + fixture.root = codegen.RunDSL(t, func() { + auth := dsl.APIKeySecurity("key", func() { + dsl.Scope("read", "Read values") + }) + fixture.interceptor = dsl.Interceptor("Audit", func() { + dsl.Description("Audits request values.") + dsl.ReadPayload(func() { dsl.Attribute("key") }) + dsl.ReadStreamingPayload(func() { dsl.Attribute("chunk") }) + }) + result := dsl.ResultType("application/vnd.retained", func() { + dsl.TypeName("RetainedResult") + dsl.Description("The retained result.") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + fixture.result = result + streamResult := dsl.Type("RetainedStreamResult", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + serviceError := dsl.Type("RetainedServiceError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + methodError := dsl.Type("RetainedMethodError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + fixture.service = dsl.Service("RetainedMutable", func() { + dsl.Description("The retained mutable service.") + dsl.Security(auth, func() { dsl.Scope("read") }) + dsl.ServerInterceptor(fixture.interceptor) + dsl.ClientInterceptor(fixture.interceptor) + dsl.Error("service_failed", serviceError, "The service failed.") + fixture.method = dsl.Method("Watch", func() { + dsl.Description("Watches retained values.") + dsl.Payload(func() { + dsl.APIKey("key", "key", dsl.String) + dsl.Required("key") + dsl.Example(map[string]any{"key": "original"}) + }) + dsl.StreamingPayload(func() { + dsl.Attribute("chunk", dsl.String) + dsl.Required("chunk") + dsl.Example(map[string]any{"chunk": "original"}) + }) + dsl.Result(result) + dsl.StreamingResult(streamResult) + dsl.Error("method_failed", methodError, "The method failed.") + }) + }) + }) + return fixture +} + +// renderedPlanAndExamples renders both generated service packages and their +// starter implementation so post-link expression reads cannot hide in either. +func renderedPlanAndExamples(t *testing.T, plan *Plan) map[string][]byte { + t.Helper() + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + return renderedServiceFiles(t, files) +} diff --git a/codegen/service/retained_plan_test.go b/codegen/service/retained_plan_test.go new file mode 100644 index 0000000000..455832b4b8 --- /dev/null +++ b/codegen/service/retained_plan_test.go @@ -0,0 +1,93 @@ +// This file verifies that service planning retains one immutable render model +// per design root. Definitions and references must consume the exact package- +// owned declaration record collected by that plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePlanSharesDefinitionAndReferenceDeclaration catches service +// analysis that reconstructs a payload name independently from its definition. +func TestServicePlanSharesDefinitionAndReferenceDeclaration(t *testing.T) { + var payload expr.UserType + root := codegen.RunDSL(t, func() { + payload = dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + root.Service("Values").Methods = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + owner := generation.Package("generated.local/gen/values") + declaration, err := owner.UserType(payload) + require.NoError(t, err) + services := plan.Services() + require.Len(t, services.Get("Values").Methods, 1) + require.Same(t, declaration, services.Get("Values").Methods[0].PayloadDeclaration) +} + +// TestServicePlanSharesNestedValidatorDeclaration verifies that a projected +// parent call and the child function definition retain one package declaration +// even when another projected type collides with the child's preferred +// validator name. +func TestServicePlanSharesNestedValidatorDeclaration(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("collision", collision) + dsl.Required("child") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := mustServicePlan(t, root) + data := plan.Services().Get("Values") + var child, parent *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + child = validation + case "ParentView": + parent = validation + } + } + } + require.NotNil(t, child) + require.NotNil(t, parent) + require.Len(t, parent.Calls, 1) + require.Same(t, child.Declaration, parent.Calls[0].Declaration) + require.Equal(t, child.Declaration.Name(), parent.Calls[0].Declaration.Name()) +} diff --git a/codegen/service/security_data.go b/codegen/service/security_data.go new file mode 100644 index 0000000000..06e584c8fb --- /dev/null +++ b/codegen/service/security_data.go @@ -0,0 +1,78 @@ +// This file formats evaluated security schemes and authorization attributes for service templates. +package service + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// BuildSchemeData builds the scheme data for the given scheme and method expr. +func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { + if !expr.IsObject(m.Payload.Type) { + return nil + } + if s.Kind == expr.BasicAuthKind { + userAtt := expr.TaggedAttribute(m.Payload, "security:username") + passAtt := expr.TaggedAttribute(m.Payload, "security:password") + return &SchemeData{ + Type: s.Kind.String(), + SchemeName: s.SchemeName, + UsernameAttr: userAtt, + UsernameField: codegen.Goify(userAtt, true), + UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), + UsernameRequired: m.Payload.IsRequired(userAtt), + PasswordAttr: passAtt, + PasswordField: codegen.Goify(passAtt, true), + PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), + PasswordRequired: m.Payload.IsRequired(passAtt), + Scopes: schemeScopes(s), + } + } + // The remaining scheme kinds all carry a single credential attribute + // identified by a kind-specific security tag on the method payload. + var tag string + switch s.Kind { + case expr.APIKeyKind: + tag = "security:apikey:" + s.SchemeName + case expr.BearerKind: + tag = "security:bearer" + case expr.JWTKind: + tag = "security:token" + case expr.OAuth2Kind: + tag = "security:accesstoken" + default: + return nil + } + keyAtt := expr.TaggedAttribute(m.Payload, tag) + if keyAtt == "" { + return nil + } + data := &SchemeData{ + Type: s.Kind.String(), + Name: s.Name, + SchemeName: s.SchemeName, + CredField: codegen.Goify(keyAtt, true), + CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), + CredRequired: m.Payload.IsRequired(keyAtt), + KeyAttr: keyAtt, + Scopes: schemeScopes(s), + In: s.In, + } + if s.Kind == expr.OAuth2Kind { + data.Flows = s.Flows + } + return data +} + +// schemeScopes returns the scope names defined by the scheme, nil when the +// scheme defines none. +func schemeScopes(s *expr.SchemeExpr) []string { + if len(s.Scopes) == 0 { + return nil + } + scopes := make([]string, len(s.Scopes)) + for i, sc := range s.Scopes { + scopes[i] = sc.Name + } + return scopes +} diff --git a/codegen/service/security_test.go b/codegen/service/security_test.go index 649c286328..96921351cc 100644 --- a/codegen/service/security_test.go +++ b/codegen/service/security_test.go @@ -24,9 +24,9 @@ func TestSecureEndpointInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates require.Greater(t, len(sections), 1) @@ -51,9 +51,9 @@ func TestSecureEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[4]) @@ -73,9 +73,9 @@ func TestSecureWithSkipRequestBodyEncodeDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[5]) diff --git a/codegen/service/service.go b/codegen/service/service.go index 2e2a626ad4..e44d1b4f2d 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -14,36 +14,112 @@ import ( "goa.design/goa/v3/expr" ) -// Files returns every service-local file from analyses and emits each relocated -// user type and union once across the complete generation. -func Files(genpkg string, analyses []*ServicesData) []*codegen.File { +type ( + // serviceTypeSectionPhase identifies the stable group that owns a service + // type-file section. Type declarations must precede methods defined on them. + serviceTypeSectionPhase uint8 + + // serviceTypeSection retains the explicit ordering facts for one section in + // service.go instead of encoding its group in a decorated string key. + serviceTypeSection struct { + phase serviceTypeSectionPhase + name string + section *codegen.SectionTemplate + } +) + +const ( + serviceTypeDefinitionPhase serviceTypeSectionPhase = iota + serviceErrorImplementationPhase +) + +// Files renders every service file described by plans. Each plan must be +// linked so every renderer reads the exact declarations retained before names +// froze instead of rebuilding service analysis from the expression root. +func Files(plans ...*Plan) ([]*codegen.File, error) { var files []*codegen.File - for _, services := range analyses { - for _, service := range services.Root.Services { - files = append(files, serviceFiles(genpkg, service, services)...) + if len(plans) == 0 { + return files, nil + } + generation := plans[0].generation + ownedRoots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + ownedRoots[root] = struct{}{} } } - return append(files, generatedPackageFiles(genpkg, analyses)...) + for _, plan := range plans[1:] { + if plan.generation != generation { + return nil, fmt.Errorf("service plans belong to different generations") + } + } + if len(plans) != len(ownedRoots) { + return nil, fmt.Errorf("service rendering requires all %d planned roots, got %d", len(ownedRoots), len(plans)) + } + seenRoots := make(map[*expr.RootExpr]struct{}, len(plans)) + for _, plan := range plans { + if _, owned := ownedRoots[plan.facts.root]; !owned { + return nil, rootMembershipError(plan.facts.root) + } + if _, exists := seenRoots[plan.facts.root]; exists { + return nil, fmt.Errorf("service root %p is rendered more than once", plan.facts.root) + } + seenRoots[plan.facts.root] = struct{}{} + } + analyses := make([]*ServicesData, len(plans)) + for index, plan := range plans { + analyses[index] = plan.Services() + for _, facts := range plan.facts.services { + files = append(files, serviceFiles(plan, facts)...) + } + } + generatedFiles, err := generatedPackageFiles(analyses) + if err != nil { + return nil, err + } + files = append(files, generatedFiles...) + for _, plan := range plans { + for _, facts := range plan.facts.services { + files = append(files, + endpointFile(plan, facts), + clientFile(plan, facts), + ) + if file := viewsFile(plan, facts); file != nil { + files = append(files, file) + } + } + } + conversionFiles, err := externalConversionFiles(plans) + if err != nil { + return nil, err + } + files = append(files, convertFiles(conversionFiles)...) + return files, nil } // serviceFiles renders the declarations and helpers owned exclusively by one // service package. Relocated declarations and all union definitions are // emitted later by generatedPackageFiles. -func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { - svc := services.Get(service.Name) +func serviceFiles(plan *Plan, facts *serviceFacts) []*codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName svcPath := filepath.Join(codegen.Gendir, svcName, "service.go") seen := make(map[string]struct{}) - typeDefSections := make(map[string]*codegen.SectionTemplate) + typeSections := make([]serviceTypeSection, 0) svcSections := make([]*codegen.SectionTemplate, 0, 10) addTypeDefSection := func(name string, section *codegen.SectionTemplate) { - typeDefSections[name] = section + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceTypeDefinitionPhase, + name: name, + section: section, + }) seen[name] = struct{}{} } for i, m := range svc.Methods { - method := service.Methods[i] + method := facts.orderedMethods[i] if m.PayloadLoc == nil && m.PayloadDef != "" { if _, ok := seen[m.Payload]; !ok { addTypeDefSection(m.Payload, &codegen.SectionTemplate{ @@ -53,7 +129,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa }) } } - if method.StreamingPayload != nil && codegen.UserTypeLocation(method.StreamingPayload.Type) == nil && m.StreamingPayloadDef != "" { + if method.streamingPayload != nil && method.streamingPayload.location == nil && m.StreamingPayloadDef != "" { if _, ok := seen[m.StreamingPayload]; !ok { addTypeDefSection(m.StreamingPayload, &codegen.SectionTemplate{ Name: "service-streaming-payload", @@ -72,7 +148,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa } } // Generate streaming result type if different from result - if method.StreamingResult != nil && codegen.UserTypeLocation(method.StreamingResult.Type) == nil && m.StreamingResultDef != "" && m.StreamingResult != m.Result { + if method.streamingResult != nil && method.streamingResult.location == nil && m.StreamingResultDef != "" && m.StreamingResult != m.Result { if _, ok := seen[m.StreamingResult]; !ok { addTypeDefSection(m.StreamingResult, &codegen.SectionTemplate{ Name: "service-streaming-result", @@ -100,7 +176,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa seenErrs := make(map[string]struct{}) for _, et := range svc.errorTypes { - if et.Type == expr.ErrorResult || et.Loc != nil { + if et.IsServiceError || et.Loc != nil { continue } if _, ok := seenErrs[et.Name]; !ok { @@ -112,12 +188,15 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa Data: et, }) } - typeDefSections["|"+et.Name] = &codegen.SectionTemplate{ - Name: "service-error", - Source: serviceTemplates.Read(errorT), - FuncMap: map[string]any{"errorName": errorName}, - Data: et, - } + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceErrorImplementationPhase, + name: et.Name, + section: &codegen.SectionTemplate{ + Name: "service-error", + Source: serviceTemplates.Read(errorT), + Data: et, + }, + }) } } for _, er := range svc.errorInits { @@ -162,27 +241,7 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa }) } - outputPackage := genpkg + "/" + svcName - attributes := serviceReferenceAttributes(service) - attributes = append(attributes, emittedMethodDefinitions(service, svc)...) - for _, userType := range svc.userTypes { - if userType.Loc == nil { - attributes = append(attributes, userType.Type.Attribute()) - } - } - for _, errorType := range svc.errorTypes { - if errorType.Loc == nil { - attributes = append(attributes, errorType.Type.Attribute()) - } - } - imports := services.fileImports(outputPackage, []string{ - "context", - "io", - codegen.GoaImport("").Path, - codegen.GoaImport("security").Path, - outputPackage + "/views", - }, attributes...) - header := codegen.Header(service.Name+" service", svc.PkgName, imports) + header := codegen.Header(facts.name+" service", svc.PkgName, facts.imports.service.specs) def := &codegen.SectionTemplate{ Name: "service", Source: serviceTemplates.Read(serviceT), @@ -195,61 +254,32 @@ func serviceFiles(genpkg string, service *expr.ServiceExpr, services *ServicesDa }, } - names := make([]string, 0, len(typeDefSections)) - for name := range typeDefSections { - names = append(names, name) - } - sort.Strings(names) - sections := make([]*codegen.SectionTemplate, 0, 2+len(names)+len(svcSections)) + sort.Slice(typeSections, func(i, j int) bool { + if typeSections[i].phase != typeSections[j].phase { + return typeSections[i].phase < typeSections[j].phase + } + return typeSections[i].name < typeSections[j].name + }) + sections := make([]*codegen.SectionTemplate, 0, 2+len(typeSections)+len(svcSections)) sections = append(sections, header, def) - for _, name := range names { - sections = append(sections, typeDefSections[name]) + for _, record := range typeSections { + sections = append(sections, record.section) } sections = append(sections, svcSections...) files := []*codegen.File{{Path: svcPath, SectionTemplates: sections}} - return append(files, InterceptorsFiles(genpkg, service, services)...) -} - -// emittedMethodDefinitions returns the underlying method type definitions -// written to service.go so their nested references contribute imports. -func emittedMethodDefinitions(service *expr.ServiceExpr, data *Data) []*expr.AttributeExpr { - var definitions []*expr.AttributeExpr - for index, method := range service.Methods { - methodData := data.Methods[index] - if methodData.PayloadLoc == nil && methodData.PayloadDef != "" { - definitions = appendUserTypeDefinition(definitions, method.Payload) - } - if method.StreamingPayload != nil && codegen.UserTypeLocation(method.StreamingPayload.Type) == nil && methodData.StreamingPayloadDef != "" { - definitions = appendUserTypeDefinition(definitions, method.StreamingPayload) - } - if methodData.ResultLoc == nil && methodData.ResultDef != "" { - definitions = appendUserTypeDefinition(definitions, method.Result) - } - if method.HasMixedResults() && codegen.UserTypeLocation(method.StreamingResult.Type) == nil && methodData.StreamingResultDef != "" { - definitions = appendUserTypeDefinition(definitions, method.StreamingResult) - } - } - return definitions -} - -// appendUserTypeDefinition appends the definition rendered for a named method -// attribute. -func appendUserTypeDefinition(definitions []*expr.AttributeExpr, attribute *expr.AttributeExpr) []*expr.AttributeExpr { - userType, ok := attribute.Type.(expr.UserType) - if !ok { - return definitions - } - return append(definitions, userType.Attribute()) + return append(files, interceptorsFiles(plan, facts)...) } // generatedPackageFiles renders each relocated user type in its configured // file and one sorted unions.go for every package that owns unions. -func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.File { - packages := aggregateGeneratedPackages(analyses) +func generatedPackageFiles(analyses []*ServicesData) ([]*codegen.File, error) { + packages, err := aggregateGeneratedPackages(analyses) + if err != nil { + return nil, err + } if len(packages) == 0 { - return nil + return nil, nil } - aliases := analyses[0].aliases packageOwners := make([]*codegen.GeneratedPackage, 0, len(packages)) for owner := range packages { packageOwners = append(packageOwners, owner) @@ -278,11 +308,10 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F sort.Slice(generatedTypes, func(i, j int) bool { return generatedTypes[i].declaration.Name() < generatedTypes[j].declaration.Name() }) - collector := newImportCollector(aliases, genpkg, packagePath) + var imports []*codegen.ImportSpec for _, generatedType := range generatedTypes { - collector.collectDefinition(generatedType.userType.Attribute()) + imports = appendImportSpecs(imports, generatedType.imports) } - imports := collector.imports() sections := []*codegen.SectionTemplate{ codegen.Header("User types", packageName, imports), } @@ -303,19 +332,8 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F sort.Slice(unions, func(i, j int) bool { return unions[i].Name < unions[j].Name }) - collector := newImportCollector(aliases, genpkg, packagePath) - for _, importPath := range []string{"bytes", "encoding/json", "fmt", codegen.GoaImport("").Path} { - collector.addPath(importPath) - } - for _, union := range unions { - for _, field := range union.Fields { - collector.collectDefinition(field.reference) - collector.collectDefinition(field.definition) - } - } - imports := collector.imports() sections := []*codegen.SectionTemplate{ - codegen.Header("Union types", packageName, imports), + codegen.Header("Union types", packageName, generatedPackage.unionImports), } for _, union := range unions { sections = append(sections, &codegen.SectionTemplate{ @@ -330,12 +348,12 @@ func generatedPackageFiles(genpkg string, analyses []*ServicesData) []*codegen.F }) } } - return files + return files, nil } // aggregateGeneratedPackages selects one render section per canonical package // declaration across all analyzed roots without mutating generation state. -func aggregateGeneratedPackages(analyses []*ServicesData) map[*codegen.GeneratedPackage]*generatedPackageData { +func aggregateGeneratedPackages(analyses []*ServicesData) (map[*codegen.GeneratedPackage]*generatedPackageData, error) { packages := make(map[*codegen.GeneratedPackage]*generatedPackageData) for _, services := range analyses { for owner, analyzedPackage := range services.packages { @@ -343,23 +361,54 @@ func aggregateGeneratedPackages(analyses []*ServicesData) map[*codegen.Generated if !ok { generatedPackage = &generatedPackageData{ types: make(map[*codegen.TypeDeclaration]*generatedTypeData), - unions: make(map[codegen.UnionTypeID]*UnionTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), } packages[owner] = generatedPackage } for declaration, generatedType := range analyzedPackage.types { - if _, exists := generatedPackage.types[declaration]; !exists { - generatedPackage.types[declaration] = generatedType + if _, exists := generatedPackage.types[declaration]; exists { + return nil, fmt.Errorf( + "generated type declaration %q was assigned to more than one service plan", + declaration.Name(), + ) } + generatedPackage.types[declaration] = generatedType } - for identity, union := range analyzedPackage.unions { - if _, exists := generatedPackage.unions[identity]; !exists { - generatedPackage.unions[identity] = union + for declaration, union := range analyzedPackage.unions { + if _, exists := generatedPackage.unions[declaration]; exists { + return nil, fmt.Errorf( + "generated union declaration %q was assigned to more than one service plan", + union.Name, + ) } + generatedPackage.unions[declaration] = union } + generatedPackage.unionImports = appendImportSpecs(generatedPackage.unionImports, analyzedPackage.unionImports) } } - return packages + return packages, nil +} + +// appendImportSpecs merges exact file contributions by complete package path +// and returns them in deterministic path order. +func appendImportSpecs(existing, added []*codegen.ImportSpec) []*codegen.ImportSpec { + byPath := make(map[string]*codegen.ImportSpec, len(existing)+len(added)) + for _, spec := range existing { + byPath[spec.Path] = spec + } + for _, spec := range added { + byPath[spec.Path] = spec + } + paths := make([]string, 0, len(byPath)) + for importPath := range byPath { + paths = append(paths, importPath) + } + sort.Strings(paths) + result := make([]*codegen.ImportSpec, len(paths)) + for index, importPath := range paths { + result[index] = byPath[importPath] + } + return result } // dedupeByResult returns a slice of methods where only a single representative @@ -385,24 +434,6 @@ func dedupeByResult(ms []*MethodData) []*MethodData { return out } -func errorName(et *UserTypeData) string { - obj := expr.AsObject(et.Type) - if obj != nil { - for _, att := range *obj { - if _, ok := att.Attribute.Meta["struct:error:name"]; ok { - return fmt.Sprintf("e.%s", codegen.GoifyAtt(att.Attribute, att.Name, true)) - } - } - } - // if error type is a custom user type and used by at most one error, then - // error Finalize should have added "struct:error:name" to the user type - // attribute's meta. - if v, ok := et.Type.Attribute().Meta["struct:error:name"]; ok { - return fmt.Sprintf("%q", v[0]) - } - return fmt.Sprintf("%q", et.Name) -} - // hasJSONRPCStreaming returns true if the service has a JSON-RPC streaming // endpoint (WebSocket or SSE). func hasJSONRPCStreaming(sd *Data) bool { diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index 57ad005285..edb24013fb 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -4,11 +4,7 @@ package service import ( - "bytes" "fmt" - "slices" - "sort" - "strings" "text/template" "goa.design/goa/v3/codegen" @@ -43,12 +39,45 @@ type ( examples *expr.ExampleGenerator aliases *importAliases packages map[*codegen.GeneratedPackage]*generatedPackageData - rootTypes *rootTypeSet + facts *rootFacts } // Data contains the data used to render the code related to a single // service. Data struct { + // ServiceDeclaration is the exact package-level service interface record. + ServiceDeclaration *codegen.NameDeclaration + // AutherDeclaration is the exact package-level authorization interface + // record. It is nil when the service has no security schemes. + AutherDeclaration *codegen.NameDeclaration + // APINameDeclaration is the exact package-level API name constant record. + APINameDeclaration *codegen.NameDeclaration + // APIVersionDeclaration is the exact package-level API version constant record. + APIVersionDeclaration *codegen.NameDeclaration + // ServiceNameDeclaration is the exact package-level service name constant record. + ServiceNameDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration is the exact package-level method names variable record. + MethodNamesDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact package-level endpoint collection record. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor record. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client record. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor record. + NewClientDeclaration *codegen.NameDeclaration + // StreamDeclaration is the shared JSON-RPC stream record when emitted. + StreamDeclaration *codegen.NameDeclaration + // EventDeclaration is the shared JSON-RPC SSE event record when emitted. + EventDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the server interceptor interface record. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the client interceptor interface record. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ExampleStructDeclaration is the starter implementation struct record. + ExampleStructDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the starter constructor record. + ExampleConstructorDeclaration *codegen.NameDeclaration // Name is the service name. Name string // Description is the service description. @@ -66,9 +95,6 @@ type ( // PkgName is the name of the package containing the generated service // code. PkgName string - // ViewsPkg is the name of the package containing the projected and viewed - // result types. - ViewsPkg string // Methods lists the service interface methods. Methods []*MethodData // Schemes is the list of security schemes required by the service methods. @@ -98,6 +124,8 @@ type ( projectedTypes []*ProjectedTypeData // unions lists the sum-type unions defined for the service. unions []*UnionTypeData + // viewUnions lists the sum-type unions emitted by the views package. + viewUnions []*UnionTypeData // viewedResultTypes lists all the viewed method result types. viewedResultTypes []*ViewedResultTypeData // viewDerived binds the independently rebuilt view graph to declarations @@ -107,6 +135,24 @@ type ( // MethodData describes a single service method. MethodData struct { + // EndpointDeclaration is the exact package-level endpoint constructor. + EndpointDeclaration *codegen.NameDeclaration + // EndpointInputDeclaration is the exact streaming endpoint input record. + EndpointInputDeclaration *codegen.NameDeclaration + // ServerStreamDeclaration is the exact server stream interface record. + ServerStreamDeclaration *codegen.NameDeclaration + // ClientStreamDeclaration is the exact client stream interface record. + ClientStreamDeclaration *codegen.NameDeclaration + // EventDeclaration is the exact JSON-RPC SSE event record. + EventDeclaration *codegen.NameDeclaration + // RequestDeclaration is the exact JSON-RPC request data record. + RequestDeclaration *codegen.NameDeclaration + // ResponseDeclaration is the exact JSON-RPC response data record. + ResponseDeclaration *codegen.NameDeclaration + // ServerEndpointWrapperDeclaration is the exact server endpoint wrapper. + ServerEndpointWrapperDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact client endpoint wrapper. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration // Name is the method name. Name string // Description is the method description. @@ -248,8 +294,8 @@ type ( StreamData struct { // Interface is the name of the stream interface. Interface string - // VarName is the name of the struct type that implements the stream - // interface. + // VarName is the lexical implementation type name retained during service + // planning for transport generators. VarName string // SendName is the name of the send function. SendName string @@ -296,8 +342,9 @@ type ( // ErrorInitData describes an error returned by a service method of type // ErrorResult. ErrorInitData struct { - // Name is the name of the init function. - Name string + // Declaration is the exact package-level constructor record retained while + // the service was planned. + Declaration *codegen.NameDeclaration // Description is the error description. Description string // ErrName is the name of the error. @@ -317,6 +364,16 @@ type ( // InterceptorData contains the data required to render the service-level // interceptor code. interceptors.go.tpl InterceptorData struct { + // InfoDeclaration is the exact interceptor metadata record. + InfoDeclaration *codegen.NameDeclaration + // PayloadDeclaration is the exact payload accessor interface when emitted. + PayloadDeclaration *codegen.NameDeclaration + // ResultDeclaration is the exact result accessor interface when emitted. + ResultDeclaration *codegen.NameDeclaration + // StreamingPayloadDeclaration is the exact streaming payload accessor interface when emitted. + StreamingPayloadDeclaration *codegen.NameDeclaration + // StreamingResultDeclaration is the exact streaming result accessor interface when emitted. + StreamingResultDeclaration *codegen.NameDeclaration // Name is the name of the interceptor used in the generated code. Name string // DesignName is the name of the interceptor as defined in the design. @@ -361,6 +418,18 @@ type ( // MethodInterceptorData contains the data required to render the // method-level interceptor code. MethodInterceptorData struct { + // PayloadAccessDeclaration is the exact private payload accessor struct. + PayloadAccessDeclaration *codegen.NameDeclaration + // ResultAccessDeclaration is the exact private result accessor struct. + ResultAccessDeclaration *codegen.NameDeclaration + // StreamingPayloadAccessDeclaration is the exact private streaming payload accessor struct. + StreamingPayloadAccessDeclaration *codegen.NameDeclaration + // StreamingResultAccessDeclaration is the exact private streaming result accessor struct. + StreamingResultAccessDeclaration *codegen.NameDeclaration + // ServerWrapperDeclaration is the exact server interceptor wrapper function. + ServerWrapperDeclaration *codegen.NameDeclaration + // ClientWrapperDeclaration is the exact client interceptor wrapper function. + ClientWrapperDeclaration *codegen.NameDeclaration // MethodName is the name of the method. MethodName string // PayloadAccess is the name of the payload access struct. @@ -387,6 +456,10 @@ type ( // StreamInterceptorData is the stream data for an interceptor. StreamInterceptorData struct { + // InterfaceDeclaration is the exact stream interface wrapped by this record. + InterfaceDeclaration *codegen.NameDeclaration + // WrapperDeclaration is the exact private interceptor stream wrapper struct. + WrapperDeclaration *codegen.NameDeclaration // Interface is the name of the stream interface. Interface string // SendName is the name of the send function. @@ -445,6 +518,10 @@ type ( VarName string // Description is the type human description. Description string + // ErrorName is the retained Go expression returned by GoaErrorName. + ErrorName string + // IsServiceError reports whether this is Goa's built-in service error. + IsServiceError bool // Def is the type definition Go code. Def string // Ref is the reference to the type. @@ -598,6 +675,12 @@ type ( Attributes []string // TypeVarName is the Go variable name of the type that defines the view. TypeVarName string + // MapDeclaration is the exact package-level view map record for this type. + MapDeclaration *codegen.NameDeclaration + // ToProjected is the exact private constructor that applies this view. + ToProjected *codegen.NameDeclaration + // ToResult is the exact private constructor that removes this view. + ToResult *codegen.NameDeclaration } // ProjectedTypeData contains the data used to generate a projected type for @@ -621,8 +704,6 @@ type ( // corresponding service type. If the projected type corresponds to a // result type, then a function for each view is generated. TypeInits []*InitData - // ViewsPkg is the views package name. - ViewsPkg string // Views lists the views defined on the projected type. Views []*ViewData } @@ -630,8 +711,9 @@ type ( // InitData contains the data to render a constructor to initialize service // types from viewed result types and vice versa. InitData struct { - // Name is the name of the constructor function. - Name string + // Declaration is the exact package-level constructor record retained while + // the service was planned. + Declaration *codegen.NameDeclaration // Description is the function description. Description string // Args lists arguments to this function. @@ -655,8 +737,9 @@ type ( // ValidateData contains data to render a validate function to validate a // projected type or a viewed result type based on views. ValidateData struct { - // Name is the validation function name. - Name string + // Declaration is the exact package-level function record retained while + // the service was planned. + Declaration *codegen.NameDeclaration // Ref is the reference to the type on which the validation function // is defined. Ref string @@ -664,6 +747,34 @@ type ( Description string // Validate is the validation code. Validate string + // Calls lists nested validator functions called by Validate. + Calls []*ValidationCallData + } + + // ValidationCallData binds one nested validation call to the exact function + // declaration that owns the rendered name. + ValidationCallData struct { + // Declaration is the exact package-level validator function record. + Declaration *codegen.NameDeclaration + // View is the selected result-type view. + View string + // Default reports whether View is the default result-type view. + Default bool + } + + // validationFieldData describes a nested result field validated by a + // projected parent validator. + validationFieldData struct { + Name string + Call *ValidationCallData + IsRequired bool + } + + // constructorFieldData binds one nested result field to the exact retained + // private constructor called by its parent conversion. + constructorFieldData struct { + VarName string + Declaration *codegen.NameDeclaration } // unionDataKey identifies one emitted union definition in one generated Go @@ -688,35 +799,30 @@ type ( sourceAttribute *expr.AttributeExpr projectedAttribute *expr.AttributeExpr } - - // unionBranchLookup resolves one branch's complete frozen declaration family. - unionBranchLookup func(*expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) ) -// NewServicesData analyzes root using declarations frozen by generation. -// Call Plan for every participating root and freeze generation first. -func NewServicesData(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*ServicesData, error) { - aliases, err := newImportAliases(root, generation) - if err != nil { - return nil, err - } +// linkServicesData resolves the exact service facts retained before generation +// freeze into immutable render data. +func linkServicesData(facts *rootFacts, generation *codegen.Generation, aliases *importAliases) (*ServicesData, error) { + root := facts.root data := &ServicesData{ Root: root, Services: make(map[string]*Data), generation: generation, - examples: examples, + examples: facts.examples, aliases: aliases, packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), - rootTypes: newRootTypeSet(root), + facts: facts, } - for _, service := range root.Services { - generation.Package(servicePackagePath(generation.GenPkg(), service)).Scope() + for _, service := range facts.services { analyzed, err := data.analyze(service) if err != nil { return nil, err } - data.Services[service.Name] = analyzed + service.data = analyzed + data.Services[service.name] = analyzed } + data.registerPackageData() return data, nil } @@ -750,22 +856,22 @@ func (d *ServicesData) GenPkg() string { // ServiceImport returns the frozen import alias for name's generated service // package. The returned value is a copy that callers may add to one file. func (d *ServicesData) ServiceImport(name string) *codegen.ImportSpec { - service := d.Root.Service(name) - if service == nil { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - spec := d.aliases.spec(servicePackagePath(d.generation.GenPkg(), service)) + spec := d.aliases.spec(serviceFacts.packagePath) return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} } // ViewImport returns the frozen import alias for name's generated views // package. The returned value is a copy that callers may add to one file. func (d *ServicesData) ViewImport(name string) *codegen.ImportSpec { - service := d.Root.Service(name) - if service == nil { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - spec := d.aliases.spec(servicePackagePath(d.generation.GenPkg(), service) + "/views") + spec := d.aliases.spec(serviceFacts.viewsPath) return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} } @@ -781,22 +887,25 @@ func (d *ServicesData) PackageImport(importPath string) *codegen.ImportSpec { // generated package locations and uses the same import aliases as service // rendering. func (d *ServicesData) ServiceAttributor(name, outputPackage string) codegen.Attributor { - service := d.Root.Service(name) - if service == nil { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - return newServiceResolver(d.generation, d.aliases, service, outputPackage) + return newServiceResolver(d.generation, d.aliases, serviceFacts.service, outputPackage). + withValidators(serviceFacts.validators) } // ViewAttributor returns the frozen projected and viewed result declaration // resolver for name as referenced from outputPackage. func (d *ServicesData) ViewAttributor(name, outputPackage string) codegen.Attributor { - service := d.Root.Service(name) - if service == nil { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } data := d.Services[name] - return newViewResolver(d.generation, d.aliases, service, data.viewDerived).withOutputPackage(outputPackage) + return newViewResolver(d.generation, d.aliases, serviceFacts.service, data.viewDerived). + withValidators(serviceFacts.validators). + withOutputPackage(outputPackage) } // Method returns the service method data for the method with the given name, @@ -875,1806 +984,3 @@ func (s SchemesData) DedupeByType() SchemesData { return uniqueSchemes } - -// analyze creates the data necessary to render the code of the given service. -// It records the user types needed by the service definition in userTypes. -func (d *ServicesData) analyze(service *expr.ServiceExpr) (*Data, error) { - var ( - types []*UserTypeData - errTypes []*UserTypeData - errorInits []*ErrorInitData - projTypes []*ProjectedTypeData - viewedRTs []*ViewedResultTypeData - ) - servicePackage := d.generation.Package(servicePackagePath(d.generation.GenPkg(), service)) - scope := servicePackage.Scope().Fork() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - viewScope := d.generation.Package( - servicePackagePath(d.generation.GenPkg(), service) + "/views", - ).Scope().Fork() - pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") - viewspkg := pkgName + "views" - seenTypes := make(map[userTypeDataKey]struct{}) - seenErrors := make(map[string]struct{}) - seenProjected := make(map[expr.UserType]expr.UserType) - seenProj := make(map[expr.UserType]*ProjectedTypeData) - seenViewed := make(map[string]*ViewedResultTypeData) - viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) - serviceResolver := newServiceResolver( - d.generation, - d.aliases, - service, - servicePackagePath(d.generation.GenPkg(), service), - ) - - // A function to collect user types from an error expression - recordError := func(er *expr.ErrorExpr) error { - collected, err := d.collectTypes(er.AttributeExpr, service, serviceResolver, seenTypes, nil) - if err != nil { - return err - } - errTypes = append(errTypes, collected...) - if er.Type == expr.ErrorResult { - if _, ok := seenErrors[er.Name]; ok { - return nil - } - seenErrors[er.Name] = struct{}{} - errorInits = append(errorInits, buildErrorInitData(er, serviceResolver)) - } - return nil - } - for _, er := range service.Errors { - if err := recordError(er); err != nil { - return nil, err - } - } - - // A function to collect inner user types from an attribute expression - collectUserTypes := func(att *expr.AttributeExpr) error { - if att == nil { - return nil - } - var loc *codegen.Location - resolver := serviceResolver - if ut, ok := att.Type.(expr.UserType); ok { - loc = codegen.UserTypeLocation(ut) - resolver = serviceResolver.Enter(att).(*declarationResolver) - att = ut.Attribute() - } - collected, err := d.collectTypes(att, service, resolver, seenTypes, loc) - if err != nil { - return err - } - types = append(types, collected...) - return nil - } - for _, m := range service.Methods { - // collect inner user types - if err := collectUserTypes(m.Payload); err != nil { - return nil, err - } - if err := collectUserTypes(m.StreamingPayload); err != nil { - return nil, err - } - if err := collectUserTypes(m.Result); err != nil { - return nil, err - } - // Collect streaming result types if different from Result - if m.HasMixedResults() { - if err := collectUserTypes(m.StreamingResult); err != nil { - return nil, err - } - } - // Collect projected types - if hasResultType(m.Result) { - projected, result := projectedResultRoot(d.generation, m) - pairs := projectTypePairs(projected, result, seenProjected) - removeMeta(projected) - views := d.generation.Package(servicePackagePath(d.generation.GenPkg(), service) + "/views") - for _, pair := range pairs { - identity := codegen.NewProjectedTypeID(pair.source) - viewDerived[pair.projected.Origin()] = identity - } - viewResolver := newViewResolver(d.generation, d.aliases, service, viewDerived) - for _, pair := range pairs { - identity := codegen.NewProjectedTypeID(pair.source) - declaration, err := views.DerivedType(identity) - if err != nil { - return nil, err - } - projectedType := buildProjectedType( - pair.projectedAttribute, - pair.sourceAttribute, - viewspkg, - serviceResolver, - viewResolver, - declaration, - ) - seenProj[pair.source.Origin()] = projectedType - projTypes = append(projTypes, projectedType) - } - } - for _, er := range m.Errors { - if err := recordError(er); err != nil { - return nil, err - } - } - } - - // A function to record method user types so that forced types are not - // collected twice. Raw object method types are wrapped into synthesized - // user types when codegen.NewGeneration takes ownership of the evaluated - // roots: analyze reads the design and never mutates it, so a raw object here - // means the caller skipped generation construction. - recordMethodType := func(m *expr.MethodExpr, att *expr.AttributeExpr) { - if att == nil || att.Type == expr.Empty { - return - } - if _, ok := att.Type.(*expr.Object); ok { - panic(fmt.Sprintf( - "service %q method %q declares a raw object type: codegen.NewGeneration must own the finalized design before generators read it", - service.Name, m.Name)) // bug - } - if ut, ok := att.Type.(expr.UserType); ok { - declaration := serviceResolver.Enter(att).(*declarationResolver).userType( - serviceResolver.owner(att), - ut, - ) - seenTypes[userTypeDataKey{origin: ut.Origin(), declaration: declaration}] = struct{}{} - } - } - - for _, m := range service.Methods { - recordMethodType(m, m.Payload) - recordMethodType(m, m.StreamingPayload) - recordMethodType(m, m.Result) - if m.HasMixedResults() { - recordMethodType(m, m.StreamingResult) - } - } - - // Add forced types - for _, t := range d.Root.Types { - svcs, ok := t.Attribute().Meta["type:generate:force"] - if !ok { - continue - } - att := &expr.AttributeExpr{Type: t} - if len(svcs) > 0 { - // Force generate type only in the specified services - if slices.Contains(svcs, service.Name) { - collected, err := d.collectTypes(att, service, serviceResolver, seenTypes, nil) - if err != nil { - return nil, err - } - types = append(types, collected...) - } - continue - } - // Force generate type in all the services - collected, err := d.collectTypes(att, service, serviceResolver, seenTypes, nil) - if err != nil { - return nil, err - } - types = append(types, collected...) - } - - var ( - methods []*MethodData - schemes SchemesData - ) - methods = make([]*MethodData, len(service.Methods)) - for i, e := range service.Methods { - m, err := d.buildMethodData(e, scope, serviceResolver) - if err != nil { - return nil, err - } - methods[i] = m - for _, s := range m.Schemes { - schemes = schemes.Append(s) - } - rt, ok := e.Result.Type.(*expr.ResultTypeExpr) - if !ok { - continue - } - var view string - if v, ok := e.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v - } - if vrt, ok := seenViewed[m.Result+"::"+view]; ok { - m.ViewedResult = vrt - continue - } - projected := seenProj[rt.Origin()] - projAtt := &expr.AttributeExpr{Type: projected.Type} - viewedDeclaration, err := d.generation.Package( - servicePackagePath(d.generation.GenPkg(), service) + "/views", - ).DerivedType(codegen.NewViewedResultTypeID(rt)) - if err != nil { - return nil, err - } - vrt := buildViewedResultType( - e.Result, - projAtt, - viewspkg, - serviceResolver, - newViewResolver(d.generation, d.aliases, service, viewDerived), - viewedDeclaration, - ) - found := false - for _, rt := range viewedRTs { - if rt.Type.ID() == vrt.Type.ID() { - found = true - break - } - } - if !found { - viewedRTs = append(viewedRTs, vrt) - } - m.ViewedResult = vrt - seenViewed[vrt.Name+"::"+view] = vrt - } - - // Compute unique EndpointField names using the service-level scope, after - // method names are set. This records field identifiers without changing - // existing method names. - for _, m := range methods { - m.EndpointField = scope.Unique(m.VarName+"Endpoint", "") - if m.HasMixedResults { - m.StreamEndpointField = scope.Unique(m.VarName+"StreamEndpoint", "") - } - } - - // Collect union sum-type definitions for the service. - unionByPackage := make(map[unionDataKey]*UnionTypeData) - seen := make(map[expr.UserType]struct{}) - collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) error { - return d.collectUnionTypes(att, service, serviceResolver, loc, unionByPackage, seen, false) - } - for _, t := range types { - if err := collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc); err != nil { - return nil, err - } - } - for _, t := range errTypes { - if err := collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc); err != nil { - return nil, err - } - } - for _, m := range service.Methods { - if m.Payload != nil { - if err := collectUnions(m.Payload, codegen.UserTypeLocation(m.Payload.Type)); err != nil { - return nil, err - } - } - if m.StreamingPayload != nil { - if err := collectUnions(m.StreamingPayload, codegen.UserTypeLocation(m.StreamingPayload.Type)); err != nil { - return nil, err - } - } - if m.Result != nil { - if err := collectUnions(m.Result, codegen.UserTypeLocation(m.Result.Type)); err != nil { - return nil, err - } - } - for _, e := range m.Errors { - if err := collectUnions(e.AttributeExpr, codegen.UserTypeLocation(e.Type)); err != nil { - return nil, err - } - } - } - unions := make([]*UnionTypeData, 0, len(unionByPackage)) - for _, u := range unionByPackage { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - if unions[i].Name != unions[j].Name { - return unions[i].Name < unions[j].Name - } - var left, right string - if unions[i].Loc != nil { - left = unions[i].Loc.RelImportPath - } - if unions[j].Loc != nil { - right = unions[j].Loc.RelImportPath - } - return left < right - }) - - desc := service.Description - if desc == "" { - desc = fmt.Sprintf("Service is the %s service interface.", service.Name) - } - - varName := codegen.Goify(service.Name, false) - data := &Data{ - Name: service.Name, - Description: desc, - APIName: d.Root.API.Name, - APIVersion: d.Root.API.Version, - VarName: varName, - PathName: codegen.SnakeCase(varName), - StructName: codegen.Goify(service.Name, true), - PkgName: pkgName, - ViewsPkg: viewspkg, - Methods: methods, - Schemes: schemes, - ServerInterceptors: d.collectInterceptors(service, methods, serviceResolver, true), - ClientInterceptors: d.collectInterceptors(service, methods, serviceResolver, false), - Scope: scope, - ViewScope: viewScope, - errorTypes: errTypes, - errorInits: errorInits, - userTypes: types, - projectedTypes: projTypes, - viewedResultTypes: viewedRTs, - unions: unions, - viewDerived: viewDerived, - } - if err := d.registerPackageData(service, data); err != nil { - return nil, err - } - return data, nil -} - -// collectInterceptors returns the set of interceptors defined on the given -// service including any interceptor defined on specific service methods or API. -func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*MethodData, resolver *declarationResolver, server bool) []*InterceptorData { - var ints []*expr.InterceptorExpr - if server { - ints = d.Root.API.ServerInterceptors - ints = append(ints, svc.ServerInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ServerInterceptors...) - } - } else { - ints = d.Root.API.ClientInterceptors - ints = append(ints, svc.ClientInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ClientInterceptors...) - } - } - // remove duplicate interceptors - sort.Slice(ints, func(i, j int) bool { - return ints[i].Name < ints[j].Name - }) - for i := 1; i < len(ints); i++ { - if ints[i-1].Name == ints[i].Name { - ints = append(ints[:i], ints[i+1:]...) - i-- - } - } - - res := make([]*InterceptorData, 0, len(ints)) - for _, i := range ints { - res = append(res, buildInterceptorData(svc, methods, i, resolver, server)) - } - return res -} - -// declarationContext configures transformations and validations to resolve -// every named service or view type through its planned package declaration. -func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.AttributeContext { - return &codegen.AttributeContext{ - Pointer: pointer, - UseDefault: true, - Scope: resolver, - } -} - -// collectTypes recurses through the attribute to gather all user types and -// binds relocated types to their frozen package declarations. -func (d *ServicesData) collectTypes(at *expr.AttributeExpr, service *expr.ServiceExpr, resolver *declarationResolver, seen map[userTypeDataKey]struct{}, loc *codegen.Location) (data []*UserTypeData, err error) { - if at == nil || at.Type == expr.Empty { - return nil, nil - } - collect := func(at *expr.AttributeExpr, loc *codegen.Location) error { - collected, err := d.collectTypes(at, service, resolver, seen, loc) - data = append(data, collected...) - return err - } - switch dt := at.Type.(type) { - case expr.UserType: - typeLoc := codegen.UserTypeLocation(dt) - if typeLoc == nil { - typeLoc = loc - } - entered := resolver.Enter(at).(*declarationResolver) - declaration := entered.userType(entered.currentPath, dt) - key := userTypeDataKey{origin: dt.Origin(), declaration: declaration} - if _, ok := seen[key]; ok { - return nil, nil - } - definitionResolver := entered.inOutputPackage(entered.currentPath) - data = append(data, &UserTypeData{ - Declaration: declaration, - Name: dt.Name(), - VarName: declaration.Name(), - Description: dt.Attribute().Description, - Def: definitionResolver.Def(dt.Attribute(), false, true), - Ref: definitionResolver.Ref(at, ""), - Loc: typeLoc, - Type: dt, - }) - seen[key] = struct{}{} - collected, collectErr := d.collectTypes(dt.Attribute(), service, entered, seen, typeLoc) - data = append(data, collected...) - if collectErr != nil { - return nil, collectErr - } - case *expr.Object: - for _, nat := range *dt { - if err := collect(nat.Attribute, loc); err != nil { - return nil, err - } - } - case *expr.Array: - if err := collect(dt.ElemType, loc); err != nil { - return nil, err - } - case *expr.Map: - if err := collect(dt.KeyType, loc); err != nil { - return nil, err - } - if err := collect(dt.ElemType, loc); err != nil { - return nil, err - } - case *expr.Union: - for _, nat := range dt.Values { - if userType, ok := generatedUnionBranch(nat, d.rootTypes); ok && loc != nil { - collected, collectErr := d.collectTypes(&expr.AttributeExpr{Type: userType}, service, resolver, seen, loc) - data = append(data, collected...) - if collectErr != nil { - return nil, collectErr - } - continue - } - if err := collect(nat.Attribute, loc); err != nil { - return nil, err - } - } - } - return data, nil -} - -// collectUnionTypes traverses the attribute to gather all union sum-type -// definitions referenced by the service. It records each emitted definition by -// generated package so Extend can copy one union into multiple packages while -// duplicate uses within one package share a definition. When view is true the -// provided location is used for all nested user types so that unions are -// generated in the views package and refer to view-local types (preventing -// import cycles). -func (d *ServicesData) collectUnionTypes(att *expr.AttributeExpr, service *expr.ServiceExpr, resolver *declarationResolver, loc *codegen.Location, unions map[unionDataKey]*UnionTypeData, seen map[expr.UserType]struct{}, view bool) error { - if att == nil || att.Type == expr.Empty { - return nil - } - recurse := func(att *expr.AttributeExpr, loc *codegen.Location) error { - return d.collectUnionTypes(att, service, resolver, loc, unions, seen, view) - } - switch dt := att.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.Origin()]; ok { - return nil - } - seen[dt.Origin()] = struct{}{} - typeLoc := loc - entered := resolver.Enter(att).(*declarationResolver) - if !view { - if ownLocation := codegen.UserTypeLocation(dt); ownLocation != nil { - typeLoc = ownLocation - } - } - return d.collectUnionTypes(dt.Attribute(), service, entered, typeLoc, unions, seen, view) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - if err := recurse(nat.Attribute, loc); err != nil { - return err - } - } - case *expr.Array: - return recurse(dt.ElemType, loc) - case *expr.Map: - if err := recurse(dt.KeyType, loc); err != nil { - return err - } - return recurse(dt.ElemType, loc) - case *expr.Union: - packagePath := servicePackagePath(d.generation.GenPkg(), service) - if view { - packagePath += "/views" - } else if loc != nil { - packagePath = generatedPackagePath(d.generation.GenPkg(), service, loc) - } - key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(dt)} - if _, ok := unions[key]; !ok { - generatedPackage := d.generation.Package(packagePath) - declaration, err := generatedPackage.Union(dt) - if err != nil { - return err - } - branchLookup := func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { - return generatedPackage.UnionBranch(dt, branch.Name) - } - unionData, err := buildUnionTypeData(dt, declaration, resolver.inOutputPackage(packagePath), loc, view, branchLookup) - if err != nil { - return err - } - if view { - unions[key] = unionData - } else { - owner := d.generatedPackage(service, loc) - ownedUnion, ok := owner.unions[key.identity] - if !ok { - ownedUnion = unionData - owner.unions[key.identity] = ownedUnion - } - unions[key] = ownedUnion - } - } - for _, nat := range dt.Values { - if err := recurse(nat.Attribute, loc); err != nil { - return err - } - } - } - return nil -} - -// buildUnionTypeData creates the data needed to generate a sum-type union -// struct, its discriminator kind, and branch metadata. When view is true the -// union is generated in the views package: field types are computed using the -// view scope and are always emitted unqualified so they refer to the -// view-local projected types. -func buildUnionTypeData(u *expr.Union, declaration *codegen.UnionDeclaration, attributor codegen.Attributor, loc *codegen.Location, view bool, branchLookup unionBranchLookup) (*UnionTypeData, error) { - fields := make([]*UnionFieldData, len(u.Values)) - for i, nat := range u.Values { - fieldName := codegen.Goify(nat.Name, true) - branchDeclaration, err := branchLookup(nat) - if err != nil { - return nil, err - } - fieldType := attributor.Enter(nat.Attribute).Ref(nat.Attribute, "") - primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) - _, isUserType := nat.Attribute.Type.(expr.UserType) - emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && attributor.Package(nat.Attribute) == "" - var definition *expr.AttributeExpr - if _, emitsAlias := branchDeclaration.Type(); emitsAlias { - definition = nat.Attribute.Type.(expr.UserType).Attribute() - } - fields[i] = &UnionFieldData{ - Name: nat.Name, - KindConst: branchDeclaration.KindConst(), - Constructor: branchDeclaration.Constructor(), - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - EmitPrimitiveAlias: emitPrimitiveAlias, - PrimitiveAliasType: primitiveAliasType, - TypeTag: nat.Name, - reference: nat.Attribute, - definition: definition, - } - } - - return &UnionTypeData{ - Declaration: declaration, - Name: declaration.Name(), - KindName: declaration.KindName(), - Fields: fields, - Loc: loc, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), - }, nil -} - -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs - } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted -} - -// primitiveAliasGoType resolves the native Go type for a primitive alias branch. -// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. -func primitiveAliasGoType(dt expr.DataType) (string, bool) { - if !expr.IsPrimitive(dt) { - return "", false - } - for { - ut, ok := dt.(expr.UserType) - if !ok { - return codegen.GoNativeTypeName(dt), true - } - dt = ut.Attribute().Type - } -} - -// serviceTypeData returns the frozen declaration name, owning-package -// definition, and service-package reference for one normalized method type. -func serviceTypeData(attribute *expr.AttributeExpr, resolver *declarationResolver) (string, string, string) { - userType, ok := attribute.Type.(expr.UserType) - if !ok { - return resolver.Name(attribute, "", false, true), - "", - resolver.Ref(attribute, "") - } - entered := resolver.Enter(attribute).(*declarationResolver) - declaration := entered.userType(entered.currentPath, userType) - definitionResolver := entered.inOutputPackage(entered.currentPath) - return declaration.Name(), - definitionResolver.Def(userType.Attribute(), false, true), - resolver.Ref(attribute, "") -} - -// serviceTypeDeclaration returns the frozen declaration for a named method -// type. Primitive method types do not own generated declarations. -func serviceTypeDeclaration(attribute *expr.AttributeExpr, resolver *declarationResolver) *codegen.TypeDeclaration { - if attribute == nil || attribute.Type == expr.Empty { - return nil - } - userType, ok := attribute.Type.(expr.UserType) - if !ok { - return nil - } - entered := resolver.Enter(attribute).(*declarationResolver) - return entered.userType(entered.currentPath, userType) -} - -// buildErrorInitData creates the data needed to generate code around endpoint error return values. -func buildErrorInitData(er *expr.ErrorExpr, resolver *declarationResolver) *ErrorInitData { - _, temporary := er.Meta["goa:error:temporary"] - _, timeout := er.Meta["goa:error:timeout"] - _, fault := er.Meta["goa:error:fault"] - return &ErrorInitData{ - Name: fmt.Sprintf("Make%s", codegen.Goify(er.Name, true)), - Description: er.Description, - ErrName: er.Name, - TypeName: resolver.Name(er.AttributeExpr, "", false, true), - TypeRef: resolver.Ref(er.AttributeExpr, ""), - Temporary: temporary, - Timeout: timeout, - Fault: fault, - } -} - -// buildMethodData creates the data needed to render the given endpoint. It -// records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope, resolver *declarationResolver) (*MethodData, error) { - var ( - vname string - desc string - payloadName string - payloadLoc *codegen.Location - payloadDef string - payloadRef string - payloadDesc string - payloadEx any - rname string - resultLoc *codegen.Location - resultDef string - resultRef string - resultDesc string - resultEx any - errors []*ErrorInitData - errorLocs map[string]*codegen.Location - isJSONRPC bool - reqs = make(RequirementsData, 0, len(m.Requirements)) - schemes SchemesData - ) - vname = scope.Unique(codegen.Goify(m.Name, true), "Endpoint") - desc = m.Description - if desc == "" { - desc = codegen.Goify(m.Name, true) + " implements " + m.Name + "." - } - if m.Payload.Type != expr.Empty { - payloadLoc = codegen.UserTypeLocation(m.Payload.Type) - payloadName, payloadDef, payloadRef = serviceTypeData(m.Payload, resolver) - payloadDesc = m.Payload.Description - if payloadDesc == "" { - payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", - payloadName, m.Service.Name, m.Name) - } - payloadEx = m.Payload.Example(d.examples.At(expr.MethodPayloadExampleIdentity(m))) - } - if m.Result.Type != expr.Empty { - resultLoc = codegen.UserTypeLocation(m.Result.Type) - rname, resultDef, resultRef = serviceTypeData(m.Result, resolver) - resultDesc = m.Result.Description - if resultDesc == "" { - resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", - rname, m.Service.Name, m.Name) - } - resultEx = m.Result.Example(d.examples.At(expr.MethodResultExampleIdentity(m))) - } - if len(m.Errors) > 0 { - errors = make([]*ErrorInitData, len(m.Errors)) - errorLocs = make(map[string]*codegen.Location, len(m.Errors)) - for i, er := range m.Errors { - errors[i] = buildErrorInitData(er, resolver) - errorLocs[er.Name] = codegen.UserTypeLocation(er.Type) - } - } - - _, isJSONRPC = m.Meta["jsonrpc"] - - // Check if this JSON-RPC method uses SSE or WebSocket - var isJSONRPCSSE bool - var isJSONRPCWebSocket bool - if isJSONRPC && m.IsStreaming() { - if httpJSONRPCSvc := d.Root.API.JSONRPC.HTTPExpr.Service(m.Service.Name); httpJSONRPCSvc != nil { - for _, e := range httpJSONRPCSvc.HTTPEndpoints { - if e.MethodExpr.Name == m.Name { - if e.SSE != nil { - isJSONRPCSSE = true - } else { - isJSONRPCWebSocket = true - } - break - } - } - } - } - - for _, req := range expr.EffectiveSecurityRequirements(m.Requirements) { - var rs SchemesData - for _, s := range req.Schemes { - sch := BuildSchemeData(s, m) - rs = rs.Append(sch) - schemes = schemes.Append(sch) - } - reqs = append(reqs, &RequirementData{Schemes: rs, Scopes: req.Scopes}) - } - - // Unfortunately we can't completely isolate the service codegen from - // the underlying transport when wanting to skip Goa's built-in decoding. - skipRequestBodyEncodeDecode := false - skipResponseBodyEncodeDecode := false - var httpSvc *expr.HTTPServiceExpr - for _, svc := range d.Root.API.HTTP.Services { - if svc.Name() == m.Service.Name { - httpSvc = svc - break - } - } - if httpSvc != nil { - if httpMet := httpSvc.Endpoint(m.Name); httpMet != nil { - skipRequestBodyEncodeDecode = httpMet.SkipRequestBodyEncodeDecode - skipResponseBodyEncodeDecode = httpMet.SkipResponseBodyEncodeDecode - } - } - - data := &MethodData{ - Name: m.Name, - VarName: vname, - Description: desc, - Idempotent: m.Idempotent, - Payload: payloadName, - PayloadLoc: payloadLoc, - PayloadDef: payloadDef, - PayloadRef: payloadRef, - PayloadDeclaration: serviceTypeDeclaration(m.Payload, resolver), - PayloadDesc: payloadDesc, - PayloadEx: payloadEx, - PayloadDefault: m.Payload.DefaultValue, - Result: rname, - ResultLoc: resultLoc, - ResultDef: resultDef, - ResultRef: resultRef, - ResultDeclaration: serviceTypeDeclaration(m.Result, resolver), - ResultDesc: resultDesc, - ResultEx: resultEx, - Errors: errors, - ErrorLocs: errorLocs, - IsJSONRPC: isJSONRPC, - IsJSONRPCSSE: isJSONRPCSSE, - IsJSONRPCWebSocket: isJSONRPCWebSocket, - Requirements: reqs, - Schemes: schemes, - StreamKind: m.Stream, - HasMixedResults: m.HasMixedResults(), - SkipRequestBodyEncodeDecode: skipRequestBodyEncodeDecode, - SkipResponseBodyEncodeDecode: skipResponseBodyEncodeDecode, - RequestStruct: vname + "RequestData", - ResponseStruct: vname + "ResponseData", - } - - if err := d.initStreamData(data, m, vname, rname, resultRef, scope, resolver); err != nil { - return nil, err - } - return data, nil -} - -// initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope, resolver *declarationResolver) error { - if !m.IsStreaming() && !m.HasMixedResults() { - return nil - } - var ( - spayloadName string - spayloadRef string - spayloadDef string - spayloadDesc string - spayloadEx any - srname = rname // streaming result name - srref = resultRef // streaming result ref - ) - - // If StreamingResult is different from Result, use it for streaming - if m.HasMixedResults() && m.StreamingResult != nil && m.StreamingResult.Type != expr.Empty { - srname, data.StreamingResultDef, srref = serviceTypeData(m.StreamingResult, resolver) - data.StreamingResult = srname - data.StreamingResultRef = srref - data.StreamingResultDeclaration = serviceTypeDeclaration(m.StreamingResult, resolver) - data.StreamingResultDesc = m.StreamingResult.Description - if data.StreamingResultDesc == "" { - data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", - srname, m.Service.Name, m.Name) - } - data.StreamingResultEx = m.StreamingResult.Example(d.examples.At(expr.MethodStreamingResultExampleIdentity(m))) - } - - if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { - spayloadName, spayloadDef, spayloadRef = serviceTypeData(m.StreamingPayload, resolver) - data.StreamingPayloadDeclaration = serviceTypeDeclaration(m.StreamingPayload, resolver) - spayloadDesc = m.StreamingPayload.Description - if spayloadDesc == "" { - spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", - spayloadName, m.Service.Name, m.Name) - } - spayloadEx = m.StreamingPayload.Example(d.examples.At(expr.MethodStreamingPayloadExampleIdentity(m))) - } - // For JSON-RPC WebSocket: - // - Client streaming (no result streaming): no endpoint struct needed, just payload - // - Bidirectional streaming: endpoint struct needed for both payload and stream - endpointStruct := vname + "EndpointInput" - if data.IsJSONRPC && m.IsStreaming() && !data.IsJSONRPCSSE && m.Stream == expr.ClientStreamKind { - endpointStruct = "" - } - // For mixed results with SSE, treat as server streaming - streamKind := m.Stream - if m.HasMixedResults() && !m.IsStreaming() { - // Mixed results with SSE should be treated as server streaming - streamKind = expr.ServerStreamKind - } - svrStream := &StreamData{ - Interface: vname + "ServerStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ServerStream"), - EndpointStruct: endpointStruct, - Kind: streamKind, - SendName: "Send", - SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), - SendWithContextName: "SendWithContext", - SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), - SendTypeName: srname, - SendTypeRef: srref, - MustClose: true, - } - cliStream := &StreamData{ - Interface: vname + "ClientStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ClientStream"), - Kind: streamKind, - RecvName: "Recv", - RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), - RecvWithContextName: "RecvWithContext", - RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), - RecvTypeName: srname, - RecvTypeRef: srref, - } - // For SSE server streaming, we need both Send (for notifications) and SendAndClose (for final response) - if data.IsJSONRPCSSE && m.Stream == expr.ServerStreamKind && resultRef != "" { - svrStream.SendAndCloseName = "SendAndClose" - svrStream.SendAndCloseDesc = fmt.Sprintf("SendAndClose sends a final response with %q and closes the stream.", srname) - // For JSON-RPC SSE, methods take context directly; align names accordingly - svrStream.SendWithContextName = "Send" - svrStream.RecvWithContextName = "Recv" - // Update Send description to clarify it's for notifications only - svrStream.SendDesc = fmt.Sprintf("Send streams JSON-RPC notifications with %q. Notifications do not expect a response.", srname) - } - if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { - switch streamKind { - case expr.ClientStreamKind: - if srref != "" { - svrStream.SendName = "SendAndClose" - svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) - svrStream.SendWithContextName = "SendAndCloseWithContext" - svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) - svrStream.MustClose = false - cliStream.RecvName = "CloseAndRecv" - cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) - cliStream.RecvWithContextName = "CloseAndRecvWithContext" - cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) - } else { - cliStream.MustClose = true - } - case expr.BidirectionalStreamKind: - cliStream.MustClose = true - } - svrStream.RecvName = "Recv" - svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) - svrStream.RecvWithContextName = "RecvWithContext" - svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) - svrStream.RecvTypeName = spayloadName - svrStream.RecvTypeRef = spayloadRef - cliStream.SendName = "Send" - cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) - cliStream.SendWithContextName = "SendWithContext" - cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) - cliStream.SendTypeName = spayloadName - cliStream.SendTypeRef = spayloadRef - } - data.ClientStream = cliStream - data.ServerStream = svrStream - data.StreamingPayload = spayloadName - data.StreamingPayloadDef = spayloadDef - data.StreamingPayloadRef = spayloadRef - data.StreamingPayloadDesc = spayloadDesc - data.StreamingPayloadEx = spayloadEx - return nil -} - -// buildInterceptorData creates the data needed to generate interceptor code. -func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr.InterceptorExpr, resolver *declarationResolver, server bool) *InterceptorData { - data := &InterceptorData{ - Name: codegen.Goify(i.Name, true), - DesignName: i.Name, - Description: i.Description, - } - if len(svc.Methods) == 0 { - return data - } - attributesCollected := false - for _, m := range svc.Methods { - applies := false - intExprs := m.ServerInterceptors - if !server { - intExprs = m.ClientInterceptors - } - for _, in := range intExprs { - if in.Name == i.Name { - if !attributesCollected { - payload, result, streamingPayload := m.Payload, m.Result, m.StreamingPayload - data.ReadPayload = collectAttributes(i.ReadPayload, payload, resolver) - data.WritePayload = collectAttributes(i.WritePayload, payload, resolver) - data.ReadResult = collectAttributes(i.ReadResult, result, resolver) - data.WriteResult = collectAttributes(i.WriteResult, result, resolver) - data.ReadStreamingPayload = collectAttributes(i.ReadStreamingPayload, streamingPayload, resolver) - data.WriteStreamingPayload = collectAttributes(i.WriteStreamingPayload, streamingPayload, resolver) - data.ReadStreamingResult = collectAttributes(i.ReadStreamingResult, result, resolver) - data.WriteStreamingResult = collectAttributes(i.WriteStreamingResult, result, resolver) - if len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 { - data.HasPayloadAccess = true - } - if len(data.ReadResult) > 0 || len(data.WriteResult) > 0 { - data.HasResultAccess = true - } - if len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 { - data.HasStreamingPayloadAccess = true - } - if len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 { - data.HasStreamingResultAccess = true - } - attributesCollected = true - } - applies = true - break - } - } - if !applies { - continue - } - var md *MethodData - for _, mt := range methods { - if m.Name == mt.Name { - md = mt - break - } - } - data.Methods = append(data.Methods, buildInterceptorMethodData(i, md)) - if server { - md.ServerInterceptors = append(md.ServerInterceptors, i.Name) - } else { - md.ClientInterceptors = append(md.ClientInterceptors, i.Name) - } - } - return data -} - -// buildInterceptorMethodData creates the data needed to generate interceptor -// method code. -func buildInterceptorMethodData(i *expr.InterceptorExpr, md *MethodData) *MethodInterceptorData { - var serverStream, clientStream *StreamInterceptorData - if md.ServerStream != nil { - serverStream = &StreamInterceptorData{ - Interface: md.ServerStream.Interface, - SendName: md.ServerStream.SendName, - SendWithContextName: md.ServerStream.SendWithContextName, - SendTypeRef: md.ServerStream.SendTypeRef, - RecvName: md.ServerStream.RecvName, - RecvWithContextName: md.ServerStream.RecvWithContextName, - RecvTypeRef: md.ServerStream.RecvTypeRef, - MustClose: md.ServerStream.MustClose, - EndpointStruct: md.ServerStream.EndpointStruct, - } - } - if md.ClientStream != nil { - clientStream = &StreamInterceptorData{ - Interface: md.ClientStream.Interface, - SendName: md.ClientStream.SendName, - SendWithContextName: md.ClientStream.SendWithContextName, - SendTypeRef: md.ClientStream.SendTypeRef, - RecvName: md.ClientStream.RecvName, - RecvWithContextName: md.ClientStream.RecvWithContextName, - RecvTypeRef: md.ClientStream.RecvTypeRef, - MustClose: md.ClientStream.MustClose, - } - } - var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string - if i.ReadPayload != nil || i.WritePayload != nil { - payloadAccess = codegen.Goify(i.Name, false) + md.VarName + "Payload" - } - if i.ReadResult != nil || i.WriteResult != nil { - resultAccess = codegen.Goify(i.Name, false) + md.VarName + "Result" - } - if i.ReadStreamingPayload != nil || i.WriteStreamingPayload != nil { - streamingPayloadAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingPayload" - } - if i.ReadStreamingResult != nil || i.WriteStreamingResult != nil { - streamingResultAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingResult" - } - return &MethodInterceptorData{ - MethodName: md.VarName, - PayloadAccess: payloadAccess, - ResultAccess: resultAccess, - PayloadRef: md.PayloadRef, - ResultRef: md.ResultRef, - StreamingPayloadAccess: streamingPayloadAccess, - StreamingPayloadRef: md.StreamingPayloadRef, - StreamingResultAccess: streamingResultAccess, - StreamingResultRef: md.ResultRef, - ClientStream: clientStream, - ServerStream: serverStream, - } -} - -// BuildSchemeData builds the scheme data for the given scheme and method expr. -func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { - if !expr.IsObject(m.Payload.Type) { - return nil - } - if s.Kind == expr.BasicAuthKind { - userAtt := expr.TaggedAttribute(m.Payload, "security:username") - passAtt := expr.TaggedAttribute(m.Payload, "security:password") - return &SchemeData{ - Type: s.Kind.String(), - SchemeName: s.SchemeName, - UsernameAttr: userAtt, - UsernameField: codegen.Goify(userAtt, true), - UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), - UsernameRequired: m.Payload.IsRequired(userAtt), - PasswordAttr: passAtt, - PasswordField: codegen.Goify(passAtt, true), - PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), - PasswordRequired: m.Payload.IsRequired(passAtt), - Scopes: schemeScopes(s), - } - } - // The remaining scheme kinds all carry a single credential attribute - // identified by a kind-specific security tag on the method payload. - var tag string - switch s.Kind { - case expr.APIKeyKind: - tag = "security:apikey:" + s.SchemeName - case expr.BearerKind: - tag = "security:bearer" - case expr.JWTKind: - tag = "security:token" - case expr.OAuth2Kind: - tag = "security:accesstoken" - default: - return nil - } - keyAtt := expr.TaggedAttribute(m.Payload, tag) - if keyAtt == "" { - return nil - } - data := &SchemeData{ - Type: s.Kind.String(), - Name: s.Name, - SchemeName: s.SchemeName, - CredField: codegen.Goify(keyAtt, true), - CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), - CredRequired: m.Payload.IsRequired(keyAtt), - KeyAttr: keyAtt, - Scopes: schemeScopes(s), - In: s.In, - } - if s.Kind == expr.OAuth2Kind { - data.Flows = s.Flows - } - return data -} - -// schemeScopes returns the scope names defined by the scheme, nil when the -// scheme defines none. -func schemeScopes(s *expr.SchemeExpr) []string { - if len(s.Scopes) == 0 { - return nil - } - scopes := make([]string, len(s.Scopes)) - for i, sc := range s.Scopes { - scopes[i] = sc.Name - } - return scopes -} - -// collectAttributes resolves the interceptor fields selected from parent into -// the generated names, type references, and pointer behavior rendered by the -// interceptor templates. -func collectAttributes(attrNames, parent *expr.AttributeExpr, resolver codegen.Attributor) []*AttributeData { - if attrNames == nil { - return nil - } - obj := expr.AsObject(attrNames.Type) - if obj == nil { - return nil - } - data := make([]*AttributeData, len(*obj)) - parentResolver := resolver.Enter(parent) - for i, nat := range *obj { - parentAttr := parent.Find(nat.Name) - if parentAttr == nil { - // Attribute references are validated at design time so a miss - // here would surface as a nil deref at template render time. - panic(fmt.Sprintf("attribute %q not found in parent attribute", nat.Name)) // bug - } - data[i] = &AttributeData{ - Name: codegen.Goify(nat.Name, true), - TypeRef: parentResolver.Ref(parentAttr, parentResolver.Package(parentAttr)), - Pointer: parent.IsPrimitivePointer(nat.Name, true), - } - } - return data -} - -// projectTypePairs rewrites a copied result graph into pointer-backed view -// types and returns each generated declaration with its exact source. The -// source Origin makes independently rebuilt plan and render graphs select the -// same package record. -func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) []*projectedTypePair { - collect := func(projected, source *expr.AttributeExpr) []*projectedTypePair { - return projectTypePairs(projected, source, seen) - } - switch projectedType := projected.Type.(type) { - case expr.UserType: - sourceType := source.Type.(expr.UserType) - origin := sourceType.Origin() - if existing, ok := seen[origin]; ok { - if existing != nil { - projected.Type = existing - } - return nil - } - seen[origin] = nil - projectedType.Rename(projectedType.Name() + "View") - nested := collect(projectedType.Attribute(), sourceType.Attribute()) - seen[origin] = projectedType - return append([]*projectedTypePair{{ - source: sourceType, - projected: projectedType, - sourceAttribute: source, - projectedAttribute: projected, - }}, nested...) - case *expr.Array: - return collect(projectedType.ElemType, source.Type.(*expr.Array).ElemType) - case *expr.Map: - sourceMap := source.Type.(*expr.Map) - pairs := collect(projectedType.KeyType, sourceMap.KeyType) - return append(pairs, collect(projectedType.ElemType, sourceMap.ElemType)...) - case *expr.Object: - sourceObject := source.Type.(*expr.Object) - var pairs []*projectedTypePair - for _, field := range *projectedType { - pairs = append(pairs, collect(field.Attribute, sourceObject.Attribute(field.Name))...) - } - return pairs - case *expr.Union: - sourceUnion := source.Type.(*expr.Union) - var pairs []*projectedTypePair - for index, branch := range projectedType.Values { - pairs = append(pairs, collect(branch.Attribute, sourceUnion.Values[index].Attribute)...) - } - return pairs - default: - return nil - } -} - -// projectedResultRoot returns the root attribute used to collect projected -// view types for m.Result. Compiler-created method wrappers retain their exact -// provenance in generation, so authored types with matching text stay intact. -func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { - if _, normalized := generation.NormalizedMethodType(ut); !normalized { - return expr.DupAtt(m.Result), m.Result - } - return expr.DupAtt(ut.Attribute()), ut.Attribute() - } - return expr.DupAtt(m.Result), m.Result -} - -// hasResultType returns true if the given attribute has a result type recursively. -func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true - } - var seen map[expr.UserType]struct{} - if len(seens) > 0 { - seen = seens[0] - } else { - seen = make(map[expr.UserType]struct{}) - } - switch a := att.Type.(type) { - case expr.UserType: - origin := a.Origin() - if _, ok := seen[origin]; ok { - return false - } - seen[origin] = struct{}{} - return hasResultType(a.Attribute(), seen) - case *expr.Array: - return hasResultType(a.ElemType, seen) - case *expr.Map: - return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultType(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultType(nat.Attribute, seen) { - return true - } - } - } - return false -} - -// buildProjectedType returns the render data for one pointer-backed view -// declaration and its conversions to the exact source service type. -func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ProjectedTypeData { - var ( - projections []*InitData - typeInits []*InitData - views []*ViewData - - varname = declaration.Name() - pt = projected.Type.(expr.UserType) - ) - if _, isrt := pt.(*expr.ResultTypeExpr); isrt { - typeInits = buildViewConversions(projected, att, serviceResolver, viewResolver, true) - projections = buildViewConversions(projected, att, serviceResolver, viewResolver, false) - serviceName, _, _ := serviceTypeData(att, serviceResolver) - views = buildViews(att.Type.(*expr.ResultTypeExpr), serviceName) - } - validations := buildValidations(projected, viewResolver) - removeMeta(projected) - return &ProjectedTypeData{ - UserTypeData: &UserTypeData{ - Declaration: declaration, - Name: varname, - Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), - VarName: varname, - Def: viewResolver.Def(pt.Attribute(), true, true), - Ref: viewResolver.Ref(projected, ""), - Type: pt, - }, - Projections: projections, - TypeInits: typeInits, - Validations: validations, - ViewsPkg: viewspkg, - Views: views, - } -} - -// buildViews builds the view data for all the views in the given result type. -func buildViews(rt *expr.ResultTypeExpr, typeName string) []*ViewData { - views := make([]*ViewData, len(rt.Views)) - for i, view := range rt.Views { - vatt := expr.AsObject(view.Type) - attrs := make([]string, len(*vatt)) - for j, nat := range *vatt { - attrs[j] = nat.Name - } - views[i] = &ViewData{ - Name: view.Name, - Description: view.Description, - Attributes: attrs, - TypeVarName: typeName, - } - } - return views -} - -// buildViewedResultType builds a viewed result type from the given result type -// and projected type. -func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { - // collect result type views - rt := att.Type.(*expr.ResultTypeExpr) - isarr := expr.IsArray(att.Type) - var viewName string - if !rt.HasMultipleViews() { - viewName = expr.DefaultView - } - if v, ok := att.Meta.Last(expr.ViewMetaKey); ok { - viewName = v - } - projectedDeclaration := viewResolver.userType(viewResolver.currentPath, projected.Type.(expr.UserType)) - views := buildViews(rt, declaration.Name()) - - // build validation data - resvar, _, serviceRef := serviceTypeData(att, serviceResolver) - projT := wrapProjected(projected.Type.(expr.UserType)) - wrapperResolver := viewResolver.bindDerived(projT, codegen.NewViewedResultTypeID(rt)) - resref := wrapperResolver.refDeclaration(declaration, att.Type) - data := map[string]any{ - "Projected": projectedDeclaration.Name(), - "ArgVar": "result", - "Source": "result", - "Views": views, - "IsViewed": true, - } - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name := "Validate" + resvar - validate := &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), - Ref: resref, - Validate: buf.String(), - } - - // build constructor to initialize viewed result type from result type - serviceViewResolver := wrapperResolver.withOutputPackage(serviceResolver.outputPath) - vresref := serviceViewResolver.refDeclaration(declaration, att.Type) - data = map[string]any{ - "ToViewed": true, - "ArgVar": "res", - "ReturnVar": "vres", - "Views": views, - "ReturnTypeRef": vresref, - "IsCollection": isarr, - "TargetType": serviceViewResolver.Name(&expr.AttributeExpr{Type: projT}, "", false, true), - "InitName": "new" + projectedDeclaration.Name(), - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name = "NewViewed" + resvar - init := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), - Args: []*InitArgData{ - {Name: "res", Ref: serviceRef}, - {Name: "view", Ref: "string"}, - }, - ReturnTypeRef: vresref, - Code: buf.String(), - } - - // build constructor to initialize result type from viewed result type - resref = serviceRef - data = map[string]any{ - "ToResult": true, - "ArgVar": "vres", - "ReturnVar": "res", - "Views": views, - "ReturnTypeRef": resref, - "InitName": "new" + resvar, - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name = "New" + resvar - resinit := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: vresref}}, - ReturnTypeRef: resref, - Code: buf.String(), - } - - return &ViewedResultTypeData{ - UserTypeData: &UserTypeData{ - Declaration: declaration, - Name: resvar, - Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), - VarName: resvar, - Def: wrapperResolver.Def(projT.Attribute(), false, true), - Ref: resref, - Type: projT, - }, - FullName: serviceViewResolver.Name(&expr.AttributeExpr{Type: projT}, "", false, true), - FullRef: vresref, - ResultInit: resinit, - Init: init, - Views: views, - Validate: validate, - IsCollection: isarr, - ViewName: viewName, - ViewsPkg: viewspkg, - } -} - -// wrapProjected builds a viewed result type by wrapping the given projected -// in a result type with "projected" and "view" attributes. -func wrapProjected(projected expr.UserType) expr.UserType { - rt := projected.(*expr.ResultTypeExpr) - pratt := &expr.NamedAttributeExpr{ - Name: "projected", - Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, - } - prview := &expr.NamedAttributeExpr{ - Name: "view", - Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, - } - return &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{pratt, prview}, - Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, - }, - TypeName: rt.TypeName, - }, - Identifier: rt.Identifier, - Views: rt.Views, - } -} - -// buildViewConversions builds the data to generate the constructor code that -// converts between a result type and its projected type, one constructor per -// view. When toResult is true the constructors initialize the result type from -// the projected type, otherwise they project the result type to the projected -// type based on the view. -func buildViewConversions(projected, att *expr.AttributeExpr, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { - vrt := att.Type.(*expr.ResultTypeExpr) - if toResult { - vrt = projected.Type.(*expr.ResultTypeExpr) - } - pobj := expr.AsObject(projected.Type) - parr := expr.AsArray(projected.Type) - if parr != nil { - // result type collection - pobj = expr.AsObject(parr.ElemType.Type) - } - - init := make([]*InitData, 0, len(vrt.Views)) - serviceName, _, serviceRef := serviceTypeData(att, serviceResolver) - projectedType := projected.Type.(expr.UserType) - projectedDeclaration := viewResolver.userType(viewResolver.currentPath, projectedType) - serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) - for _, view := range vrt.Views { - var typ expr.DataType - obj := &expr.Object{} - walkViewAttrs(pobj, view, func(name string, att, _ *expr.AttributeExpr) { - obj.Set(name, att) - }) - typ = obj - if parr != nil { - ename := parr.ElemType.Type.Name() - if toResult { - ename = viewResolver.Name(parr.ElemType, "", false, true) - } - typ = &expr.Array{ElemType: &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: obj}, - TypeName: ename, - }, - }, - }} - } - wname := projected.Type.Name() - if toResult { - wname = projectedDeclaration.Name() - } - // viewed is the projected type narrowed down to the view attributes. - viewed := &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: typ}, - TypeName: wname, - }, - Views: vrt.Views, - Identifier: vrt.Identifier, - }, - } - - viewedType := viewed.Type.(expr.UserType) - viewIdentity, ok := viewResolver.derived[projectedType.Origin()] - if !ok { - panic(fmt.Sprintf("projected type %q has no planned derived identity", projectedType.Name())) // bug - } - viewedResolver := serviceViewResolver.bindDerived(viewedType, viewIdentity) - if projectedArray := expr.AsArray(projected.Type); projectedArray != nil { - projectedElement := projectedArray.ElemType.Type.(expr.UserType) - elementIdentity, ok := viewResolver.derived[projectedElement.Origin()] - if !ok { - panic(fmt.Sprintf("projected element type %q has no planned derived identity", projectedElement.Name())) // bug - } - viewedElement := expr.AsArray(viewed.Type).ElemType.Type.(expr.UserType) - viewedResolver = viewedResolver.bindDerived(viewedElement, elementIdentity) - } - if toResult { - srcCtx := declarationContext(viewedResolver, true) - tgtCtx := declarationContext(serviceResolver, false) - resvar := serviceName - name := "new" + resvar - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - elementInit := "" - if parr != nil { - serviceElement := expr.AsArray(att.Type).ElemType - serviceElementResolver := serviceResolver.Enter(serviceElement).(*declarationResolver) - elementInit = serviceElementResolver.userType( - serviceElementResolver.currentPath, - serviceElement.Type.(expr.UserType), - ).Name() - } - code, helpers := buildConstructorCode( - viewed, - att, - "vres", - "res", - srcCtx, - tgtCtx, - view.Name, - elementInit, - serviceResolver.declarationName, - ) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: serviceViewResolver.Ref(projected, "")}}, - ReturnTypeRef: serviceRef, - Code: code, - Helpers: helpers, - }) - } else { - srcCtx := declarationContext(serviceResolver, false) - tgtCtx := declarationContext(viewedResolver, true) - tname := projectedDeclaration.Name() - name := "new" + tname - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - elementInit := "" - if parr != nil { - projectedElement := parr.ElemType.Type.(expr.UserType) - elementInit = viewResolver.userType(viewResolver.currentPath, projectedElement).Name() - } - code, helpers := buildConstructorCode( - att, - viewed, - "res", - "vres", - srcCtx, - tgtCtx, - view.Name, - elementInit, - viewedResolver.declarationName, - ) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, view.Name), - Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, - ReturnTypeRef: serviceViewResolver.Ref(projected, ""), - Code: code, - Helpers: helpers, - }) - } - } - return init -} - -// buildValidations builds the data required to generate validations for the -// projected types. -func buildValidations(projected *expr.AttributeExpr, resolver *declarationResolver) []*ValidateData { - ut := projected.Type.(expr.UserType) - tname := resolver.Name(projected, "", false, true) - var validations []*ValidateData - if rt, isrt := ut.(*expr.ResultTypeExpr); isrt { - // for result types we create a validation function containing view - // specific validation logic for each view - arr := expr.AsArray(projected.Type) - for _, view := range rt.Views { - data := map[string]any{ - "Projected": tname, - "ArgVar": "result", - "Source": "result", - "IsCollection": arr != nil, - } - var vn string - name := "Validate" + tname - if view.Name != expr.DefaultView { - vn = codegen.Goify(view.Name, true) - name += vn - } - - if arr != nil { - // dealing with an array type - data["Source"] = "item" - data["ValidateVar"] = "Validate" + resolver.Name(arr.ElemType, "", false, true) + vn - } else { - var fields []map[string]any - o := &expr.Object{} - walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attr, vatt *expr.AttributeExpr) { - if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok { - // use explicitly specified view (if any) for the attribute, - // otherwise use default - vw := "" - if v, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && v != expr.DefaultView { - vw = v - } - fields = append(fields, map[string]any{ - "Name": name, - "ValidateVar": "Validate" + resolver.Name(attr, "", false, true) + codegen.Goify(vw, true), - "IsRequired": rt.Attribute().IsRequired(name), - }) - } else { - o.Set(name, attr) - } - }) - ctx := declarationContext(resolver, !expr.IsPrimitive(projected.Type)) - data["Validate"] = codegen.ValidationCode(&expr.AttributeExpr{Type: o, Validation: rt.Validation}, rt, ctx, true, false, true, "result") - data["Fields"] = fields - } - - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, view.Name), - Ref: resolver.Ref(projected, ""), - Validate: buf.String(), - }) - } - } else { - // for a user type or a result type with single view, we generate only one validation - // function containing the validation logic - name := "Validate" + tname - ctx := declarationContext(resolver, !expr.IsPrimitive(projected.Type)) - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), - Ref: resolver.Ref(projected, ""), - Validate: codegen.ValidationCode(ut.Attribute(), ut, ctx, true, expr.IsAlias(ut), true, "result"), - }) - } - return validations -} - -// buildConstructorCode builds the transformation code to create a projected -// type from a service type and vice versa. -// -// source and target contains the projected/service contextual attributes -// -// sourceVar and targetVar contains the variable name that holds the source and -// target data structures in the transformation code. -// -// view is used to generate the constructor function name. -func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, view, elementInitName string, nestedInitName func(*expr.AttributeExpr) string) (string, []*codegen.TransformFunctionData) { - var ( - helpers []*codegen.TransformFunctionData - buf bytes.Buffer - ) - rt := src.Type.(*expr.ResultTypeExpr) - arr := expr.AsArray(tgt.Type) - - data := map[string]any{ - "ArgVar": sourceVar, - "ReturnVar": targetVar, - "IsCollection": arr != nil, - "TargetType": targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault), - } - - if arr != nil { - // result type collection - init := "new" + elementInitName - if view != "" && view != expr.DefaultView { - init += codegen.Goify(view, true) - } - data["InitName"] = init - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers - } - - // service type to projected type (or vice versa) - targetRTs := &expr.Object{} - tatt := expr.DupAtt(tgt) - tobj := expr.AsObject(tatt.Type) - for _, nat := range *tobj { - if _, ok := nat.Attribute.Type.(*expr.ResultTypeExpr); ok { - targetRTs.Set(nat.Name, nat.Attribute) - tobj.Delete(nat.Name) - } - } - data["Source"] = sourceVar - data["Target"] = targetVar - - // build code for target with no result types - code, helpers, err := codegen.GoTransform(src, tatt, sourceVar, targetVar, sourceCtx, targetCtx, "transform", true) - if err != nil { - panic(err) // bug - } - data["Code"] = code - - fields := make([]map[string]any, 0, len(*targetRTs)) - // iterate through the result types found in the target and add the - // code to initialize them - for _, nat := range *targetRTs { - finit := "new" + nestedInitName(nat.Attribute) - if view != "" { - v := "" - if vatt := rt.View(view).Find(nat.Name); vatt != nil { - if attv, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && attv != expr.DefaultView { - // view is explicitly set for the result type on the attribute - v = attv - } - } - finit += codegen.Goify(v, true) - } - fields = append(fields, map[string]any{ - "VarName": codegen.Goify(nat.Name, true), - "FieldInit": finit, - }) - } - data["Fields"] = fields - - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers -} - -// walkViewAttrs iterates through the attributes in att that are found in the -// given view and executes the walker function. -func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { - for _, nat := range *expr.AsObject(view.Type) { - if attr := obj.Attribute(nat.Name); attr != nil { - walker(nat.Name, attr, nat.Attribute) - } - } -} - -// removeMeta removes the meta attributes from the given attribute. This is -// needed to make sure that any field name overriding is removed when -// generating protobuf types (as protogen itself won't honor these overrides). -func removeMeta(att *expr.AttributeExpr) { - if err := codegen.Walk(att, func(a *expr.AttributeExpr) error { - delete(a.Meta, "struct:pkg:path") - return nil - }); err != nil { - panic(err) // bug - } -} diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index e1a5d4088a..2fdaa9e84b 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -16,27 +16,19 @@ func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() generation := mustTestGeneration(t, "gen", nil) pkg := mustClaimTestPackage(t, generation, "gen/service") - _, err := pkg.DeclareUnion(union) + declaration, err := pkg.DeclareUnion(union) require.NoError(t, err) + facts := &unionFacts{ + union: union, + identity: codegen.NewUnionTypeID(union), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + location: &codegen.Location{RelImportPath: "gen/service"}, + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, pkg)) require.NoError(t, generation.Freeze()) - declaration, err := pkg.Union(union) - require.NoError(t, err) - data, err := buildUnionTypeData( - union, - declaration, - newServiceResolver( - generation, - aliasesForTest(t, "gen/service"), - &expr.ServiceExpr{Name: "service"}, - "gen/service", - ), - &codegen.Location{RelImportPath: "gen/service"}, - false, - func(branch *expr.NamedAttributeExpr) (*codegen.UnionBranchDeclaration, error) { - return pkg.UnionBranch(union, branch.Name) - }, - ) - assert.NoError(t, err) + data := buildRetainedUnionTypeData(facts, &importAliases{generation: generation}) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index 44c6602590..ab0d9b5f48 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -1,119 +1,68 @@ -// This file verifies that service union declarations keep deterministic names -// regardless of design traversal order. +// This file verifies retained service union declarations keep deterministic +// names regardless of design traversal order. package service import ( + "sort" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/dsl" ) -func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { - sourceFromSignals := makeUnionForOrderTest("source", - "physical_point", - "synthetic_series", - ) - sourceFromInputs := makeUnionForOrderTest("source", - "time_series", - "energy_rates", - ) +func TestServicePlanUnionNamesAreIndependentOfObjectOrder(t *testing.T) { + forward := retainedUnionNames(t, false) + reverse := retainedUnionNames(t, true) - forward := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - }, - } - reverse := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - }, - } - - loc := &codegen.Location{ - RelImportPath: "gen/service", - } - forwardNames := collectServiceUnionTypeNames(t, forward, loc) - reverseNames := collectServiceUnionTypeNames(t, reverse, loc) - - require.Len(t, forwardNames, 2) - require.Equal(t, forwardNames, reverseNames) + require.Len(t, forward, 2) + require.Equal(t, forward, reverse) } -func collectServiceUnionTypeNames(t *testing.T, att *expr.AttributeExpr, loc *codegen.Location) map[string]string { +// retainedUnionNames plans two same-base unions in the requested field order +// and indexes their frozen names by their ordered branch contract. +func retainedUnionNames(t *testing.T, reverse bool) map[string]string { t.Helper() - service := &expr.ServiceExpr{Name: "test"} - generation := mustTestGeneration(t, "generated.local/gen", nil) - generatedPackage := mustClaimTestPackage(t, generation, - generatedPackagePath(generation.GenPkg(), service, loc)) - - object := att.Type.(*expr.Object) - for _, named := range *object { - _, err := generatedPackage.DeclareUnion(named.Attribute.Type.(*expr.Union)) - if err != nil { - panic(err) + root := codegen.RunDSL(t, func() { + signals := dsl.Type("Signals", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.Attribute("physical_point", dsl.String) + dsl.Attribute("synthetic_series", dsl.String) + }) + }) + inputs := dsl.Type("Inputs", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.Attribute("time_series", dsl.String) + dsl.Attribute("energy_rates", dsl.String) + }) + }) + dsl.Service("test", func() { + dsl.Method("read", func() { + dsl.Payload(func() { + if reverse { + dsl.Attribute("beta", inputs) + dsl.Attribute("alpha", signals) + } else { + dsl.Attribute("alpha", signals) + dsl.Attribute("beta", inputs) + } + }) + }) + }) + }) + plan := mustServicePlan(t, root) + names := make(map[string]string) + for _, union := range plan.Services().Get("test").unions { + branches := make([]string, len(union.Fields)) + for index, field := range union.Fields { + branches[index] = field.Name } - } - if err := generation.Freeze(); err != nil { - panic(err) - } - services := &ServicesData{ - generation: generation, - aliases: aliasesForTest(t, generatedPackagePath(generation.GenPkg(), service, loc)), - packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), - } - seen := make(map[expr.UserType]struct{}) - unionByHash := make(map[unionDataKey]*UnionTypeData) - resolver := newServiceResolver( - generation, - services.aliases, - service, - generatedPackagePath(generation.GenPkg(), service, loc), - ) - if err := services.collectUnionTypes(att, service, resolver, loc, unionByHash, seen, false); err != nil { - panic(err) - } - - names := make(map[string]string, len(unionByHash)) - for key, data := range unionByHash { - names[string(key.identity)] = data.Name + sort.Strings(branches) + names[strings.Join(branches, ",")] = union.Name } return names } - -func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { - values := make([]*expr.NamedAttributeExpr, len(variants)) - for i, variant := range variants { - values[i] = &expr.NamedAttributeExpr{ - Name: variant, - Attribute: &expr.AttributeExpr{ - Type: expr.String, - }, - } - } - return &expr.Union{TypeName: typeName, Values: values} -} diff --git a/codegen/service/service_declaration_condition_contract_test.go b/codegen/service/service_declaration_condition_contract_test.go new file mode 100644 index 0000000000..918359fa09 --- /dev/null +++ b/codegen/service/service_declaration_condition_contract_test.go @@ -0,0 +1,110 @@ +// This file verifies service package declarations are collected only for the +// conditions that emit them and never depend on declarations in other packages. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestRelocatedResultViewConstructorsCompile catches constructor declarations +// that incorrectly depend on a result type declaration owned by another Go +// package. +func TestRelocatedResultViewConstructorsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("name", dsl.String) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "value") + dsl.View("default", func() { + dsl.Attribute("name") + dsl.Attribute("value") + }) + dsl.View("summary", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, "generated.local", files) +} + +// TestJSONRPCSSEEventNameIsDeclaredOnlyWhenEmitted catches an unused Event +// declaration that changes collision suffixes for methods with no result. +func TestJSONRPCSSEEventNameIsDeclaredOnlyWhenEmitted(t *testing.T) { + cases := []struct { + name string + result expr.DataType + wantEvent bool + wantName string + }{ + {name: "no result", result: expr.Empty}, + {name: "emits event", result: expr.String, wantEvent: true, wantName: "Event2"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, "generated.local/gen/events") + mustClaimTestPackage(t, generation, "generated.local/gen/events/views") + authored, err := servicePackage.DeclareUserType(&expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Event", + UID: "authored-event", + }) + require.NoError(t, err) + + result := &expr.AttributeExpr{Type: test.result} + method := &expr.MethodExpr{ + Name: "Watch", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + Result: result, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + Stream: expr.ServerStreamKind, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: result, + } + service := &expr.ServiceExpr{Name: "Events", Methods: []*expr.MethodExpr{method}} + method.Service = service + facts := &serviceFacts{ + service: service, + methods: []*expr.MethodExpr{method}, + methodByExpr: map[*expr.MethodExpr]*methodFacts{ + method: { + method: method, + varName: "Watch", + isJSONRPCSSE: true, + }, + }, + projections: make(map[*expr.MethodExpr]*projectionFacts), + } + + require.NoError(t, collectServiceNames(facts, &rootTypeSet{byOrigin: make(map[expr.UserType]expr.UserType)}, generation)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Event", authored.Name()) + event, exists := facts.names[serviceSymbolID{ + role: serviceEventNameRole, + service: service.Name, + }] + require.Equal(t, test.wantEvent, exists) + if test.wantEvent { + require.Equal(t, test.wantName, event.declaration.Name()) + } + }) + } +} diff --git a/codegen/service/service_dedup_test.go b/codegen/service/service_dedup_test.go index 897e0f25dd..886adc90d6 100644 --- a/codegen/service/service_dedup_test.go +++ b/codegen/service/service_dedup_test.go @@ -15,10 +15,10 @@ import ( // same result type the generated service code only emits a single event marker method. func TestService_DedupEventMarkers(t *testing.T) { root := codegen.RunDSL(t, stest.StreamingDuplicateResultTypesDSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, plan) require.Greater(t, len(files), 0) // Generate the service.go content diff --git a/codegen/service/service_fact_plan.go b/codegen/service/service_fact_plan.go new file mode 100644 index 0000000000..56942b887a --- /dev/null +++ b/codegen/service/service_fact_plan.go @@ -0,0 +1,315 @@ +// This file copies service method, error, streaming, and interceptor membership before generated package names freeze. +package service + +import ( + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// collectServiceFacts copies service membership and the transport decisions +// that renderers need so linking never consults mutable root collections. +func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, examples *expr.ExampleGenerator) *serviceFacts { + facts := &serviceFacts{ + service: service, + name: service.Name, + description: service.Description, + methods: append([]*expr.MethodExpr(nil), service.Methods...), + methodByExpr: make(map[*expr.MethodExpr]*methodFacts, len(service.Methods)), + errors: append([]*expr.ErrorExpr(nil), service.Errors...), + reachableTypes: make(map[expr.UserType]struct{}), + projections: make(map[*expr.MethodExpr]*projectionFacts), + } + for _, serviceError := range facts.errors { + facts.errorFacts = append(facts.errorFacts, retainErrorRenderFacts(serviceError)) + facts.referenceAttributes = append(facts.referenceAttributes, serviceError.AttributeExpr) + retainServiceValueTypes(facts, serviceError.AttributeExpr) + } + methodScope := codegen.NewNameScope() + methodScope.Unique("Use") + methodScope.Unique("websocket") + for _, method := range service.Methods { + methodFacts := &methodFacts{ + method: method, + serviceName: service.Name, + name: method.Name, + description: method.Description, + idempotent: method.Idempotent, + payload: retainMethodAttribute(method.Payload, examples.At(expr.MethodPayloadExampleIdentity(method))), + result: retainMethodAttribute(method.Result, examples.At(expr.MethodResultExampleIdentity(method))), + streamKind: method.Stream, + isStreaming: method.IsStreaming(), + hasMixedResults: method.HasMixedResults(), + varName: methodScope.Unique(codegen.Goify(method.Name, true), "Endpoint"), + } + methodFacts.streamingPayload = retainMethodAttribute( + method.StreamingPayload, + examples.At(expr.MethodStreamingPayloadExampleIdentity(method)), + ) + methodFacts.streamingResult = retainMethodAttribute( + method.StreamingResult, + examples.At(expr.MethodStreamingResultExampleIdentity(method)), + ) + _, methodFacts.isJSONRPC = method.Meta["jsonrpc"] + methodFacts.requirements, methodFacts.schemes = retainMethodSecurity(method) + for _, methodError := range method.Errors { + methodFacts.errors = append(methodFacts.errors, retainErrorRenderFacts(methodError)) + } + if method.IsStreaming() || method.HasMixedResults() { + methodFacts.serverStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ServerStream") + methodFacts.clientStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ClientStream") + } + if _, jsonRPC := method.Meta["jsonrpc"]; jsonRPC && method.IsStreaming() { + if jsonRPCService := root.API.JSONRPC.HTTPExpr.Service(service.Name); jsonRPCService != nil { + for _, endpoint := range jsonRPCService.HTTPEndpoints { + if endpoint.MethodExpr == method { + methodFacts.isJSONRPCSSE = endpoint.SSE != nil + methodFacts.isJSONRPCWebSocket = endpoint.SSE == nil + break + } + } + } + } + for _, httpService := range root.API.HTTP.Services { + if httpService.Name() != service.Name { + continue + } + if endpoint := httpService.Endpoint(method.Name); endpoint != nil { + methodFacts.skipRequestBodyEncodeDecode = endpoint.SkipRequestBodyEncodeDecode + methodFacts.skipResponseBodyEncodeDecode = endpoint.SkipResponseBodyEncodeDecode + } + break + } + facts.methodByExpr[method] = methodFacts + facts.orderedMethods = append(facts.orderedMethods, methodFacts) + facts.referenceAttributes = append( + facts.referenceAttributes, + method.Payload, + method.StreamingPayload, + method.Result, + ) + retainServiceValueTypes(facts, method.Payload) + retainServiceValueTypes(facts, method.StreamingPayload) + retainServiceValueTypes(facts, method.Result) + if method.HasMixedResults() { + facts.referenceAttributes = append(facts.referenceAttributes, method.StreamingResult) + retainServiceValueTypes(facts, method.StreamingResult) + } + for _, methodError := range method.Errors { + facts.referenceAttributes = append(facts.referenceAttributes, methodError.AttributeExpr) + retainServiceValueTypes(facts, methodError.AttributeExpr) + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodFacts.endpointField = methodScope.Unique(methodFacts.varName+"Endpoint", "") + if method.HasMixedResults() { + methodFacts.streamEndpointField = methodScope.Unique(methodFacts.varName+"StreamEndpoint", "") + } + } + facts.serverInterceptors = retainedInterceptors(root.API.ServerInterceptors, service.ServerInterceptors, facts.methods, true) + facts.clientInterceptors = retainedInterceptors(root.API.ClientInterceptors, service.ClientInterceptors, facts.methods, false) + facts.serverInterceptorFacts = collectInterceptorFacts(facts.serverInterceptors, facts.methods, facts.methodByExpr, true) + facts.clientInterceptorFacts = collectInterceptorFacts(facts.clientInterceptors, facts.methods, facts.methodByExpr, false) + return facts +} + +// retainServiceValueTypes records every named type reachable from one service +// value contract. External mappings use this set so stream and error values +// receive the same generated conversion ownership as payloads and results. +func retainServiceValueTypes(facts *serviceFacts, attribute *expr.AttributeExpr) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + err := codegen.Walk(attribute, func(attribute *expr.AttributeExpr) error { + if userType, ok := attribute.Type.(expr.UserType); ok { + facts.reachableTypes[userType.Origin()] = struct{}{} + } + return nil + }) + if err != nil { + panic(err) // the collector callback cannot return an error + } +} + +// collectInterceptorFacts fixes method applicability during planning so +// linking never walks service methods or interceptor expression lists again. +func collectInterceptorFacts(interceptors []*expr.InterceptorExpr, methods []*expr.MethodExpr, methodFacts map[*expr.MethodExpr]*methodFacts, server bool) []*interceptorFacts { + result := make([]*interceptorFacts, len(interceptors)) + for index, interceptor := range interceptors { + facts := &interceptorFacts{ + name: interceptor.Name, + description: interceptor.Description, + readPayload: interceptor.ReadPayload, + writePayload: interceptor.WritePayload, + readResult: interceptor.ReadResult, + writeResult: interceptor.WriteResult, + readStreamingPayload: interceptor.ReadStreamingPayload, + writeStreamingPayload: interceptor.WriteStreamingPayload, + readStreamingResult: interceptor.ReadStreamingResult, + writeStreamingResult: interceptor.WriteStreamingResult, + } + for _, method := range methods { + applied := method.ClientInterceptors + if server { + applied = method.ServerInterceptors + } + if interceptorNamed(applied, interceptor.Name) { + facts.methods = append(facts.methods, methodFacts[method]) + } + } + result[index] = facts + } + return result +} + +// retainMethodAttribute copies the top-level method contract and evaluates its +// example during collection. Nested type layout is retained separately by the +// generated Go type plan. +func retainMethodAttribute(attribute *expr.AttributeExpr, examples *expr.ExampleGenerator) *methodAttributeFacts { + if attribute == nil { + return nil + } + retained := *attribute + if attribute.Meta != nil { + retained.Meta = attribute.Meta.Dup() + } + return &methodAttributeFacts{ + attribute: &retained, + present: attribute.Type != expr.Empty, + isObject: expr.IsObject(attribute.Type), + location: codegen.UserTypeLocation(attribute.Type), + description: attribute.Description, + defaultValue: cloneRetainedValue(attribute.DefaultValue), + example: cloneRetainedValue(attribute.Example(examples)), + } +} + +// retainErrorRenderFacts copies the error text, type wrapper, location, and +// marker flags that generated constructors and client comments consume. +func retainErrorRenderFacts(errorExpression *expr.ErrorExpr) *errorRenderFacts { + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + attribute := *errorExpression.AttributeExpr + if errorExpression.Meta != nil { + attribute.Meta = errorExpression.Meta.Dup() + } + return &errorRenderFacts{ + attribute: &attribute, + name: errorExpression.Name, + description: errorExpression.Description, + location: codegen.UserTypeLocation(errorExpression.Type), + temporary: temporary, + timeout: timeout, + fault: fault, + serviceType: errorExpression.Type == expr.ErrorResult, + } +} + +// retainMethodSecurity evaluates scheme credential fields and scopes while +// the finalized method payload and requirements are still collection inputs. +func retainMethodSecurity(method *expr.MethodExpr) (RequirementsData, SchemesData) { + requirements := make(RequirementsData, 0, len(method.Requirements)) + var schemes SchemesData + for _, requirement := range expr.EffectiveSecurityRequirements(method.Requirements) { + var requirementSchemes SchemesData + for _, scheme := range requirement.Schemes { + data := cloneSchemeData(BuildSchemeData(scheme, method)) + requirementSchemes = requirementSchemes.Append(data) + schemes = schemes.Append(data) + } + requirements = append(requirements, &RequirementData{ + Schemes: requirementSchemes, + Scopes: append([]string(nil), requirement.Scopes...), + }) + } + return requirements, schemes +} + +// cloneSchemeData detaches the collection values retained in one scheme. +func cloneSchemeData(source *SchemeData) *SchemeData { + if source == nil { + return nil + } + cloned := *source + cloned.Scopes = append([]string(nil), source.Scopes...) + cloned.Flows = make([]*expr.FlowExpr, len(source.Flows)) + for index, flow := range source.Flows { + copy := *flow + cloned.Flows[index] = © + } + return &cloned +} + +// cloneRetainedValue copies the collection shapes accepted by Goa examples +// and defaults. Primitive values are immutable and may be shared. +func cloneRetainedValue(source any) any { + switch actual := source.(type) { + case expr.Val: + cloned := make(expr.Val, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case expr.ArrayVal: + cloned := make(expr.ArrayVal, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case expr.MapVal: + cloned := make(expr.MapVal, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + case []any: + cloned := make([]any, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + cloned := make(map[string]any, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case map[any]any: + cloned := make(map[any]any, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + default: + return actual + } +} + +// retainedInterceptors returns one stable, name-ordered interceptor set without +// sorting or appending into any expression-owned slice. +func retainedInterceptors(api, service []*expr.InterceptorExpr, methods []*expr.MethodExpr, server bool) []*expr.InterceptorExpr { + interceptors := append([]*expr.InterceptorExpr(nil), api...) + interceptors = append(interceptors, service...) + for _, method := range methods { + if server { + interceptors = append(interceptors, method.ServerInterceptors...) + } else { + interceptors = append(interceptors, method.ClientInterceptors...) + } + } + sort.Slice(interceptors, func(i, j int) bool { + return interceptors[i].Name < interceptors[j].Name + }) + result := interceptors[:0] + for _, interceptor := range interceptors { + if len(result) == 0 || result[len(result)-1].Name != interceptor.Name { + result = append(result, interceptor) + } + } + return result +} diff --git a/codegen/service/service_link.go b/codegen/service/service_link.go new file mode 100644 index 0000000000..a2b313270d --- /dev/null +++ b/codegen/service/service_link.go @@ -0,0 +1,423 @@ +// This file links retained service facts into immutable render data after names and import aliases freeze. +package service + +import ( + "fmt" + "path" + "slices" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// Link resolves the plan's collected facts through frozen declarations into +// the immutable template data consumed by renderers. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("service plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("service plan is already linked") + } + aliases, err := newImportAliases(p.facts.root, p.generation) + if err != nil { + return err + } + for _, facts := range p.facts.services { + linkServiceFileImports(facts, p.generation) + } + if err := linkExternalConversions(p.facts, p.generation, aliases); err != nil { + return err + } + services, err := linkServicesData(p.facts, p.generation, aliases) + if err != nil { + return err + } + p.services = services + return nil +} + +// Services returns the linked service render model. It panics before Link +// because no renderer or transport may observe provisional generated names. +func (p *Plan) Services() *ServicesData { + if p.services == nil { + panic("service render model requested before plan linking") + } + return p.services +} + +// analyze creates the data necessary to render the code of the given service. +// It records the user types needed by the service definition in userTypes. +func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { + var ( + types []*UserTypeData + errTypes []*UserTypeData + errorInits []*ErrorInitData + projTypes []*ProjectedTypeData + viewedRTs []*ViewedResultTypeData + ) + servicePackage := d.generation.Package(facts.packagePath) + scope := servicePackage.Scope() + viewScope := d.generation.Package( + facts.viewsPath, + ).Scope() + pkgName := codegen.Goify(path.Base(servicePackage.ImportPath()), false) + seenErrors := make(map[string]struct{}) + type viewedResultKey struct { + origin expr.UserType + view string + } + seenViewed := make(map[viewedResultKey]*ViewedResultTypeData) + seenViewedDeclarations := make(map[*codegen.TypeDeclaration]struct{}) + viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) + serviceResolver := newRetainedServiceResolver( + d.generation, + d.aliases, + facts.name, + facts.packagePath, + facts.packagePath, + ).withValidators(facts.validators) + types = formatUserTypeFacts(facts.userTypes, facts.packagePath, d.aliases) + errTypes = formatUserTypeFacts(facts.errorTypes, facts.packagePath, d.aliases) + + // recordError formats each selected ErrorResult constructor once. + recordError := func(errorFacts *errorRenderFacts) { + if errorFacts.serviceType { + if _, ok := seenErrors[errorFacts.name]; ok { + return + } + seenErrors[errorFacts.name] = struct{}{} + errorInits = append(errorInits, buildRetainedErrorInitData( + errorFacts, + serviceResolver, + facts.errorConstructors[errorFacts.name], + )) + } + } + for _, errorFacts := range facts.errorFacts { + recordError(errorFacts) + } + + for _, method := range facts.orderedMethods { + // Collect projected types + if projection := method.projection; projection != nil { + views := d.generation.Package(facts.viewsPath) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + viewDerived[pair.projected.Origin()] = identity + } + viewResolver := newRetainedViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + declaration, err := views.DerivedType(identity) + if err != nil { + return nil, err + } + projectedType := buildProjectedType( + projectedFacts, + serviceResolver, + viewResolver, + declaration, + ) + projTypes = append(projTypes, projectedType) + } + } + for _, errorFacts := range method.errors { + recordError(errorFacts) + } + } + viewUnions, err := d.formatViewUnions(facts) + if err != nil { + return nil, err + } + + var ( + methods []*MethodData + schemes SchemesData + ) + methods = make([]*MethodData, len(facts.orderedMethods)) + methodDataByFacts := make(map[*methodFacts]*MethodData, len(facts.orderedMethods)) + for i, method := range facts.orderedMethods { + m, err := d.buildMethodData(method, serviceResolver, facts) + if err != nil { + return nil, err + } + methods[i] = m + methodDataByFacts[method] = m + for _, s := range m.Schemes { + schemes = schemes.Append(s) + } + viewedFacts := method.viewedResult + if viewedFacts == nil { + continue + } + key := viewedResultKey{origin: viewedFacts.origin, view: viewedFacts.viewName} + if vrt, ok := seenViewed[key]; ok { + m.ViewedResult = vrt + continue + } + vrt := buildViewedResultType( + viewedFacts, + d.aliases.spec(facts.viewsPath).Name, + serviceResolver, + newRetainedViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators), + viewedFacts.declaration, + ) + if _, found := seenViewedDeclarations[viewedFacts.declaration]; !found { + viewedRTs = append(viewedRTs, vrt) + seenViewedDeclarations[viewedFacts.declaration] = struct{}{} + } + m.ViewedResult = vrt + seenViewed[key] = vrt + } + + unions, err := d.formatServiceUnions(facts) + if err != nil { + return nil, err + } + + desc := facts.description + if desc == "" { + desc = fmt.Sprintf("Service is the %s service interface.", facts.name) + } + + varName := codegen.Goify(facts.name, false) + data := &Data{ + ServiceDeclaration: facts.names.declaration(serviceSymbolID{role: serviceInterfaceNameRole, service: facts.name}), + AutherDeclaration: facts.names[serviceSymbolID{role: serviceAutherNameRole, service: facts.name}].declaration, + APINameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPINameRole, service: facts.name}), + APIVersionDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPIVersionNameRole, service: facts.name}), + ServiceNameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNameConstantRole, service: facts.name}), + MethodNamesDeclaration: facts.names.declaration(serviceSymbolID{role: serviceMethodNamesRole, service: facts.name}), + EndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceEndpointsNameRole, service: facts.name}), + NewEndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewEndpointsNameRole, service: facts.name}), + ClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceClientNameRole, service: facts.name}), + NewClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewClientNameRole, service: facts.name}), + StreamDeclaration: facts.names[serviceSymbolID{role: serviceStreamNameRole, service: facts.name}].declaration, + EventDeclaration: facts.names[serviceSymbolID{role: serviceEventNameRole, service: facts.name}].declaration, + ServerInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceServerInterceptorsNameRole, service: facts.name, + }].declaration, + ClientInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceClientInterceptorsNameRole, service: facts.name, + }].declaration, + ExampleStructDeclaration: facts.exampleStruct, + ExampleConstructorDeclaration: facts.exampleConstructor, + Name: facts.name, + Description: desc, + APIName: d.facts.apiName, + APIVersion: d.facts.apiVersion, + VarName: varName, + PathName: codegen.SnakeCase(varName), + StructName: codegen.Goify(facts.name, true), + PkgName: pkgName, + Methods: methods, + Schemes: schemes, + ServerInterceptors: d.collectInterceptors(facts, facts.serverInterceptorFacts, methodDataByFacts, serviceResolver, true), + ClientInterceptors: d.collectInterceptors(facts, facts.clientInterceptorFacts, methodDataByFacts, serviceResolver, false), + Scope: scope, + ViewScope: viewScope, + errorTypes: errTypes, + errorInits: errorInits, + userTypes: types, + projectedTypes: projTypes, + viewedResultTypes: viewedRTs, + unions: unions, + viewUnions: viewUnions, + viewDerived: viewDerived, + } + return data, nil +} + +// collectInterceptors returns the set of interceptors defined on the given +// service including any interceptor defined on specific service methods or API. +func (d *ServicesData) collectInterceptors(service *serviceFacts, facts []*interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) []*InterceptorData { + res := make([]*InterceptorData, 0, len(facts)) + for _, interceptor := range facts { + res = append(res, buildInterceptorData(service, interceptor, methods, resolver, server)) + } + return res +} + +// declarationContext configures transformations and validations to resolve +// every named service or view type through its planned package declaration. +func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.AttributeContext { + return &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: true, + Scope: resolver, + } +} + +// formatUserTypeFacts resolves the final names and definitions of types whose +// reachability and declaration ownership were fixed during collection. +func formatUserTypeFacts(facts []*userTypeFacts, outputPath string, aliases *importAliases) []*UserTypeData { + data := make([]*UserTypeData, len(facts)) + for index, facts := range facts { + description := facts.description + if description == "" && facts.location != nil { + description = fmt.Sprintf("%s is a generated service type.", facts.declaration.Name()) + } + definition := facts.layout.Link( + facts.declaration.PackagePath(), + retainedTypeQualifier(aliases), + ) + reference := facts.reference.Link( + outputPath, + retainedTypeQualifier(aliases), + ) + data[index] = &UserTypeData{ + Declaration: facts.declaration, + Name: facts.name, + VarName: facts.declaration.Name(), + Description: description, + ErrorName: facts.errorName, + IsServiceError: facts.serviceError, + Def: definition.Def(), + Ref: reference.Ref(), + Loc: facts.location, + Type: facts.userType, + } + } + return data +} + +// formatServiceUnions resolves the exact service union declarations retained +// during collection and registers one render record per generated package. +func (d *ServicesData) formatServiceUnions(facts *serviceFacts) ([]*UnionTypeData, error) { + unions := make([]*UnionTypeData, 0, len(facts.unions)) + for _, facts := range facts.unions { + union := buildRetainedUnionTypeData(facts, d.aliases) + facts.data = union + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + if unions[i].Name != unions[j].Name { + return unions[i].Name < unions[j].Name + } + var left, right string + if unions[i].Loc != nil { + left = unions[i].Loc.RelImportPath + } + if unions[j].Loc != nil { + right = unions[j].Loc.RelImportPath + } + return left < right + }) + return unions, nil +} + +// formatViewUnions resolves the exact view union expressions retained while +// their declarations were collected. It does not traverse projected types. +func (d *ServicesData) formatViewUnions(facts *serviceFacts) ([]*UnionTypeData, error) { + unions := make([]*UnionTypeData, len(facts.viewUnions)) + for index, union := range facts.viewUnions { + unions[index] = buildRetainedUnionTypeData(union, d.aliases) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + return unions, nil +} + +// buildRetainedUnionTypeData formats one union from the branch declarations +// and Go layouts selected before the generation froze. +func buildRetainedUnionTypeData(facts *unionFacts, aliases *importAliases) *UnionTypeData { + fields := make([]*UnionFieldData, len(facts.branches)) + for index, branch := range facts.branches { + fields[index] = &UnionFieldData{ + Name: branch.name, + KindConst: branch.declaration.KindConst(), + Constructor: branch.declaration.Constructor(), + FieldName: branch.fieldName, + FieldType: branch.layout.Link(facts.declaration.PackagePath(), retainedTypeQualifier(aliases)).Ref(), + Nilable: branch.nilable, + EmitPrimitiveAlias: branch.emitPrimitiveAlias, + PrimitiveAliasType: branch.primitiveAliasType, + TypeTag: branch.name, + } + } + return &UnionTypeData{ + Declaration: facts.declaration, + Name: facts.declaration.Name(), + KindName: facts.declaration.KindName(), + Fields: fields, + Loc: facts.location, + TypeKey: facts.typeKey, + ValueKey: facts.valueKey, + } +} + +// sortedNamedAttributes returns object fields sorted by attribute name. +// Union naming uses NameScope uniqueness, so callers that discover unions while +// traversing objects must use a deterministic field order to avoid oscillating +// generated identifiers across runs. +func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + if len(attrs) < 2 { + return attrs + } + sorted := slices.Clone(attrs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Name < sorted[j].Name + }) + return sorted +} + +// primitiveAliasGoType resolves the native Go type for a primitive alias branch. +// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. +func primitiveAliasGoType(dt expr.DataType) (string, bool) { + if !expr.IsPrimitive(dt) { + return "", false + } + for { + ut, ok := dt.(expr.UserType) + if !ok { + return codegen.GoNativeTypeName(dt), true + } + dt = ut.Attribute().Type + } +} + +// buildRetainedErrorInitData formats an error selected during collection +// without consulting the mutable design expression. +func buildRetainedErrorInitData(facts *errorRenderFacts, resolver *declarationResolver, declaration *codegen.NameDeclaration) *ErrorInitData { + if facts.layout == nil { + panic(fmt.Sprintf("retained error %q has no Go type layout", facts.name)) + } + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + return &ErrorInitData{ + Declaration: declaration, + Description: facts.description, + ErrName: facts.name, + TypeName: linked.Name(), + TypeRef: linked.Ref(), + Temporary: facts.temporary, + Timeout: facts.timeout, + Fault: facts.fault, + } +} + +// retainedTypeQualifier returns the frozen qualifier assigned to one retained +// Go type import. +func retainedTypeQualifier(aliases *importAliases) codegen.GoTypeQualifier { + return func(importPath string) string { + return aliases.name(importPath) + } +} diff --git a/codegen/service/service_name_collision_contract_test.go b/codegen/service/service_name_collision_contract_test.go new file mode 100644 index 0000000000..2cefd51d7b --- /dev/null +++ b/codegen/service/service_name_collision_contract_test.go @@ -0,0 +1,152 @@ +// This file verifies every core service package symbol participates in the +// single Go package namespace with authored types and other generated symbols. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type serviceCollisionResult struct { + authored string + subject string + competitor string +} + +// TestEveryServiceNameRoleSharesOnePackageNamespace catches a service symbol +// family that bypasses exact authored types or uses discovery order to choose +// collision suffixes. +func TestEveryServiceNameRoleSharesOnePackageNamespace(t *testing.T) { + roles := []serviceNameRole{ + serviceInterfaceNameRole, + serviceAutherNameRole, + serviceAPINameRole, + serviceAPIVersionNameRole, + serviceNameConstantRole, + serviceMethodNamesRole, + serviceMethodEventNameRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceStreamNameRole, + serviceEventNameRole, + serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceViewMapNameRole, + serviceEndpointsNameRole, + serviceNewEndpointsNameRole, + serviceClientNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsStructNameRole, + serviceExampleClientInterceptorsConstructorNameRole, + } + + for _, role := range roles { + t.Run(fmt.Sprintf("role-%d", role), func(t *testing.T) { + forward := serviceCollisionNames(t, role, false) + reverse := serviceCollisionNames(t, role, true) + wantGenerated := []string{"Symbol2", "Symbol3"} + if role.visibility() == codegen.UnexportedName { + wantGenerated = []string{"symbol", "symbol2"} + } + + require.Equal(t, forward, reverse) + require.Equal(t, "Symbol", forward.authored) + require.ElementsMatch(t, wantGenerated, []string{ + forward.subject, + forward.competitor, + }) + }) + } +} + +// serviceCollisionNames declares one role and an unrelated generated function +// in the requested order, then returns their frozen names. +func serviceCollisionNames(t *testing.T, role serviceNameRole, reverse bool) serviceCollisionResult { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Symbol", + UID: fmt.Sprintf("authored-symbol-%d", role), + } + authoredDeclaration, err := generatedPackage.DeclareUserType(authored) + require.NoError(t, err) + + competitorRole := serviceErrorConstructorNameRole + if role.visibility() == codegen.UnexportedName { + competitorRole = servicePrivateProjectionConstructorNameRole + if role == competitorRole { + competitorRole = serviceTransformHelperNameRole + } + } else if role == competitorRole { + competitorRole = serviceValidatorNameRole + } + subjectID := serviceSymbolID{ + role: role, + service: "calc", + subject: "subject", + } + competitorID := serviceSymbolID{ + role: competitorRole, + service: "calc", + subject: "competitor", + } + names := make(serviceNames) + declare := func(id serviceSymbolID) *codegen.NameDeclaration { + declaration, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + repeated, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + require.Same(t, declaration, repeated) + return declaration + } + var subject, competitor *codegen.NameDeclaration + if reverse { + competitor = declare(competitorID) + subject = declare(subjectID) + } else { + subject = declare(subjectID) + competitor = declare(competitorID) + } + require.NoError(t, generation.Freeze()) + + return serviceCollisionResult{ + authored: authoredDeclaration.Name(), + subject: subject.Name(), + competitor: competitor.Name(), + } +} diff --git a/codegen/service/service_names.go b/codegen/service/service_names.go new file mode 100644 index 0000000000..400c7683a3 --- /dev/null +++ b/codegen/service/service_names.go @@ -0,0 +1,323 @@ +// This file defines stable identities for every package-level declaration +// emitted by the core service and views generators. Retained service plans +// declare these records before generation freeze and render their final names +// from the same records afterward. +package service + +import ( + "cmp" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceNameRole identifies one closed family of package-level declarations + // emitted by the core service and views generators. + serviceNameRole uint8 + + // serviceNameOrder contains only stable semantic values, giving colliding + // service declarations a deterministic total order across traversals. + serviceNameOrder struct { + role serviceNameRole + service string + method string + subject string + view string + source string + target string + side string + occurrence int + required bool + } + + // serviceSymbolID identifies one package declaration without using its + // provisional Go spelling. Source and target distinguish transform helpers; + // subject and view distinguish constructors and validators. + serviceSymbolID serviceNameOrder + + // serviceName retains the preferred spelling with its canonical declaration + // so repeated collection cannot silently rename one semantic symbol. + serviceName struct { + preferred string + base *codegen.NameDeclaration + prefix string + suffix string + declaration *codegen.NameDeclaration + } + + // serviceNames owns the core declarations collected for one retained service + // plan. The declaration itself remains owned by its generated Go package. + serviceNames map[serviceSymbolID]serviceName +) + +const ( + serviceInterfaceNameRole serviceNameRole = iota + 1 + serviceAutherNameRole + serviceAPINameRole + serviceAPIVersionNameRole + serviceNameConstantRole + serviceMethodNamesRole + serviceMethodEventNameRole + serviceServerStreamNameRole + serviceClientStreamNameRole + serviceStreamNameRole + serviceEventNameRole + serviceErrorConstructorNameRole + serviceViewConstructorNameRole + servicePrivateProjectionConstructorNameRole + serviceValidatorNameRole + serviceViewMapNameRole + serviceEndpointsNameRole + serviceNewEndpointsNameRole + serviceClientNameRole + serviceNewClientNameRole + serviceMethodEndpointNameRole + serviceEndpointInputNameRole + serviceRequestNameRole + serviceResponseNameRole + serviceServerInterceptorsNameRole + serviceClientInterceptorsNameRole + serviceInterceptorInfoNameRole + serviceInterceptorPayloadNameRole + serviceInterceptorResultNameRole + serviceInterceptorStreamingPayloadNameRole + serviceInterceptorStreamingResultNameRole + serviceInterceptorPayloadAccessNameRole + serviceInterceptorResultAccessNameRole + serviceInterceptorStreamingPayloadAccessNameRole + serviceInterceptorStreamingResultAccessNameRole + serviceServerEndpointWrapperNameRole + serviceClientEndpointWrapperNameRole + serviceServerInterceptorWrapperNameRole + serviceClientInterceptorWrapperNameRole + serviceServerStreamWrapperNameRole + serviceClientStreamWrapperNameRole + serviceTransformHelperNameRole + serviceExampleStructNameRole + serviceExampleConstructorNameRole + serviceExampleServerInterceptorsStructNameRole + serviceExampleServerInterceptorsConstructorNameRole + serviceExampleClientInterceptorsStructNameRole + serviceExampleClientInterceptorsConstructorNameRole +) + +// ComparePackageName orders declarations from the core service generator by +// their complete stable semantic identity. +func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(serviceNameOrder) + if compared := cmp.Compare(o.role, right.role); compared != 0 { + return compared + } + if compared := cmp.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := cmp.Compare(o.method, right.method); compared != 0 { + return compared + } + if compared := cmp.Compare(o.subject, right.subject); compared != 0 { + return compared + } + if compared := cmp.Compare(o.view, right.view); compared != 0 { + return compared + } + if compared := cmp.Compare(o.source, right.source); compared != 0 { + return compared + } + if compared := cmp.Compare(o.target, right.target); compared != 0 { + return compared + } + if compared := cmp.Compare(o.side, right.side); compared != 0 { + return compared + } + if compared := cmp.Compare(o.occurrence, right.occurrence); compared != 0 { + return compared + } + if o.required == right.required { + return 0 + } + if !o.required { + return -1 + } + return 1 +} + +// kind returns the package declaration category fixed by this service symbol +// family. An unknown role is an internal planner bug. +func (r serviceNameRole) kind() codegen.PackageNameKind { + switch r { + case serviceAPINameRole, serviceAPIVersionNameRole, serviceNameConstantRole: + return codegen.NameConstant + case serviceMethodNamesRole, serviceViewMapNameRole: + return codegen.NameVariable + case serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceNewEndpointsNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsConstructorNameRole: + return codegen.NameFunction + case serviceInterfaceNameRole, + serviceAutherNameRole, + serviceMethodEventNameRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceStreamNameRole, + serviceEventNameRole, + serviceEndpointsNameRole, + serviceClientNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceExampleStructNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleClientInterceptorsStructNameRole: + return codegen.NameType + default: + panic(fmt.Sprintf("unknown service package name role %d", r)) + } +} + +// visibility reports whether the emitted declaration is part of the generated +// package API or an implementation detail used only by neighboring sections. +func (r serviceNameRole) visibility() codegen.PackageNameVisibility { + switch r { + case servicePrivateProjectionConstructorNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole: + return codegen.UnexportedName + default: + return codegen.ExportedName + } +} + +// declare records id in pkg and returns the same canonical declaration when a +// planning traversal encounters that exact semantic symbol again. +func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != nil || existing.preferred != preferred { + return nil, fmt.Errorf( + "service symbol role %d cannot declare both %q and %q", + id.role, + existing.preferred, + preferred, + ) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + declaration := codegen.NewPreferredName( + id.role.kind(), + preferred, + id.role.visibility(), + serviceNameOrder(id), + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + n[id] = serviceName{preferred: preferred, declaration: declaration} + return declaration, nil +} + +// declareDependent records a companion whose preferred spelling follows the +// exact final name of base. Repeated collection must use the same base record +// and affixes, so one semantic symbol cannot silently change families. +func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != base || existing.prefix != prefix || existing.suffix != suffix { + return nil, fmt.Errorf("service symbol role %d cannot change its dependent declaration family", id.role) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + declaration, err := pkg.DeclareDependentName( + id.role.kind(), + base, + prefix, + suffix, + serviceNameOrder(id), + ) + if err != nil { + return nil, err + } + n[id] = serviceName{ + base: base, + prefix: prefix, + suffix: suffix, + declaration: declaration, + } + return declaration, nil +} + +// declaration returns the canonical record for id. Calling it for a symbol +// that collection did not declare is an internal retained-plan bug. +func (n serviceNames) declaration(id serviceSymbolID) *codegen.NameDeclaration { + name, ok := n[id] + if !ok { + panic(fmt.Sprintf("service symbol role %d was not declared", id.role)) + } + return name.declaration +} + +// transformDataTypeIdentity returns the authored declaration identity used by +// TransformPlan when the operation crosses copied named attributes. +func transformDataTypeIdentity(dataType expr.DataType) expr.DataType { + if userType, ok := dataType.(expr.UserType); ok { + return userType.Origin() + } + return dataType +} + +// transformDataTypeName returns stable semantic labels for one helper side. +func transformDataTypeName(dataType expr.DataType) (string, string) { + if userType, ok := dataType.(expr.UserType); ok { + return userType.Name(), userType.ID() + } + return dataType.Name(), "" +} + +// canonicalValidatorView gives a default result view the same identity used by +// validation calls that omit an explicit view. +func canonicalValidatorView(view string) string { + if view == expr.DefaultView { + return "" + } + return view +} diff --git a/codegen/service/service_names_test.go b/codegen/service/service_names_test.go new file mode 100644 index 0000000000..ecccaaebee --- /dev/null +++ b/codegen/service/service_names_test.go @@ -0,0 +1,189 @@ +// This file verifies the typed package-level declaration identities used by +// retained service plans before any generated source is rendered. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestServiceNamesAreIndependentOfDiscoveryOrder verifies that stable semantic +// identities, rather than traversal order, decide collision suffixes. +func TestServiceNamesAreIndependentOfDiscoveryOrder(t *testing.T) { + ids := []serviceSymbolID{ + {role: serviceValidatorNameRole, service: "calc", subject: "Result"}, + {role: serviceErrorConstructorNameRole, service: "calc", subject: "Result"}, + {role: serviceMethodEndpointNameRole, service: "calc", method: "add"}, + } + + generate := func(order []int) map[serviceSymbolID]string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + for _, index := range order { + _, err := names.declare(pkg, ids[index], "Build") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + result := make(map[serviceSymbolID]string, len(ids)) + for _, id := range ids { + result[id] = names.declaration(id).Name() + } + return result + } + + require.Equal(t, generate([]int{0, 1, 2}), generate([]int{2, 0, 1})) +} + +// TestServiceNamesShareTheAuthoredPackageNamespace verifies that generated +// functions collide with exact authored types and with one another in the one +// namespace enforced by the Go compiler. +func TestServiceNamesShareTheAuthoredPackageNamespace(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "ValidateResult", + UID: "authored-validate-result", + } + authoredDeclaration, err := pkg.DeclareUserType(authored) + require.NoError(t, err) + + names := make(serviceNames) + validator, err := names.declare(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + constructor, err := names.declare(pkg, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValidateResult", authoredDeclaration.Name()) + require.Equal(t, "ValidateResult2", constructor.Name()) + require.Equal(t, "ValidateResult3", validator.Name()) +} + +// TestServiceNamesOwnOneCanonicalDeclaration verifies that rebuilding an exact +// semantic identity returns the original record and rejects changed spelling +// or ownership. +func TestServiceNamesOwnOneCanonicalDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + viewsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + names := make(serviceNames) + id := serviceSymbolID{role: serviceViewConstructorNameRole, service: "calc", subject: "Result"} + + first, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + second, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = names.declare(servicePackage, id, "NewResultView") + require.ErrorContains(t, err, "cannot declare both") + _, err = names.declare(viewsPackage, id, "NewViewedResult") + require.ErrorContains(t, err, "already belongs") +} + +// TestServiceNamesDeriveCompanionsFromFrozenTypes verifies validators follow +// the exact projected type declaration when that type receives a suffix. +func TestServiceNamesDeriveCompanionsFromFrozenTypes(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameType, "Result"))) + base := codegen.NewPreferredName(codegen.NameType, "Result", codegen.ExportedName, serviceNameOrder{ + role: serviceInterfaceNameRole, + service: "calc", + subject: "result", + }) + require.NoError(t, pkg.DeclareName(base)) + names := make(serviceNames) + validator, err := names.declareDependent(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "result", + }, base, "Validate", "") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + +// TestServiceNameRolesOwnDeclarationKinds verifies that the closed service +// symbol family, not an individual caller, selects each Go declaration kind. +func TestServiceNameRolesOwnDeclarationKinds(t *testing.T) { + tests := []struct { + role serviceNameRole + kind codegen.PackageNameKind + }{ + {serviceInterfaceNameRole, codegen.NameType}, + {serviceAutherNameRole, codegen.NameType}, + {serviceAPINameRole, codegen.NameConstant}, + {serviceAPIVersionNameRole, codegen.NameConstant}, + {serviceNameConstantRole, codegen.NameConstant}, + {serviceMethodNamesRole, codegen.NameVariable}, + {serviceMethodEventNameRole, codegen.NameType}, + {serviceServerStreamNameRole, codegen.NameType}, + {serviceClientStreamNameRole, codegen.NameType}, + {serviceStreamNameRole, codegen.NameType}, + {serviceEventNameRole, codegen.NameType}, + {serviceErrorConstructorNameRole, codegen.NameFunction}, + {serviceViewConstructorNameRole, codegen.NameFunction}, + {servicePrivateProjectionConstructorNameRole, codegen.NameFunction}, + {serviceValidatorNameRole, codegen.NameFunction}, + {serviceViewMapNameRole, codegen.NameVariable}, + {serviceEndpointsNameRole, codegen.NameType}, + {serviceNewEndpointsNameRole, codegen.NameFunction}, + {serviceClientNameRole, codegen.NameType}, + {serviceNewClientNameRole, codegen.NameFunction}, + {serviceMethodEndpointNameRole, codegen.NameFunction}, + {serviceEndpointInputNameRole, codegen.NameType}, + {serviceRequestNameRole, codegen.NameType}, + {serviceResponseNameRole, codegen.NameType}, + {serviceServerInterceptorsNameRole, codegen.NameType}, + {serviceClientInterceptorsNameRole, codegen.NameType}, + {serviceInterceptorInfoNameRole, codegen.NameType}, + {serviceInterceptorPayloadNameRole, codegen.NameType}, + {serviceInterceptorResultNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultNameRole, codegen.NameType}, + {serviceInterceptorPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorResultAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultAccessNameRole, codegen.NameType}, + {serviceServerEndpointWrapperNameRole, codegen.NameFunction}, + {serviceClientEndpointWrapperNameRole, codegen.NameFunction}, + {serviceServerInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceClientInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceServerStreamWrapperNameRole, codegen.NameType}, + {serviceClientStreamWrapperNameRole, codegen.NameType}, + {serviceTransformHelperNameRole, codegen.NameFunction}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("role-%d", test.role), func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + declaration, err := names.declare(pkg, serviceSymbolID{ + role: test.role, + service: "calc", + subject: "result", + }, "Symbol") + require.NoError(t, err) + require.Equal(t, test.kind, declaration.Kind()) + }) + } +} diff --git a/codegen/service/service_plan_compile_contract_test.go b/codegen/service/service_plan_compile_contract_test.go new file mode 100644 index 0000000000..58dd77e7e2 --- /dev/null +++ b/codegen/service/service_plan_compile_contract_test.go @@ -0,0 +1,144 @@ +// This file compiles generated service, views, and starter implementation +// packages for nested validation collisions shaped like AURA tool contracts. +package service + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePackageNameUsesClaimedImportPath verifies mixed-case service +// names keep the canonical Go casing derived from their claimed package path. +func TestServicePackageNameUsesClaimedImportPath(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("UnionValidation", func() { + dsl.Method("Read", func() {}) + }) + }) + plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + require.Equal(t, "unionValidation", plan.Services().Get("UnionValidation").PkgName) +} + +// TestNestedViewValidatorCollisionCompiles catches a parent validator that +// reconstructs its child's preferred name after the child function was +// suffixed by another projected declaration in the views package. +func TestNestedViewValidatorCollisionCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("name") + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("children", dsl.CollectionOf(child)) + dsl.Attribute("validator_name_collision", collision) + dsl.Required("child", "children", "validator_name_collision") + dsl.View("default", func() { + dsl.Attribute("child") + dsl.Attribute("children") + dsl.Attribute("validator_name_collision") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + data := plan.Services().Get("Values") + var childValidation, parentValidation *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + childValidation = validation + case "ParentView": + parentValidation = validation + } + } + } + require.NotNil(t, childValidation) + require.NotNil(t, parentValidation) + require.NotEmpty(t, parentValidation.Calls) + require.Same(t, childValidation.Declaration, parentValidation.Calls[0].Declaration) + require.Equal(t, "ValidateChildView2", childValidation.Declaration.Name()) + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, "generated.local", files) +} + +// retainedServicePlanForPackage runs the service lifecycle with the generated +// import root used by the temporary compilation module. +func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr, generatedPackage string) *Plan { + t.Helper() + generation, err := codegen.NewGeneration(generatedPackage, []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + return plan +} + +// compileGeneratedServiceFiles renders files into a temporary module and runs +// the Go compiler against every generated package. +func compileGeneratedServiceFiles(t *testing.T, modulePath string, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + goaRoot := serviceModuleDirectory(t, "goa.design/goa/v3") + module := "module " + modulePath + "\n\ngo 1.24\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated packages:\n%s", output) +} + +// serviceModuleDirectory resolves the local checkout for a module used by a +// temporary generated module. +func serviceModuleDirectory(t *testing.T, module string) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve module %s:\n%s", module, output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} diff --git a/codegen/service/service_plan_render_contract_test.go b/codegen/service/service_plan_render_contract_test.go new file mode 100644 index 0000000000..14d81dc2c4 --- /dev/null +++ b/codegen/service/service_plan_render_contract_test.go @@ -0,0 +1,223 @@ +// This file verifies retained service plans aggregate shared packages +// deterministically and render without changing their declaration catalogs. +package service + +import ( + "bytes" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestFilesRejectPlansFromDifferentGenerations verifies that aggregation cannot +// render declarations through a package catalog that did not plan them. +func TestFilesRejectPlansFromDifferentGenerations(t *testing.T) { + first := mustTestGeneration(t, "example.com/first/gen", nil) + second := mustTestGeneration(t, "example.com/second/gen", nil) + + _, err := Files(&Plan{generation: first}, &Plan{generation: second}) + require.ErrorContains(t, err, "different generations") +} + +type retainedServiceNameID struct { + root int + service string + symbol serviceSymbolID +} + +// TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder catches +// shared-package names or section order that depend on discovery order. +func TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder(t *testing.T) { + forwardPlans := orderedServicePlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + forward := renderedServiceFiles(t, forwardFiles) + + reversePlans := orderedServicePlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + reverse := renderedServiceFiles(t, reverseFiles) + + requireRenderedServiceFilesEqual(t, forward, reverse) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "alpha_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "beta_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "omega_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "unions.go")) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + for _, plan := range forwardPlans { + compileFiles = append(compileFiles, ExampleServiceFiles(plan)...) + } + compileGeneratedServiceFiles(t, "generated.local", compileFiles) +} + +// TestServicePlanRenderingIsPure catches renderers that rebuild analysis, +// replace retained declaration records, or append sections on a second read. +func TestServicePlanRenderingIsPure(t *testing.T) { + plans := orderedServicePlans(t, false) + before := retainedServiceNamePointers(plans) + firstFiles, err := Files(plans...) + require.NoError(t, err) + first := renderedServiceFiles(t, firstFiles) + + secondFiles, err := Files(plans...) + require.NoError(t, err) + second := renderedServiceFiles(t, secondFiles) + after := retainedServiceNamePointers(plans) + + require.Equal(t, first, second) + require.Len(t, after, len(before)) + for id, declaration := range before { + require.Same(t, declaration, after[id], "service symbol changed: %+v", id) + } +} + +// requireRenderedServiceFile reports a missing shared-package contribution +// without printing the complete generated output map. +func requireRenderedServiceFile(t *testing.T, files map[string][]byte, path string) { + t.Helper() + _, exists := files[path] + require.True(t, exists, "missing generated file %s", path) +} + +// requireRenderedServiceFilesEqual compares the same sorted output paths one +// at a time so an order-dependent package reports the precise changed file. +func requireRenderedServiceFilesEqual(t *testing.T, expected, actual map[string][]byte) { + t.Helper() + expectedPaths := make([]string, 0, len(expected)) + actualPaths := make([]string, 0, len(actual)) + for path := range expected { + expectedPaths = append(expectedPaths, path) + } + for path := range actual { + actualPaths = append(actualPaths, path) + } + sort.Strings(expectedPaths) + sort.Strings(actualPaths) + require.Equal(t, expectedPaths, actualPaths) + for _, path := range expectedPaths { + require.Equal(t, string(expected[path]), string(actual[path]), path) + } +} + +// orderedServicePlans builds equivalent fresh designs with both root and +// service discovery reversed, then runs collection, freeze, and link once. +func orderedServicePlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + first := orderedServiceRoot(t, reverse) + second := singleServiceRoot(t) + roots := []*expr.RootExpr{first, second} + if reverse { + roots[0], roots[1] = roots[1], roots[0] + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) + require.NoError(t, err) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// orderedServiceRoot defines two services that emit distinct same-base unions +// into one relocated package. +func orderedServiceRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := relocatedOrderType("AlphaEnvelope", "text", dsl.String) + omega := relocatedOrderType("OmegaEnvelope", "count", dsl.Int) + alphaService := func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { dsl.Payload(alpha) }) + }) + } + omegaService := func() { + dsl.Service("Omega", func() { + dsl.Method("Read", func() { dsl.Payload(omega) }) + }) + } + if reverse { + omegaService() + alphaService() + } else { + alphaService() + omegaService() + } + }) +} + +// singleServiceRoot defines the second root contributing to the same +// relocated package used by orderedServiceRoot. +func singleServiceRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + beta := relocatedOrderType("BetaEnvelope", "enabled", dsl.Boolean) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { dsl.Payload(beta) }) + }) + }) +} + +// relocatedOrderType creates one force-generated type with a Value union in +// the shared generated types package. +func relocatedOrderType(name, branch string, dataType expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute(branch, dataType) + }) + }) +} + +// renderedServiceFiles executes every retained section and indexes the exact +// bytes by output path, rejecting duplicate contributions. +func renderedServiceFiles(t *testing.T, files []*codegen.File) map[string][]byte { + t.Helper() + rendered := make(map[string][]byte, len(files)) + for _, file := range files { + _, duplicate := rendered[file.Path] + require.False(t, duplicate, "duplicate generated file %s", file.Path) + var buffer bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&buffer)) + } + rendered[file.Path] = bytes.Clone(buffer.Bytes()) + } + return rendered +} + +// retainedServiceNamePointers snapshots the exact declaration records held by +// each retained plan so rendering cannot replace them unnoticed. +func retainedServiceNamePointers(plans []*Plan) map[retainedServiceNameID]*codegen.NameDeclaration { + pointers := make(map[retainedServiceNameID]*codegen.NameDeclaration) + for rootIndex, plan := range plans { + for _, facts := range plan.facts.services { + for id, name := range facts.names { + pointers[retainedServiceNameID{ + root: rootIndex, + service: facts.service.Name, + symbol: id, + }] = name.declaration + } + } + } + return pointers +} diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 356d446bf5..c266c0ec68 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -6,6 +6,7 @@ import ( "bytes" "go/format" "path/filepath" + "slices" "strings" "testing" @@ -47,13 +48,12 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { }) generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) - require.Panics(t, func() { - _, _ = NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - }) - require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) + require.Panics(t, func() { plan.Services() }) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() first := services.Get("First") second := services.Get("Second") @@ -93,7 +93,7 @@ func TestPlanOwnsNormalizedMethodNames(t *testing.T) { }) }) generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + require.NoError(t, planTestServices(root, generation)) require.NoError(t, generation.Freeze()) service := root.Service("Values") @@ -134,7 +134,7 @@ func TestPlanPreservesGeneratedPackageClaims(t *testing.T) { }) generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - err := Plan(root, generation) + err := planTestServices(root, generation) require.ErrorContains(t, err, test.contains) }) } @@ -168,7 +168,7 @@ func TestPlanRejectsInvalidGeneratedPackageLocations(t *testing.T) { }) generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.Error(t, Plan(root, generation)) + require.Error(t, planTestServices(root, generation)) }) } } @@ -187,7 +187,7 @@ func TestPlanIgnoresUnusedRelocatedTypes(t *testing.T) { }) generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + require.NoError(t, planTestServices(root, generation)) require.NoError(t, generation.Freeze()) } @@ -203,9 +203,9 @@ func TestFilesUseCanonicalOwnedOutputDirectory(t *testing.T) { dsl.Method("Read", func() { dsl.Payload(value) }) }) }) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) - require.NotNil(t, findFile(Files("goa.design/goa/example", []*ServicesData{services}), + require.NotNil(t, findFile(mustServiceFiles(t, plan), filepath.Join("gen", "types", "value.go"))) } @@ -229,16 +229,16 @@ func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { }) generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) views := mustClaimTestPackage(t, generation, "goa.design/goa/example/values/views") plannedProjected, err := views.DerivedType(codegen.NewProjectedTypeID(result)) require.NoError(t, err) plannedViewed, err := views.DerivedType(codegen.NewViewedResultTypeID(result)) require.NoError(t, err) require.NoError(t, generation.Freeze()) - - services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, plan.Link()) + services := plan.Services() service := services.Get("Values") require.Len(t, service.projectedTypes, 1) require.Len(t, service.viewedResultTypes, 1) @@ -250,8 +250,7 @@ func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) - services := mustServicesData(t, root) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, mustServicePlan(t, root)) require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first_value.go"))) require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second_value.go"))) @@ -291,7 +290,7 @@ func TestFilesEmitsDifferentSameBaseUnionsWithFrozenNames(t *testing.T) { }) }) - files := Files("goa.design/goa/example", []*ServicesData{mustServicesData(t, root)}) + files := mustServiceFiles(t, mustServicePlan(t, root)) unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) require.NotNil(t, unionFile) code := renderSections(t, unionFile.SectionTemplates) @@ -332,8 +331,13 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { }) generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) - require.NoError(t, Plan(firstRoot, generation)) - require.NoError(t, Plan(secondRoot, generation)) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] firstUnion := expr.AsObject(firstType).Attribute("Value").Type.(*expr.Union) secondUnion := expr.AsObject(secondType).Attribute("Value").Type.(*expr.Union) generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") @@ -344,11 +348,9 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { require.Same(t, firstBranch, secondBranch) require.NoError(t, generation.Freeze()) - firstServices, err := NewServicesData(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) - require.NoError(t, err) - secondServices, err := NewServicesData(secondRoot, generation, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) - require.NoError(t, err) - files := Files("goa.design/goa/example", []*ServicesData{firstServices, secondServices}) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + files := mustServiceFiles(t, firstPlan, secondPlan) require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first.go"))) require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second.go"))) @@ -358,6 +360,240 @@ func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { require.Equal(t, 1, countFiles(files, filepath.Join("gen", "second_service", "service.go"))) } +func TestFilesEmitCanonicalSharedDeclarationAcrossRoots(t *testing.T) { + forwardPlans := sharedDeclarationPlans(t, false) + forwardFiles := mustServiceFiles(t, forwardPlans...) + sharedPath := filepath.Join("gen", "types", "shared.go") + require.Equal(t, 1, countFiles(forwardFiles, sharedPath)) + forward := renderSingleFileAtPath(t, forwardFiles, sharedPath) + + reversePlans := sharedDeclarationPlans(t, true) + reverseFiles := mustServiceFiles(t, reversePlans...) + require.Equal(t, 1, countFiles(reverseFiles, sharedPath)) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, sharedPath)) +} + +func TestNewPlansRejectConflictingSharedDeclarationEmissionCandidates(t *testing.T) { + firstRoot, secondRoot := conflictingSharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptEquivalentSharedDeclarationCopies proves compiler-created +// copies coalesce when every retained type fact is structurally identical. +func TestNewPlansAcceptEquivalentSharedDeclarationCopies(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, nil) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedDeclarationLayoutConflicts proves a shared package +// declaration cannot silently select one compiler copy's field spelling. +func TestNewPlansRejectSharedDeclarationLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + field := expr.AsObject(copy).Attribute("value") + field.AddMeta("struct:field:name", "OtherValue") + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration proves +// validation does not become false service-file ownership. HTTP and gRPC own +// their validation programs; the shared service file owns only the Go layout. +func TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + expr.AsObject(copy).Attribute("value").Validation = &expr.ValidationExpr{Pattern: "^[a-z]+$"} + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedUnionBranchLayoutConflicts proves one canonical +// union declaration cannot select between differing retained branch layouts. +func TestNewPlansRejectSharedUnionBranchLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedUnionRoots(t, func(union *expr.Union) { + union.Values[0].Attribute.Description = "a conflicting branch description" + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated union emission") +} + +func TestNewPlanRejectsPartialMultiRootPlanning(t *testing.T) { + _, firstRoot, secondRoot := sharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlan(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.ErrorContains(t, err, "requires all 2 generation roots") +} + +func sharedDeclarationPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + _, firstRoot, secondRoot := sharedDeclarationRoots(t) + roots := []*expr.RootExpr{firstRoot, secondRoot} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "goa.design/goa/example", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +func sharedDeclarationRoots(t *testing.T) (expr.UserType, *expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + return shared, firstRoot, secondRoot +} + +func conflictingSharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The first retained declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + conflicting := shared.Dup(expr.DupAtt(shared.Attribute())).(expr.UserType) + conflicting.Attribute().Description = "The conflicting retained declaration." + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(conflicting) + }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedDeclarationRoots returns two roots whose compiler copies share +// one authored origin and therefore one generated declaration. +func copiedSharedDeclarationRoots(t *testing.T, mutate func(expr.UserType)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(shared) }) + }) + }) + copy := shared.Dup(expr.DupAtt(shared.Attribute())).(expr.UserType) + if mutate != nil { + mutate(copy) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedUnionRoots returns two roots whose equal union identities bind +// the same generated declaration while retaining independent branch facts. +func copiedSharedUnionRoots(t *testing.T, mutate func(*expr.Union)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var container expr.UserType + firstRoot := codegen.RunDSL(t, func() { + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(container) }) + }) + }) + copy := container.Dup(expr.DupAtt(container.Attribute())).(expr.UserType) + union := expr.AsObject(copy).Attribute("value").Type.(*expr.Union) + if mutate != nil { + mutate(union) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T) { var ( exact expr.UserType @@ -382,7 +618,8 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T }) generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) union := expr.AsObject(container).Attribute("Value").Type.(*expr.Union) generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") exactDeclaration, err := generatedPackage.UserType(exact) @@ -394,10 +631,9 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T require.NoError(t, generation.Freeze()) require.Equal(t, "ValueText", exactDeclaration.Name()) require.Equal(t, "ValueText2", branchDeclaration.Name()) - services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, plan.Link()) typeFile := findFile( - Files("goa.design/goa/example", []*ServicesData{services}), + mustServiceFiles(t, plan), filepath.Join("gen", "types", "value_text.go"), ) require.NotNil(t, typeFile) @@ -456,9 +692,9 @@ func TestService(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, plan) require.Greater(t, len(files), 0) code := renderServiceGolden(t, files, files[0]) @@ -491,12 +727,9 @@ func TestStructPkgPath(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) - files := Files("goa.design/goa/example", []*ServicesData{services}) - - // Check file count - expectedFiles := len(c.TypeFiles) + len(root.Services) - require.Len(t, files, expectedFiles, "unexpected number of files") + plan := mustServicePlan(t, root) + services := plan.Services() + files := mustServiceFiles(t, plan) serviceFile := findFile(files, filepath.Join(codegen.Gendir, services.Get(root.Services[0].Name).PathName, "service.go")) require.NotNil(t, serviceFile) @@ -518,7 +751,7 @@ func TestStructPkgPath(t *testing.T) { // For dupes case, test the second service if c.Name == "dupes" && len(root.Services) > 1 { - files = serviceFiles("goa.design/goa/example", root.Services[1], services) + files = serviceFiles(plan, plan.facts.services[1]) require.Len(t, files, 1) buf := new(bytes.Buffer) for _, s := range files[0].SectionTemplates[1:] { @@ -534,10 +767,10 @@ func TestStructPkgPath(t *testing.T) { func TestStructPkgPath_UnionImportsJSON(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionDSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) @@ -554,9 +787,8 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) - services := mustServicesData(t, root) var generated strings.Builder - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, mustServicePlan(t, root)) for _, file := range files { if !strings.Contains(file.Path, filepath.Join("gen", "types")) { continue @@ -589,16 +821,30 @@ func unionFieldType(code, owner string) string { return code[start : start+end] } -// mustServicesData runs the standalone declaration lifecycle used by service -// tests and returns the frozen render analysis. -func mustServicesData(t *testing.T, root *expr.RootExpr) *ServicesData { +// mustServicePlan runs the complete retained-plan lifecycle used by service +// renderer tests. +func mustServicePlan(t *testing.T, root *expr.RootExpr) *Plan { t.Helper() generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) - require.NoError(t, Plan(root, generation)) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, generation.Freeze()) - services, err := NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, plan.Link()) + return plan +} + +// mustServicesData returns the linked render model for focused analysis tests. +func mustServicesData(t *testing.T, root *expr.RootExpr) *ServicesData { + t.Helper() + return mustServicePlan(t, root).Services() +} + +// mustServiceFiles renders linked plans or fails the calling test. +func mustServiceFiles(t *testing.T, plans ...*Plan) []*codegen.File { + t.Helper() + files, err := Files(plans...) require.NoError(t, err) - return services + return files } // countFiles returns how many generated files have the given path. @@ -677,10 +923,10 @@ func renderServiceGolden(t *testing.T, files []*codegen.File, serviceFile *codeg func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionJSONFieldDSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) @@ -709,10 +955,10 @@ func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathExtendedUnionDSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", []*ServicesData{services}) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2) var serviceFile, localUnionFile, sharedUnionFile *codegen.File diff --git a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl index 9a250d08f6..2190de264f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedClientStreams }} -{{ comment (printf "wrapped%s is a client interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a client interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl index 555960d916..adbdccf58f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl @@ -3,17 +3,17 @@ {{- if ne .SendTypeRef "" }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/client_interceptor_wrappers.go.tpl b/codegen/service/templates/client_interceptor_wrappers.go.tpl index c01590d59c..6f074d0f38 100644 --- a/codegen/service/templates/client_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_wrappers.go.tpl @@ -1,13 +1,13 @@ -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrapClient%s%s applies the %s client interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s client interceptor to endpoints." .ClientWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorUnary, @@ -21,11 +21,11 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i return res, err } stream := res.({{ .ClientStream.Interface }}) - return &wrapped{{ .ClientStream.Interface }}{ + return &{{ .ClientStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingPayloadAccess }} sendWithContext: func(ctx context.Context, req {{ .ClientStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorStreamingSend, @@ -40,7 +40,7 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i {{- end }} {{- if $interceptor.HasStreamingResultAccess }} recvWithContext: func(ctx context.Context) ({{ .ClientStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorStreamingRecv, @@ -55,7 +55,7 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i stream: stream, }, nil {{- else }} - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorUnary, diff --git a/codegen/service/templates/client_interceptors.go.tpl b/codegen/service/templates/client_interceptors.go.tpl index 9528e3d123..ab89722bb9 100644 --- a/codegen/service/templates/client_interceptors.go.tpl +++ b/codegen/service/templates/client_interceptors.go.tpl @@ -2,11 +2,11 @@ // Client interceptors execute after the payload is encoded and before the request // is sent to the server. The implementation is responsible for calling next to // complete the request. -type ClientInterceptors interface { +type {{ .ClientInterceptorsDeclaration.Name }} interface { {{- range .ClientInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info *{{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/client_wrappers.go.tpl b/codegen/service/templates/client_wrappers.go.tpl index bbd1a551d5..eee2c6f8bc 100644 --- a/codegen/service/templates/client_wrappers.go.tpl +++ b/codegen/service/templates/client_wrappers.go.tpl @@ -1,9 +1,9 @@ -{{ comment (printf "Wrap%sClientEndpoint wraps the %s endpoint with the client interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}ClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the client interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrapClient{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/endpoint.go.tpl b/codegen/service/templates/endpoint.go.tpl index 95a459bea3..d874434107 100644 --- a/codegen/service/templates/endpoint.go.tpl +++ b/codegen/service/templates/endpoint.go.tpl @@ -1,8 +1,8 @@ {{ comment .Description }} {{- if .ServerStream }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { {{- else }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { {{- end }} {{- if .SkipRequestBodyEncodeDecode }} // req is the HTTP request body stream. diff --git a/codegen/service/templates/endpoint_wrappers.go.tpl b/codegen/service/templates/endpoint_wrappers.go.tpl index fee83a87ad..13f0164f39 100644 --- a/codegen/service/templates/endpoint_wrappers.go.tpl +++ b/codegen/service/templates/endpoint_wrappers.go.tpl @@ -1,8 +1,8 @@ -{{ comment (printf "Wrap%sEndpoint wraps the %s endpoint with the server-side interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the server-side interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrap{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/error.go.tpl b/codegen/service/templates/error.go.tpl index d995faa1f5..74c9aa7a96 100644 --- a/codegen/service/templates/error.go.tpl +++ b/codegen/service/templates/error.go.tpl @@ -12,5 +12,5 @@ func (e {{ .Ref }}) ErrorName() string { // GoaErrorName returns the error name. func (e {{ .Ref }}) GoaErrorName() string { - return {{ errorName . }} + return {{ .ErrorName }} } diff --git a/codegen/service/templates/error_init.go.tpl b/codegen/service/templates/error_init.go.tpl index 8b474689b1..fda57b0125 100644 --- a/codegen/service/templates/error_init.go.tpl +++ b/codegen/service/templates/error_init.go.tpl @@ -1,4 +1,4 @@ -{{ printf "%s builds a %s from an error." .Name .TypeName | comment }} -func {{ .Name }}(err error) {{ .TypeRef }} { +{{ printf "%s builds a %s from an error." .Declaration.Name .TypeName | comment }} +func {{ .Declaration.Name }}(err error) {{ .TypeRef }} { return goa.NewServiceError(err, {{ printf "%q" .ErrName }}, {{ printf "%v" .Timeout }}, {{ printf "%v" .Temporary}}, {{ printf "%v" .Fault}}) } diff --git a/codegen/service/templates/example_client_interceptor.go.tpl b/codegen/service/templates/example_client_interceptor.go.tpl index 4dad48f184..700b23b48b 100644 --- a/codegen/service/templates/example_client_interceptor.go.tpl +++ b/codegen/service/templates/example_client_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ClientInterceptors implements the client interceptors for the {{ .ServiceName }} service. -type {{ .StructName }}ClientInterceptors struct { +// {{ .StructDeclaration.Name }} implements the client interceptors for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ClientInterceptors creates a new client interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ClientInterceptors() *{{ .StructName }}ClientInterceptors { - return &{{ .StructName }}ClientInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new client interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ClientInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info *{{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_security_authfuncs.go.tpl b/codegen/service/templates/example_security_authfuncs.go.tpl index 5eb760e38e..ba06534ce6 100644 --- a/codegen/service/templates/example_security_authfuncs.go.tpl +++ b/codegen/service/templates/example_security_authfuncs.go.tpl @@ -1,6 +1,6 @@ {{ range .Schemes }} {{ printf "%sAuth implements the authorization logic for service %q for the %q security scheme." .Type $.Name .SchemeName | comment }} -func (s *{{ $.VarName }}srvc) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { +func (s *{{ $.ExampleStructDeclaration.Name }}) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { // // TBD: add authorization logic. // diff --git a/codegen/service/templates/example_server_interceptor.go.tpl b/codegen/service/templates/example_server_interceptor.go.tpl index 9e1ed5086e..92dc0bc65c 100644 --- a/codegen/service/templates/example_server_interceptor.go.tpl +++ b/codegen/service/templates/example_server_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ServerInterceptors implements the server interceptor for the {{ .ServiceName }} service. -type {{ .StructName }}ServerInterceptors struct { +// {{ .StructDeclaration.Name }} implements the server interceptor for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ServerInterceptors creates a new server interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ServerInterceptors() *{{ .StructName }}ServerInterceptors { - return &{{ .StructName }}ServerInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new server interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ServerInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info *{{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_service_init.go.tpl b/codegen/service/templates/example_service_init.go.tpl index ac3488af59..fe043cc951 100644 --- a/codegen/service/templates/example_service_init.go.tpl +++ b/codegen/service/templates/example_service_init.go.tpl @@ -1,4 +1,4 @@ {{ printf "New%s returns the %s service implementation." .StructName .Name | comment }} -func New{{ .StructName }}() {{ .ServicePkg }}.Service { - return &{{ .VarName }}srvc{} +func {{ .ExampleConstructorDeclaration.Name }}() {{ .ServicePkg }}.{{ .ServiceDeclaration.Name }} { + return &{{ .ExampleStructDeclaration.Name }}{} } diff --git a/codegen/service/templates/example_service_struct.go.tpl b/codegen/service/templates/example_service_struct.go.tpl index baefc91ddb..1086752e5d 100644 --- a/codegen/service/templates/example_service_struct.go.tpl +++ b/codegen/service/templates/example_service_struct.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s service example implementation.\nThe example methods log the requests and return zero values." .Name | comment }} -type {{ .VarName }}srvc struct {} +type {{ .ExampleStructDeclaration.Name }} struct {} diff --git a/codegen/service/templates/interceptors.go.tpl b/codegen/service/templates/interceptors.go.tpl index 8c53dbb773..ce267ae0c6 100644 --- a/codegen/service/templates/interceptors.go.tpl +++ b/codegen/service/templates/interceptors.go.tpl @@ -2,28 +2,28 @@ {{- range . }} // Service returns the name of the service handling the request. -func (info *{{ .Name }}Info) Service() string { +func (info *{{ .InfoDeclaration.Name }}) Service() string { return info.service } // Method returns the name of the method handling the request. -func (info *{{ .Name }}Info) Method() string { +func (info *{{ .InfoDeclaration.Name }}) Method() string { return info.method } // CallType returns the type of call the interceptor is handling. -func (info *{{ .Name }}Info) CallType() goa.InterceptorCallType { +func (info *{{ .InfoDeclaration.Name }}) CallType() goa.InterceptorCallType { return info.callType } // RawPayload returns the raw payload of the request. -func (info *{{ .Name }}Info) RawPayload() any { +func (info *{{ .InfoDeclaration.Name }}) RawPayload() any { return info.rawPayload } {{- if .HasPayloadAccess }} // Payload returns a type-safe accessor for the method payload. -func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { +func (info *{{ .InfoDeclaration.Name }}) Payload() {{ .PayloadDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} @@ -31,12 +31,12 @@ func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { {{- if hasEndpointStruct . }} switch pay := info.RawPayload().(type) { case *{{ .ServerStream.EndpointStruct }}: - return &{{ .PayloadAccess }}{payload: pay.Payload} + return &{{ .PayloadAccessDeclaration.Name }}{payload: pay.Payload} default: - return &{{ .PayloadAccess }}{payload: pay.({{ .PayloadRef }})} + return &{{ .PayloadAccessDeclaration.Name }}{payload: pay.({{ .PayloadRef }})} } {{- else }} - return &{{ .PayloadAccess }}{payload: info.RawPayload().({{ .PayloadRef }})} + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ .PayloadRef }})} {{- end }} {{- end }} default: @@ -46,12 +46,12 @@ func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { {{- if hasEndpointStruct (index .Methods 0) }} switch pay := info.RawPayload().(type) { case *{{ (index .Methods 0).ServerStream.EndpointStruct }}: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.Payload} + return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: pay.Payload} default: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.({{ (index .Methods 0).PayloadRef }})} + return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: pay.({{ (index .Methods 0).PayloadRef }})} } {{- else }} - return &{{ (index .Methods 0).PayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).PayloadRef }})} + return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ (index .Methods 0).PayloadRef }})} {{- end }} {{- end }} } @@ -59,90 +59,90 @@ func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { {{- if .HasResultAccess }} // Result returns a type-safe accessor for the method result. -func (info *{{ .Name }}Info) Result(res any) {{ .Name }}Result { +func (info *{{ .InfoDeclaration.Name }}) Result(res any) {{ .ResultDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} case "{{ .MethodName }}": - return &{{ .ResultAccess }}{result: res.({{ .ResultRef }})} + return &{{ .ResultAccessDeclaration.Name }}{result: res.({{ .ResultRef }})} {{- end }} default: return nil } {{- else }} - return &{{ (index .Methods 0).ResultAccess }}{result: res.({{ (index .Methods 0).ResultRef }})} + return &{{ (index .Methods 0).ResultAccessDeclaration.Name }}{result: res.({{ (index .Methods 0).ResultRef }})} {{- end }} } {{- end }} {{- if .HasStreamingPayloadAccess }} // ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingPayload() {{ .Name }}StreamingPayload { +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingPayload() {{ .StreamingPayloadDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: info.RawPayload().({{ .StreamingPayloadRef }})} + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ .StreamingPayloadRef }})} {{- end }} default: return nil } {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).StreamingPayloadRef }})} + return &{{ (index .Methods 0).StreamingPayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ (index .Methods 0).StreamingPayloadRef }})} {{- end }} } {{- end }} {{- if .HasStreamingResultAccess }} // ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingResult(res any) {{ .Name }}StreamingResult { +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingResult(res any) {{ .StreamingResultDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: res.({{ .StreamingResultRef }})} + return &{{ .StreamingResultAccessDeclaration.Name }}{result: res.({{ .StreamingResultRef }})} {{- end }} default: return nil } {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: res.({{ (index .Methods 0).StreamingResultRef }})} + return &{{ (index .Methods 0).StreamingResultAccessDeclaration.Name }}{result: res.({{ (index .Methods 0).StreamingResultRef }})} {{- end }} } {{- end }} {{- if .HasStreamingPayloadAccess }} // ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingPayload(pay any) {{ .Name }}StreamingPayload { +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingPayload(pay any) {{ .StreamingPayloadDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: pay.({{ .StreamingPayloadRef }})} + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: pay.({{ .StreamingPayloadRef }})} {{- end }} default: return nil } {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: pay.({{ (index .Methods 0).StreamingPayloadRef }})} + return &{{ (index .Methods 0).StreamingPayloadAccessDeclaration.Name }}{payload: pay.({{ (index .Methods 0).StreamingPayloadRef }})} {{- end }} } {{- end }} {{- if .HasStreamingResultAccess }} // ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingResult() {{ .Name }}StreamingResult { +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingResult() {{ .StreamingResultDeclaration.Name }} { {{- if gt (len .Methods) 1 }} switch info.Method() { {{- range .Methods }} case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: info.RawPayload().({{ .StreamingResultRef }})} + return &{{ .StreamingResultAccessDeclaration.Name }}{result: info.RawPayload().({{ .StreamingResultRef }})} {{- end }} default: return nil } {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: info.RawPayload().({{ (index .Methods 0).StreamingResultRef }})} + return &{{ (index .Methods 0).StreamingResultAccessDeclaration.Name }}{result: info.RawPayload().({{ (index .Methods 0).StreamingResultRef }})} {{- end }} } {{- end }} @@ -155,7 +155,7 @@ func (info *{{ .Name }}Info) ServerStreamingResult() {{ .Name }}StreamingResult {{- range .Methods }} {{- $method := . }} {{- range $interceptor.ReadPayload }} -func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -169,7 +169,7 @@ func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WritePayload }} -func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -179,7 +179,7 @@ func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadResult }} -func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.ResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -193,7 +193,7 @@ func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteResult }} -func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.ResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} @@ -203,7 +203,7 @@ func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -217,7 +217,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -227,7 +227,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) {{- end }} {{- range $interceptor.ReadStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -241,7 +241,7 @@ func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} diff --git a/codegen/service/templates/interceptors_types.go.tpl b/codegen/service/templates/interceptors_types.go.tpl index 10b890f470..511409cfd3 100644 --- a/codegen/service/templates/interceptors_types.go.tpl +++ b/codegen/service/templates/interceptors_types.go.tpl @@ -2,9 +2,9 @@ // Access interfaces for interceptor payloads and results type ( {{- range . }} - // {{ .Name }}Info provides metadata about the current interception. + // {{ .InfoDeclaration.Name }} provides metadata about the current interception. // It includes service name, method name, and access to the endpoint. - {{ .Name }}Info struct { + {{ .InfoDeclaration.Name }} struct { service string method string callType goa.InterceptorCallType @@ -12,10 +12,10 @@ type ( } {{- if .HasPayloadAccess }} - // {{ .Name }}Payload provides type-safe access to the method payload. + // {{ .PayloadDeclaration.Name }} provides type-safe access to the method payload. // It allows reading and writing specific fields of the payload as defined // in the design. - {{ .Name }}Payload interface { + {{ .PayloadDeclaration.Name }} interface { {{- range .ReadPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -26,10 +26,10 @@ type ( {{- end }} {{- if .HasResultAccess }} - // {{ .Name }}Result provides type-safe access to the method result. + // {{ .ResultDeclaration.Name }} provides type-safe access to the method result. // It allows reading and writing specific fields of the result as defined // in the design. - {{ .Name }}Result interface { + {{ .ResultDeclaration.Name }} interface { {{- range .ReadResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -40,10 +40,10 @@ type ( {{- end }} {{- if .HasStreamingPayloadAccess }} - // {{ .Name }}StreamingPayload provides type-safe access to the method streaming payload. + // {{ .StreamingPayloadDeclaration.Name }} provides type-safe access to the method streaming payload. // It allows reading and writing specific fields of the streaming payload as defined // in the design. - {{ .Name }}StreamingPayload interface { + {{ .StreamingPayloadDeclaration.Name }} interface { {{- range .ReadStreamingPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -54,10 +54,10 @@ type ( {{- end }} {{- if .HasStreamingResultAccess }} - // {{ .Name }}StreamingResult provides type-safe access to the method streaming result. + // {{ .StreamingResultDeclaration.Name }} provides type-safe access to the method streaming result. // It allows reading and writing specific fields of the streaming result as defined // in the design. - {{ .Name }}StreamingResult interface { + {{ .StreamingResultDeclaration.Name }} interface { {{- range .ReadStreamingResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -74,8 +74,8 @@ type ( type ( {{- range . }} {{- range .Methods }} - {{- if .PayloadAccess }} - {{ .PayloadAccess }} struct { + {{- if .PayloadAccessDeclaration }} + {{ .PayloadAccessDeclaration.Name }} struct { payload {{ .PayloadRef }} } {{- end }} @@ -84,8 +84,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .ResultAccess }} - {{ .ResultAccess }} struct { + {{- if .ResultAccessDeclaration }} + {{ .ResultAccessDeclaration.Name }} struct { result {{ .ResultRef }} } {{- end }} @@ -94,8 +94,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingPayloadAccess }} - {{ .StreamingPayloadAccess }} struct { + {{- if .StreamingPayloadAccessDeclaration }} + {{ .StreamingPayloadAccessDeclaration.Name }} struct { payload {{ .StreamingPayloadRef }} } {{- end }} @@ -104,8 +104,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingResultAccess }} - {{ .StreamingResultAccess }} struct { + {{- if .StreamingResultAccessDeclaration }} + {{ .StreamingResultAccessDeclaration.Name }} struct { result {{ .StreamingResultRef }} } {{- end }} diff --git a/codegen/service/templates/jsonrpc_handle_stream.go.tpl b/codegen/service/templates/jsonrpc_handle_stream.go.tpl index e448f03d2d..d4f2f78b82 100644 --- a/codegen/service/templates/jsonrpc_handle_stream.go.tpl +++ b/codegen/service/templates/jsonrpc_handle_stream.go.tpl @@ -2,7 +2,7 @@ // communication between the server and client. It receives requests from the // client, dispatches them to the appropriate service methods, and can send // server-initiated messages back to the client as needed. -func (s *{{ .VarName }}srvc) HandleStream(ctx context.Context, stream {{ .ServicePkg }}.Stream) error { +func (s *{{ .ExampleStructDeclaration.Name }}) HandleStream(ctx context.Context, stream {{ .ServicePkg }}.{{ .StreamDeclaration.Name }}) error { log.Printf(ctx, "{{ .VarName }}.HandleStream") // Example: In a real implementation you might read from an event source diff --git a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl index 868b063886..2ebabb6484 100644 --- a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl +++ b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { {{- if and .Result .ResultIsStruct }} res = &{{ .ResultFullName }}{} {{- end }} @@ -10,4 +10,4 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") return -} \ No newline at end of file +} diff --git a/codegen/service/templates/return_type_init.go.tpl b/codegen/service/templates/return_type_init.go.tpl index 28e7a51b9f..575a5680cb 100644 --- a/codegen/service/templates/return_type_init.go.tpl +++ b/codegen/service/templates/return_type_init.go.tpl @@ -2,10 +2,10 @@ {{- if eq (len .Views) 1 }} {{- with (index .Views 0) }} {{- if $.ToViewed -}} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) return {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else -}} - return {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + return {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} {{- else -}} @@ -14,10 +14,10 @@ {{- range .Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- if $.ToViewed }} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) {{ $.ReturnVar }} = {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else }} - {{ $.ReturnVar }} = {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{ $.ReturnVar }} = {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} } @@ -26,14 +26,14 @@ {{- else if .IsCollection -}} {{ .ReturnVar }} := make({{ .TargetType }}, len({{ .ArgVar }})) for i, n := range {{ .ArgVar }} { - {{ .ReturnVar }}[i] = {{ .InitName }}(n) + {{ .ReturnVar }}[i] = {{ .Init.Name }}(n) } return {{ .ReturnVar }} {{- else -}} {{ .Code }} {{- range .Fields }} if {{ $.Source }}.{{ .VarName }} != nil { - {{ $.Target }}.{{ .VarName }} = {{ .FieldInit }}({{ $.Source }}.{{ .VarName }}) + {{ $.Target }}.{{ .VarName }} = {{ .Declaration.Name }}({{ $.Source }}.{{ .VarName }}) } {{- end }} return {{ .ReturnVar }} diff --git a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl index a33a45fa2a..15f94b74f3 100644 --- a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedServerStreams }} -{{ comment (printf "wrapped%s is a server interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a server interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl index 26454cd232..dcf2fd89d7 100644 --- a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl @@ -1,19 +1,19 @@ {{- range .WrappedServerStreams }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } {{- if ne .SendTypeRef "" }} -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/server_interceptor_wrappers.go.tpl b/codegen/service/templates/server_interceptor_wrappers.go.tpl index 0dac75a6a7..72662045c1 100644 --- a/codegen/service/templates/server_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_wrappers.go.tpl @@ -1,17 +1,17 @@ -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrap%s%s applies the %s server interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s server interceptor to endpoints." .ServerWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} stream := req.(*{{ .ServerStream.EndpointStruct }}).Stream - req.(*{{ .ServerStream.EndpointStruct }}).Stream = &wrapped{{ .ServerStream.Interface }}{ + req.(*{{ .ServerStream.EndpointStruct }}).Stream = &{{ .ServerStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingResultAccess }} sendWithContext: func(ctx context.Context, req {{ .ServerStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorStreamingSend, @@ -26,7 +26,7 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve {{- end }} {{- if $interceptor.HasStreamingPayloadAccess }} recvWithContext: func(ctx context.Context) ({{ .ServerStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorStreamingRecv, @@ -41,7 +41,7 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve stream: stream, } {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorUnary, @@ -52,7 +52,7 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve return endpoint(ctx, req) {{- end }} {{- else }} - info := &{{ $interceptor.Name }}Info{ + info := &{{ $interceptor.InfoDeclaration.Name }}{ service: "{{ $.Service }}", method: "{{ .MethodName }}", callType: goa.InterceptorUnary, diff --git a/codegen/service/templates/server_interceptors.go.tpl b/codegen/service/templates/server_interceptors.go.tpl index 3bd263dc95..61f30c4a91 100644 --- a/codegen/service/templates/server_interceptors.go.tpl +++ b/codegen/service/templates/server_interceptors.go.tpl @@ -2,11 +2,11 @@ // Server interceptors execute after the request is decoded and before the // payload is sent to the service. The implementation is responsible for calling // next to complete the request. -type ServerInterceptors interface { +type {{ .ServerInterceptorsDeclaration.Name }} interface { {{- range .ServerInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info *{{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/service.go.tpl b/codegen/service/templates/service.go.tpl index 61626354f1..9fd70e3dba 100644 --- a/codegen/service/templates/service.go.tpl +++ b/codegen/service/templates/service.go.tpl @@ -1,6 +1,6 @@ {{ comment .Description }} -type Service interface { +type {{ .ServiceDeclaration.Name }} interface { {{- if isJSONRPCWebSocket . }} {{ comment "HandleStream handles the JSON-RPC WebSocket streaming connection. Calling Recv() on the stream will dispatch requests to the appropriate methods below." }} HandleStream(context.Context, Stream) error @@ -45,7 +45,7 @@ type Service interface { {{- if .Schemes }} // Auther defines the authorization functions to be implemented by the service. -type Auther interface { +type {{ .AutherDeclaration.Name }} interface { {{- range .Schemes.DedupeByType }} {{ printf "%sAuth implements the authorization logic for the %s security scheme." .Type .Type | comment }} {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, schema *security.{{ .Type }}Scheme) (context.Context, error) @@ -54,20 +54,20 @@ type Auther interface { {{- end }} // APIName is the name of the API as defined in the design. -const APIName = {{ printf "%q" .APIName }} +const {{ .APINameDeclaration.Name }} = {{ printf "%q" .APIName }} // APIVersion is the version of the API as defined in the design. -const APIVersion = {{ printf "%q" .APIVersion }} +const {{ .APIVersionDeclaration.Name }} = {{ printf "%q" .APIVersion }} // ServiceName is the name of the service as defined in the design. This is the // same value that is set in the endpoint request contexts under the ServiceKey // key. -const ServiceName = {{ printf "%q" .Name }} +const {{ .ServiceNameDeclaration.Name }} = {{ printf "%q" .Name }} // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } +var {{ .MethodNamesDeclaration.Name }} = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } {{- range .Methods }} {{- if .ServerStream }} @@ -162,7 +162,7 @@ type {{ .Stream.Interface }} interface { {{- define "jsonrpc_websocket_stream" }} {{ printf "Stream defines the interface for managing a WebSocket streaming connection in the %s server. It allows sending results, sending errors, receiving requests, and closing the connection. This interface is used by the service to interact with clients over WebSocket using JSON-RPC." .Name | comment }} -type Stream interface { +type {{ .StreamDeclaration.Name }} interface { {{- range .Methods }} {{- if .Result }} {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method (no response expected)." .VarName .Name | comment }} @@ -198,13 +198,13 @@ type Stream interface { {{- if .Errors }}{{ $hasErrors = true }}{{ end }} {{- end }} {{ printf "Stream defines the interface for managing an SSE streaming connection in the %s server. It allows sending notifications and final responses. This interface is used by the service to interact with clients over SSE using JSON-RPC." .Name | comment }} -type Stream interface { +type {{ .StreamDeclaration.Name }} interface { {{- if $hasResults }} {{ comment "Send sends an event (notification or response) to the client." }} {{ comment "For notifications, the result should not have an ID field." }} {{ comment "For responses, the result must have an ID field." }} {{ printf "Accepted types: %s" $resultTypes | comment }} - Send(ctx context.Context, event Event) error + Send(ctx context.Context, event {{ .EventDeclaration.Name }}) error {{- end }} {{- if $hasErrors }} {{ comment "SendError sends a JSON-RPC error response." }} @@ -214,7 +214,7 @@ type Stream interface { {{- if $hasResults }} {{ printf "Event is the interface implemented by all result types that can be sent via the %s Stream." .Name | comment }} -type Event interface { +type {{ .EventDeclaration.Name }} interface { is{{ .VarName }}Event() } diff --git a/codegen/service/templates/service_client.go.tpl b/codegen/service/templates/service_client.go.tpl index 90828cc6a7..6a06c234cc 100644 --- a/codegen/service/templates/service_client.go.tpl +++ b/codegen/service/templates/service_client.go.tpl @@ -1,5 +1,5 @@ -// {{ .ClientVarName }} is the {{ printf "%q" .Name }} service client. -type {{ .ClientVarName }} struct { +// {{ .ClientDeclaration.Name }} is the {{ printf "%q" .Name }} service client. +type {{ .ClientDeclaration.Name }} struct { {{- range .Methods}} {{ .EndpointField }} goa.Endpoint {{- if .HasMixedResults }} diff --git a/codegen/service/templates/service_client_init.go.tpl b/codegen/service/templates/service_client_init.go.tpl index 548b288232..cac6bc4de6 100644 --- a/codegen/service/templates/service_client_init.go.tpl +++ b/codegen/service/templates/service_client_init.go.tpl @@ -1,10 +1,10 @@ -{{ printf "New%s initializes a %q service client given the endpoints." .ClientVarName .Name | comment }} -func New{{ .ClientVarName }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci ClientInterceptors{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci ClientInterceptors{{ end }}{{ end }}) *{{ .ClientVarName }} { - return &{{ .ClientVarName }}{ +{{ printf "%s initializes a %q service client given the endpoints." .NewClientDeclaration.Name .Name | comment }} +func {{ .NewClientDeclaration.Name }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ end }}) *{{ .ClientDeclaration.Name }} { + return &{{ .ClientDeclaration.Name }}{ {{- range .Methods }} - {{ .EndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .EndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- if .HasMixedResults }} - {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- end }} {{- end }} } diff --git a/codegen/service/templates/service_client_method.go.tpl b/codegen/service/templates/service_client_method.go.tpl index df5c8f1bfd..e8099de478 100644 --- a/codegen/service/templates/service_client_method.go.tpl +++ b/codegen/service/templates/service_client_method.go.tpl @@ -9,7 +9,7 @@ {{- end }} {{- if .HasMixedResults }} {{- $unaryResultType := .ResultRef }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $unaryResultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} @@ -30,7 +30,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadR } {{ printf "%sStream calls the %q endpoint of the %q service with server streaming enabled." .VarName .Name .ServiceName | comment }} -func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { var ires any ires, err = c.{{ .StreamEndpointField }}(ctx, {{ if .MethodData.SkipRequestBodyEncodeDecode }}&{{ .RequestStruct }}{ {{ if .PayloadRef }}Payload: p, {{ end }}Body: req }{{ else if .PayloadRef }}p{{ else }}nil{{ end }}) if err != nil { @@ -44,7 +44,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .Pa {{- /* When a client stream exists, always return it from the client method. */ -}} {{- $resultType = .ClientStream.Interface }} {{- end }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $resultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} diff --git a/codegen/service/templates/service_endpoint_method.go.tpl b/codegen/service/templates/service_endpoint_method.go.tpl index e375fd4197..ca331388ce 100644 --- a/codegen/service/templates/service_endpoint_method.go.tpl +++ b/codegen/service/templates/service_endpoint_method.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sEndpoint returns an endpoint function that calls the method %q of service %q." .VarName .Name .ServiceName | comment }} -func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { +{{ printf "%s returns an endpoint function that calls the method %q of service %q." .EndpointDeclaration.Name .Name .ServiceName | comment }} +func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} @@ -127,9 +127,9 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy } {{- if .ViewedResult }} {{- if .ViewedResult.ViewName }} - vres := {{ $.ViewedResult.Init.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) {{- else }} - vres := {{ $.ViewedResult.Init.Name }}(res, view) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} return vres, nil {{- else }} @@ -167,7 +167,7 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) return vres, nil {{- else }} return {{ if not .ResultRef }}nil, {{ end }}s.{{ .VarName }}(ctx, {{ if .PayloadRef }}ep.Payload, {{ end }}ep.Body) @@ -177,7 +177,7 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) return vres, nil {{- else if .SkipResponseBodyEncodeDecode }} {{ if .ResultRef }}res, {{ end }}body, err := s.{{ .VarName }}(ctx{{ if .PayloadRef }}, {{ $payload}}{{ end }}) diff --git a/codegen/service/templates/service_endpoints.go.tpl b/codegen/service/templates/service_endpoints.go.tpl index 547d1242cd..7f46e17d82 100644 --- a/codegen/service/templates/service_endpoints.go.tpl +++ b/codegen/service/templates/service_endpoints.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -type {{ .VarName }} struct { +type {{ .EndpointsDeclaration.Name }} struct { {{- range .Methods}} {{ .VarName }} goa.Endpoint {{- end }} diff --git a/codegen/service/templates/service_endpoints_init.go.tpl b/codegen/service/templates/service_endpoints_init.go.tpl index 38770a22f7..d7e9e57d20 100644 --- a/codegen/service/templates/service_endpoints_init.go.tpl +++ b/codegen/service/templates/service_endpoints_init.go.tpl @@ -1,23 +1,23 @@ -{{ printf "New%s wraps the methods of the %q service with endpoints." .VarName .Name | comment }} -func New{{ .VarName }}(s {{ .ServiceVarName }}{{ if .HasServerInterceptors }}, si ServerInterceptors{{ end }}) *{{ .VarName }} { +{{ printf "%s wraps the methods of the %q service with endpoints." .NewEndpointsDeclaration.Name .Name | comment }} +func {{ .NewEndpointsDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ if .HasServerInterceptors }}, si {{ .ServerInterceptorsDeclaration.Name }}{{ end }}) *{{ .EndpointsDeclaration.Name }} { {{- if .Schemes }} // Casting service to Auther interface a := s.(Auther) {{- end }} {{- if .HasServerInterceptors }} - endpoints := &{{ .VarName }}{ + endpoints := &{{ .EndpointsDeclaration.Name }}{ {{- else }} - return &{{ .VarName }}{ + return &{{ .EndpointsDeclaration.Name }}{ {{- end }} {{- range .Methods }} - {{ .VarName }}: New{{ .VarName }}Endpoint(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), + {{ .VarName }}: {{ .EndpointDeclaration.Name }}(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), {{- end }} } {{- if .HasServerInterceptors }} {{- range .Methods }} {{- if .ServerInterceptors }} - endpoints.{{ .VarName }} = Wrap{{ .VarName }}Endpoint(endpoints.{{ .VarName }}, si) + endpoints.{{ .VarName }} = {{ .ServerEndpointWrapperDeclaration.Name }}(endpoints.{{ .VarName }}, si) {{- end }} {{- end }} return endpoints diff --git a/codegen/service/templates/service_endpoints_use.go.tpl b/codegen/service/templates/service_endpoints_use.go.tpl index 0539583d91..506cb85f7b 100644 --- a/codegen/service/templates/service_endpoints_use.go.tpl +++ b/codegen/service/templates/service_endpoints_use.go.tpl @@ -1,7 +1,7 @@ {{ printf "Use applies the given middleware to all the %q service endpoints." .Name | comment }} -func (e *{{ .VarName }}) Use(m func(goa.Endpoint) goa.Endpoint) { +func (e *{{ .EndpointsDeclaration.Name }}) Use(m func(goa.Endpoint) goa.Endpoint) { {{- range .Methods }} e.{{ .VarName }} = m(e.{{ .VarName }}) {{- end }} diff --git a/codegen/service/templates/transform_helper.go.tpl b/codegen/service/templates/transform_helper.go.tpl index 0e23fe9e0f..e23f97ba9e 100644 --- a/codegen/service/templates/transform_helper.go.tpl +++ b/codegen/service/templates/transform_helper.go.tpl @@ -1,5 +1,7 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}}{{- $name = .Declaration.Name -}}{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .Name }}{{ end }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/codegen/service/templates/type_init.go.tpl b/codegen/service/templates/type_init.go.tpl index 9815875413..f372d47e2e 100644 --- a/codegen/service/templates/type_init.go.tpl +++ b/codegen/service/templates/type_init.go.tpl @@ -1,4 +1,4 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} } diff --git a/codegen/service/templates/type_validate.go.tpl b/codegen/service/templates/type_validate.go.tpl index 49ebacc867..0bb57bda84 100644 --- a/codegen/service/templates/type_validate.go.tpl +++ b/codegen/service/templates/type_validate.go.tpl @@ -1,16 +1,16 @@ {{- if .IsViewed -}} switch {{ .ArgVar }}.View { - {{- range .Views }} -case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: - err = Validate{{ $.Projected }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{- range .ValidationCalls }} +case {{ printf "%q" .View }}{{ if .Default }}, ""{{ end }}: + err = {{ .Declaration.Name }}({{ $.ArgVar }}.Projected) {{- end }} default: - err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .Views }}{{ printf "%q" .Name }}, {{ end }} }) + err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .ValidationCalls }}{{ printf "%q" .View }}, {{ end }} }) } {{- else -}} {{- if .IsCollection -}} for _, {{ $.Source }} := range {{ $.ArgVar }} { - if err2 := {{ .ValidateVar }}({{ $.Source }}); err2 != nil { + if err2 := {{ .ValidateCall.Declaration.Name }}({{ $.Source }}); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -23,7 +23,7 @@ if {{ $.Source }}.{{ goify .Name true }} == nil { } {{- end }} if {{ $.Source }}.{{ goify .Name true }} != nil { - if err2 := {{ .ValidateVar }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { + if err2 := {{ .Call.Declaration.Name }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/codegen/service/templates/validate.go.tpl b/codegen/service/templates/validate.go.tpl index 2ba16dc703..e427affc10 100644 --- a/codegen/service/templates/validate.go.tpl +++ b/codegen/service/templates/validate.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}(result {{ .Ref }}) (err error) { +func {{ .Declaration.Name }}(result {{ .Ref }}) (err error) { {{ .Validate }} return } diff --git a/codegen/service/templates/viewed_type_map.go.tpl b/codegen/service/templates/viewed_type_map.go.tpl index 249b96c8a9..e3c88ee849 100644 --- a/codegen/service/templates/viewed_type_map.go.tpl +++ b/codegen/service/templates/viewed_type_map.go.tpl @@ -1,7 +1,7 @@ var ( {{- range .ViewedTypes }} - {{ printf "%sMap is a map indexing the attribute names of %s by view name." .Name .Name | comment }} - {{ .Name }}Map = map[string][]string{ + {{ printf "%s is a map indexing the attribute names of %s by view name." .Declaration.Name .TypeName | comment }} + {{ .Declaration.Name }} = map[string][]string{ {{- range .Views }} "{{ .Name }}": { {{- range $n := .Attributes }} diff --git a/codegen/service/test_helpers_test.go b/codegen/service/test_helpers_test.go index 785304947e..f0cc9044b2 100644 --- a/codegen/service/test_helpers_test.go +++ b/codegen/service/test_helpers_test.go @@ -9,6 +9,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) // mustTestGeneration creates one generation or fails the calling test. @@ -26,3 +27,10 @@ func mustClaimTestPackage(t *testing.T, generation *codegen.Generation, path str require.NoError(t, err) return generatedPackage } + +// planTestServices collects one retained service plan when the test exercises +// declaration collection separately from post-freeze linking. +func planTestServices(root *expr.RootExpr, generation *codegen.Generation) error { + _, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + return err +} diff --git a/codegen/service/testdata/a-nested-alpha/unused.go b/codegen/service/testdata/a-nested-alpha/unused.go new file mode 100644 index 0000000000..385890e057 --- /dev/null +++ b/codegen/service/testdata/a-nested-alpha/unused.go @@ -0,0 +1,8 @@ +// Package nestedalpha supplies an external field that the design deliberately +// does not map, so conversion planning can prove unused fields reserve nothing. +package nestedalpha + +// Child has the same package and type names as a mapped conversion field. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden index 1469ba4254..8002781164 100644 --- a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a generated service type. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden index 7bd09a39e3..8002781164 100644 --- a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is the payload type of the PkgPathDupeMethod service A method. +// Foo is a generated service type. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden index af590aae73..6c52fcfa6e 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden @@ -1,4 +1,4 @@ -// Bar is the payload type of the MultiplePkgPathMethod service A method. +// Bar is a generated service type. type Bar struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden index 2e6028eeb1..c7e710ef14 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden @@ -1,4 +1,4 @@ -// Baz is the payload type of the MultiplePkgPathMethod service B method. +// Baz is a generated service type. type Baz struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden index 1469ba4254..8002781164 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a generated service type. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden index 1469ba4254..8002781164 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a generated service type. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden index 1d4d0e1fdb..560f526744 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden @@ -1,5 +1,4 @@ -// RecursiveFoo is the payload type of the PkgPathRecursiveMethod service A -// method. +// RecursiveFoo is a generated service type. type RecursiveFoo struct { Foo *Foo } diff --git a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden index 38cad09709..8002781164 100644 --- a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is the payload type of the PkgPathMethod service A method. +// Foo is a generated service type. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden index 3441c502e8..9d241f0196 100644 --- a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden @@ -6,8 +6,8 @@ type Service interface { // - "default" // - "tiny" A(context.Context) (res *MultipleViews, view string, err error) - // A implements A. - AEndpoint(context.Context) (res *MultipleViews, err error) + // B implements B. + B(context.Context) (res *MultipleViews, err error) } // APIName is the name of the API as defined in the design. @@ -24,7 +24,7 @@ const ServiceName = "WithExplicitAndDefaultViews" // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [2]string{"A", "A"} +var MethodNames = [2]string{"A", "B"} // MultipleViews is the result type of the WithExplicitAndDefaultViews service // A method. diff --git a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden index 1cf995edb0..46d1ed37b8 100644 --- a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden @@ -51,6 +51,21 @@ func NewViewedResultInlineValidation(res *ResultInlineValidation, view string) * return &resultwithinlinevalidationviews.ResultInlineValidation{Projected: p, View: "default"} } +// NewResultInlineValidationBResult initializes result type +// ResultInlineValidationBResult from viewed result type +// ResultInlineValidationBResult. +func NewResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResult) *ResultInlineValidationBResult { + return newResultInlineValidationBResult(vres.Projected) +} + +// NewViewedResultInlineValidationBResult initializes viewed result type +// ResultInlineValidationBResult from result type ResultInlineValidationBResult +// using the given view. +func NewViewedResultInlineValidationBResult(res *ResultInlineValidationBResult, view string) *resultwithinlinevalidationviews.ResultInlineValidationBResult { + p := newResultInlineValidationBResultView(res) + return &resultwithinlinevalidationviews.ResultInlineValidationBResult{Projected: p, View: "default"} +} + // newResultInlineValidation converts projected type ResultInlineValidation to // service type ResultInlineValidation. func newResultInlineValidation(vres *resultwithinlinevalidationviews.ResultInlineValidationView) *ResultInlineValidation { diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden index c55718e9a4..51c7d29aa9 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden index 0cde37225c..73f2ce2eb7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &ValidationInfo{ @@ -13,7 +13,7 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden index 8510ebab3f..3c42633ff7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden index eb23fdc5df..9609b832d4 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden index 431b0789ce..15d5541a6f 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ @@ -13,7 +13,7 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden index 07c3c5c8e9..9c7190d004 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden index c55718e9a4..51c7d29aa9 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden index 8a1d529196..0b49fb0bf0 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &ValidationInfo{ @@ -13,7 +13,7 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden index 1e77d5d8a4..2c3ee03c9c 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden @@ -37,7 +37,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden index eb23fdc5df..9609b832d4 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden index 40be57c66d..6d2691de1d 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ @@ -13,7 +13,7 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden index 5457dfdafe..5e5b38a05a 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden @@ -37,7 +37,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden index c55718e9a4..51c7d29aa9 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden index d7059393aa..3285bd4c01 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &ValidationInfo{ @@ -13,7 +13,7 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden index c67700cd58..5ee1ac3121 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden index eb23fdc5df..9609b832d4 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden index 89854d6c95..0d0cb30e3a 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ @@ -13,7 +13,7 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &CachingInfo{ diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden index 0e7844bbc8..027ad83acc 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden index 449f55aa49..2279c68e7b 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden @@ -31,8 +31,8 @@ type ( // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtest2(endpoint, i) - endpoint = wrapClientMethodtest4(endpoint, i) + endpoint = wrapClientMethodTest2(endpoint, i) + endpoint = wrapClientMethodTest4(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden index cfabf2905f..4035a2680b 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapTestMethod applies the test server interceptor to endpoints. +// wrapMethodTest applies the test server interceptor to endpoints. func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &TestInfo{ @@ -13,7 +13,7 @@ func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { } } -// wrapTest3Method applies the test3 server interceptor to endpoints. +// wrapMethodTest3 applies the test3 server interceptor to endpoints. func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &Test3Info{ @@ -26,7 +26,7 @@ func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { } } -// wrapClientTest2Method applies the test2 client interceptor to endpoints. +// wrapClientMethodTest2 applies the test2 client interceptor to endpoints. func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &Test2Info{ @@ -39,7 +39,7 @@ func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endp } } -// wrapClientTest4Method applies the test4 client interceptor to endpoints. +// wrapClientMethodTest4 applies the test4 client interceptor to endpoints. func wrapClientMethodTest4(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &Test4Info{ diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden index 46327a5a20..fd74e78e0b 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden @@ -31,8 +31,8 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodtest(endpoint, i) - endpoint = wrapMethodtest3(endpoint, i) + endpoint = wrapMethodTest(endpoint, i) + endpoint = wrapMethodTest3(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden index 498cc404ca..2a415546c0 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ @@ -13,7 +13,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..edcaea754e 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden @@ -22,7 +22,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,7 +31,7 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden index 8bc74da506..dc2562dae3 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden @@ -22,7 +22,7 @@ type ( // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtracing(endpoint, i) + endpoint = wrapClientMethodTracing(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden index b72e20a2e2..9c9e165a12 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapClientTracingMethod applies the tracing client interceptor to endpoints. +// wrapClientMethodTracing applies the tracing client interceptor to endpoints. func wrapClientMethodTracing(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &TracingInfo{ diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden index 58c5f96d45..4e934d90ec 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden index 73b5303882..b5e887ec35 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden @@ -22,7 +22,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden index a73b41c30c..2415c5cadd 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ @@ -13,7 +13,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..edcaea754e 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden @@ -22,7 +22,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,7 +31,7 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden index 46dd4655d2..8aa673f2a2 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden index fe7974931a..7c9dab65ae 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden @@ -16,7 +16,7 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream @@ -46,7 +46,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden index 47a6372ee4..2895be4b3b 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden @@ -46,7 +46,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden index b4f94826cd..f5381427fd 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden index 031813f303..169bf2ec99 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden index b6e8f62cc2..058523014a 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden @@ -1,6 +1,6 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { info := &LoggingInfo{ diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden index e1b86b82ab..2112144a00 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden index 46dd4655d2..8aa673f2a2 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden index ecb540af91..6ef02da2ca 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden @@ -16,7 +16,7 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream @@ -41,7 +41,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden index af6d942fef..5c3dee24ab 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden @@ -36,7 +36,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden index 46dd4655d2..8aa673f2a2 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden @@ -10,7 +10,7 @@ type ClientInterceptors interface { // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden index dfd5cedeac..79a78a3c4f 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden @@ -18,7 +18,7 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream @@ -55,7 +55,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden index da2e8aa555..3018463916 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden @@ -48,7 +48,7 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/nested-alpha/alpha.go b/codegen/service/testdata/nested-alpha/alpha.go new file mode 100644 index 0000000000..3e74569aa4 --- /dev/null +++ b/codegen/service/testdata/nested-alpha/alpha.go @@ -0,0 +1,7 @@ +// Package nestedalpha supplies a named reflected child type for conversion tests. +package nestedalpha + +// Child is the alpha branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-beta/beta.go b/codegen/service/testdata/nested-beta/beta.go new file mode 100644 index 0000000000..d07364725c --- /dev/null +++ b/codegen/service/testdata/nested-beta/beta.go @@ -0,0 +1,7 @@ +// Package nestedbeta supplies a same-named reflected child type from a different package. +package nestedbeta + +// Child is the beta branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-outer/outer.go b/codegen/service/testdata/nested-outer/outer.go new file mode 100644 index 0000000000..d814276d74 --- /dev/null +++ b/codegen/service/testdata/nested-outer/outer.go @@ -0,0 +1,17 @@ +// Package nestedouter supplies an external conversion shape whose child types +// come from two distinct Go packages. +package nestedouter + +import ( + unusedalpha "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" +) + +// Envelope contains mapped same-named child types and one deliberately +// unmapped child whose package name collides with the mapped alpha package. +type Envelope struct { + Alpha *nestedalpha.Child + Beta *nestedbeta.Child + Unused *unusedalpha.Child +} diff --git a/codegen/service/testdata/service_dsls.go b/codegen/service/testdata/service_dsls.go index fba5189de0..f4502cc925 100644 --- a/codegen/service/testdata/service_dsls.go +++ b/codegen/service/testdata/service_dsls.go @@ -435,7 +435,7 @@ var WithExplicitAndDefaultViewsDSL = func() { Method("A", func() { Result(RTWithViews) }) - Method("A", func() { + Method("B", func() { Result(RTWithViews, func() { View("tiny") }) diff --git a/codegen/service/transform_helper_operation_contract_test.go b/codegen/service/transform_helper_operation_contract_test.go new file mode 100644 index 0000000000..808e6b2711 --- /dev/null +++ b/codegen/service/transform_helper_operation_contract_test.go @@ -0,0 +1,265 @@ +// This file verifies recursive transform helpers retain the field-presence +// operation selected by each result view from planning through rendered calls. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type retainedTransformOperation struct { + conversion *viewConversionFacts + init *InitData + helper codegen.TransformHelper + definition *codegen.TransformFunctionData +} + +// TestRecursiveTransformHelpersRetainRequiredness catches required and +// optional recursive field operations that are collapsed because their named +// source and target types have the same origins. +func TestRecursiveTransformHelpersRetainRequiredness(t *testing.T) { + forwardPlan := recursiveTransformPlan(t, false) + operations := retainedRecursiveTransformOperations(t, forwardPlan) + required := operations[expr.DefaultView] + optional := operations["optional"] + require.NotNil(t, required) + require.NotNil(t, optional) + + require.NotSame(t, required.definition.Declaration, optional.definition.Declaration) + require.NotEqual(t, required.definition.Code, optional.definition.Code) + require.NotContains(t, required.definition.Code, "if v == nil") + require.Contains(t, optional.definition.Code, "if v == nil") + require.Contains(t, required.init.Code, required.definition.Declaration.Name()+"(") + require.Contains(t, optional.init.Code, optional.definition.Declaration.Name()+"(") + require.Same( + t, + required.helper.Declaration, + required.definition.Declaration, + ) + require.Same( + t, + optional.helper.Declaration, + optional.definition.Declaration, + ) + + reversePlan := recursiveTransformPlan(t, true) + reverseOperations := retainedRecursiveTransformOperations(t, reversePlan) + for _, view := range []string{expr.DefaultView, "optional"} { + require.Equal(t, operations[view].definition.Declaration.Name(), reverseOperations[view].definition.Declaration.Name()) + require.Equal(t, operations[view].definition.Code, reverseOperations[view].definition.Code) + } + + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + compileFiles = append(compileFiles, ExampleServiceFiles(forwardPlan)...) + compileGeneratedServiceFiles(t, "generated.local", compileFiles) + reverseCompileFiles := append([]*codegen.File(nil), reverseFiles...) + reverseCompileFiles = append(reverseCompileFiles, ExampleServiceFiles(reversePlan)...) + compileGeneratedServiceFiles(t, "generated.local", reverseCompileFiles) +} + +// TestRecursiveTransformHelpersRetainSiblingOccurrences catches package-name +// planning that collapses two optional fields because their recursive types +// share an authored origin and requiredness. +func TestRecursiveTransformHelpersRetainSiblingOccurrences(t *testing.T) { + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.sibling-tree", func() { + dsl.TypeName("SiblingTree") + dsl.Attribute("left", node) + dsl.Attribute("right", node) + dsl.View(expr.DefaultView, func() { + dsl.Attribute("left") + dsl.Attribute("right") + }) + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + projected := facts.projections[facts.methods[0]].types[0] + var conversion *viewConversionFacts + for _, candidate := range projected.conversions { + if !candidate.toResult && candidate.viewName == expr.DefaultView { + conversion = candidate + break + } + } + require.NotNil(t, conversion) + helpers := conversion.plan.Helpers() + require.Len(t, helpers, 2) + require.False(t, helpers[0].Required) + require.False(t, helpers[1].Required) + require.NotSame(t, helpers[0].Declaration, helpers[1].Declaration) + + serviceData := plan.Services().Get("Trees") + require.NotNil(t, serviceData) + var init *InitData + for _, projectedData := range serviceData.projectedTypes { + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + } + require.NotNil(t, init) + require.Len(t, init.Helpers, 2) + for _, helper := range helpers { + require.Contains(t, init.Code, helper.Declaration.Name()+"(") + var definition *codegen.TransformFunctionData + for _, candidate := range init.Helpers { + if candidate.ID == helper.ID { + definition = candidate + break + } + } + require.NotNil(t, definition) + require.Same(t, helper.Declaration, definition.Declaration) + } + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, "generated.local", files) +} + +// recursiveTransformPlan builds equivalent result designs in either field and +// view order, then completes their retained service planning lifecycle. +func recursiveTransformPlan(t *testing.T, reverse bool) *Plan { + t.Helper() + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.tree", func() { + dsl.TypeName("Tree") + requiredField := func() { + dsl.Attribute("required_node", node) + } + optionalField := func() { + dsl.Attribute("optional_node", node) + } + if reverse { + optionalField() + requiredField() + } else { + requiredField() + optionalField() + } + dsl.Required("required_node") + + requiredView := func() { + dsl.View(expr.DefaultView, func() { + dsl.Attribute("required_node") + }) + } + optionalView := func() { + dsl.View("optional", func() { + dsl.Attribute("optional_node") + }) + } + if reverse { + optionalView() + requiredView() + } else { + requiredView() + optionalView() + } + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + return retainedServicePlanForPackage(t, root, "generated.local/gen") +} + +// retainedRecursiveTransformOperations returns the service-to-view helper +// operation, its call binding, and its rendered definition for each view. +func retainedRecursiveTransformOperations(t *testing.T, plan *Plan) map[string]*retainedTransformOperation { + t.Helper() + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + data := plan.Services().Get("Trees") + require.NotNil(t, data) + + var projectedFacts *projectedTypeFacts + for _, candidate := range facts.projections[facts.methods[0]].types { + if candidate.pair.source.Name() == "Tree" { + projectedFacts = candidate + break + } + } + require.NotNil(t, projectedFacts) + var projectedData *ProjectedTypeData + for _, candidate := range data.projectedTypes { + if candidate.Type.Origin() == projectedFacts.pair.projected.Origin() { + projectedData = candidate + break + } + } + require.NotNil(t, projectedData) + + operations := make(map[string]*retainedTransformOperation) + for _, conversion := range projectedFacts.conversions { + if conversion.toResult { + continue + } + helpers := conversion.plan.Helpers() + require.NotEmpty(t, helpers, conversion.viewName) + var selected codegen.TransformHelper + required := conversion.viewName == expr.DefaultView + for _, helper := range helpers { + if helper.Required == required { + selected = helper + break + } + } + require.NotNil(t, selected.Declaration, conversion.viewName) + var init *InitData + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + require.NotNil(t, init, conversion.viewName) + var definition *codegen.TransformFunctionData + for _, helper := range init.Helpers { + if helper.ID == selected.ID { + definition = helper + break + } + } + require.NotNil(t, definition, conversion.viewName) + operations[conversion.viewName] = &retainedTransformOperation{ + conversion: conversion, + init: init, + helper: selected, + definition: definition, + } + } + return operations +} diff --git a/codegen/service/type_map_identity_contract_test.go b/codegen/service/type_map_identity_contract_test.go new file mode 100644 index 0000000000..2319387bda --- /dev/null +++ b/codegen/service/type_map_identity_contract_test.go @@ -0,0 +1,33 @@ +// This file verifies external type mappings follow the exact retained user +// type origin rather than a display name shared by unrelated declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestTypeMapMatchesExactRetainedUserType catches a mapping selected only +// because an unrelated service type has the same display name. +func TestTypeMapMatchesExactRetainedUserType(t *testing.T) { + serviceType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "service-shared", + } + mappedType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "mapped-shared", + } + facts := &serviceFacts{ + reachableTypes: map[expr.UserType]struct{}{serviceType.Origin(): {}}, + } + + require.NotSame(t, serviceType.Origin(), mappedType.Origin()) + require.True(t, typeMapMatchesFacts(&expr.TypeMap{User: serviceType}, facts)) + require.False(t, typeMapMatchesFacts(&expr.TypeMap{User: mappedType}, facts)) +} diff --git a/codegen/service/type_plan.go b/codegen/service/type_plan.go new file mode 100644 index 0000000000..a2cbfbfc8c --- /dev/null +++ b/codegen/service/type_plan.go @@ -0,0 +1,527 @@ +// This file collects retained service user-type and union emission facts before generated package names freeze. +package service + +import ( + "fmt" + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// planServiceTypeLayouts retains every field, pointer, tag, owner, and exact +// declaration used to spell core service types after names freeze. +func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + binder := serviceGoTypeBinder(rootTypes, generation) + plan := func(attribute *expr.AttributeExpr, owner string) (*codegen.GoTypePlan, error) { + if attribute == nil { + return nil, nil + } + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + } + userTypes := append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) + for _, userType := range userTypes { + layout, err := plan(userType.userType.Attribute(), userType.declaration.PackagePath()) + if err != nil { + return err + } + userType.layout = layout + reference, err := plan(&expr.AttributeExpr{Type: userType.userType}, facts.packagePath) + if err != nil { + return err + } + userType.reference = reference + } + for _, errorFacts := range facts.errorFacts { + layout, err := plan(errorFacts.attribute, facts.packagePath) + if err != nil { + return err + } + errorFacts.layout = layout + } + for _, method := range facts.orderedMethods { + for _, attribute := range []*methodAttributeFacts{ + method.payload, + method.streamingPayload, + method.result, + method.streamingResult, + } { + if attribute == nil { + continue + } + layout, err := plan(attribute.attribute, facts.packagePath) + if err != nil { + return err + } + attribute.layout = layout + if userType, ok := attribute.attribute.Type.(expr.UserType); ok { + _, attribute.normalized = generation.NormalizedMethodType(userType) + } + if layout.TypeDeclaration() != nil { + userType := attribute.attribute.Type.(expr.UserType) + definition, err := plan(userType.Attribute(), layout.Owner()) + if err != nil { + return err + } + attribute.definition = definition + } + } + for _, errorFacts := range method.errors { + layout, err := plan(errorFacts.attribute, facts.packagePath) + if err != nil { + return err + } + errorFacts.layout = layout + } + } + for _, interceptor := range append( + append([]*interceptorFacts(nil), facts.serverInterceptorFacts...), + facts.clientInterceptorFacts..., + ) { + if len(interceptor.methods) == 0 { + continue + } + method := interceptor.methods[0] + accesses := []struct { + selection *expr.AttributeExpr + parent *methodAttributeFacts + target *[]*interceptorAccessFacts + }{ + {interceptor.readPayload, method.payload, &interceptor.readPayloadFields}, + {interceptor.writePayload, method.payload, &interceptor.writePayloadFields}, + {interceptor.readResult, method.result, &interceptor.readResultFields}, + {interceptor.writeResult, method.result, &interceptor.writeResultFields}, + {interceptor.readStreamingPayload, method.streamingPayload, &interceptor.readStreamingPayloadFields}, + {interceptor.writeStreamingPayload, method.streamingPayload, &interceptor.writeStreamingPayloadFields}, + {interceptor.readStreamingResult, method.result, &interceptor.readStreamingResultFields}, + {interceptor.writeStreamingResult, method.result, &interceptor.writeStreamingResultFields}, + } + for _, access := range accesses { + planned, err := planInterceptorAccess(access.selection, access.parent, facts.packagePath, binder) + if err != nil { + return err + } + *access.target = planned + } + } + for _, union := range facts.unions { + if err := planUnionRenderFacts(union, binder, generation.Package(union.declaration.PackagePath())); err != nil { + return err + } + } + return nil +} + +// planUnionRenderFacts retains every semantic branch decision and exact type +// layout before generated names and import aliases freeze. +func planUnionRenderFacts(facts *unionFacts, binder codegen.GoTypeBinder, generatedPackage *codegen.GeneratedPackage) error { + facts.identity = codegen.NewUnionTypeID(facts.union) + facts.typeKey = facts.union.GetTypeKey() + facts.valueKey = facts.union.GetValueKey() + facts.branches = make([]*unionBranchFacts, len(facts.union.Values)) + for index, branch := range facts.union.Values { + declaration, err := generatedPackage.UnionBranch(facts.union, branch.Name) + if err != nil { + return err + } + layout, err := codegen.PlanGoType(branch.Attribute, codegen.GoTypePlanOptions{ + Owner: generatedPackage.ImportPath(), + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return err + } + primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(branch.Attribute.Type) + _, isUserType := branch.Attribute.Type.(expr.UserType) + _, hasCustomImport := layout.Import() + facts.branches[index] = &unionBranchFacts{ + name: branch.Name, + fieldName: codegen.Goify(branch.Name, true), + declaration: declaration, + layout: layout, + nilable: codegen.IsNilable(branch.Attribute.Type), + emitPrimitiveAlias: hasPrimitiveAlias && !isUserType && !hasCustomImport, + primitiveAliasType: primitiveAliasType, + } + } + return nil +} + +// planInterceptorAccess retains the selected generated field names, pointer +// behavior, and exact type layouts while the design expressions are inputs. +func planInterceptorAccess(selection *expr.AttributeExpr, parent *methodAttributeFacts, owner string, binder codegen.GoTypeBinder) ([]*interceptorAccessFacts, error) { + if selection == nil { + return nil, nil + } + object := expr.AsObject(selection.Type) + if object == nil { + return nil, fmt.Errorf("plan interceptor access: selection must be an object") + } + if len(*object) == 0 { + return nil, nil + } + result := make([]*interceptorAccessFacts, len(*object)) + for index, field := range *object { + attribute := parent.attribute.Find(field.Name) + if attribute == nil { + return nil, fmt.Errorf("plan interceptor access: attribute %q is not present in its method value", field.Name) + } + layout, err := codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return nil, err + } + result[index] = &interceptorAccessFacts{ + name: codegen.Goify(field.Name, true), + pointer: parent.attribute.IsPrimitivePointer(field.Name, true), + layout: layout, + } + } + return result, nil +} + +// serviceGoTypeBinder binds authored and normalized service occurrences to +// the package declarations selected during collection. +func serviceGoTypeBinder(rootTypes *rootTypeSet, generation *codegen.Generation) codegen.GoTypeBinder { + return func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + owner := request.InheritedOwner + if location := codegen.UserTypeLocation(request.Attribute.Type); location != nil { + owner = path.Join(generation.GenPkg(), location.RelImportPath) + } + generatedPackage := generation.Package(owner) + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration, err := generatedPackage.Type(rootTypes.canonical(userType)) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Type: declaration}, nil + case codegen.GoUnion: + union := request.Attribute.Type.(*expr.Union) + declaration, err := generatedPackage.Union(union) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported retained Go type kind %s", request.Kind) + } + } +} + +// collectServiceUnionFacts selects every service sum type once per generated +// package and retains the declaration allocated during planning. +func collectServiceUnionFacts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + seenTypes := make(map[plannedUserType]struct{}) + seenUnions := make(map[unionDataKey]struct{}) + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, facts.service, location, rootTypes, generation, seenTypes, seenUnions, &facts.unions) + } + for _, userType := range facts.userTypes { + if err := collect(&expr.AttributeExpr{Type: userType.userType}, userType.location); err != nil { + return err + } + } + for _, errorType := range facts.errorTypes { + if err := collect(&expr.AttributeExpr{Type: errorType.userType}, errorType.location); err != nil { + return err + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + var location *codegen.Location + if attribute != nil { + location = codegen.UserTypeLocation(attribute.Type) + } + if err := collect(attribute, location); err != nil { + return err + } + } + for _, methodError := range method.Errors { + if err := collect(methodError.AttributeExpr, codegen.UserTypeLocation(methodError.Type)); err != nil { + return err + } + } + } + return nil +} + +// collectUnionFacts recursively records union declarations while keeping +// unlocated nested types in the package inherited from their enclosing type. +func collectUnionFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, rootTypes *rootTypeSet, generation *codegen.Generation, seenTypes map[plannedUserType]struct{}, seenUnions map[unionDataKey]struct{}, unions *[]*unionFacts) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, service, location, rootTypes, generation, seenTypes, seenUnions, unions) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner := generation.Package(generatedPackagePath(generation.GenPkg(), service, typeLocation)) + key := plannedUserType{userType: rootTypes.canonical(actual), owner: owner} + if _, exists := seenTypes[key]; exists { + return nil + } + seenTypes[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, field := range sortedNamedAttributes(*actual) { + if err := recurse(field.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + packagePath := generatedPackagePath(generation.GenPkg(), service, location) + key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(actual)} + if _, exists := seenUnions[key]; !exists { + declaration, err := generation.Package(packagePath).Union(actual) + if err != nil { + return err + } + seenUnions[key] = struct{}{} + *unions = append(*unions, &unionFacts{ + union: actual, + identity: codegen.NewUnionTypeID(actual), + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: location, + declaration: declaration, + }) + } + for _, branch := range actual.Values { + if err := recurse(branch.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// typeMapMatchesFacts reports whether a mapping's user type belongs to the +// retained method or nested service types selected during collection. +func typeMapMatchesFacts(typeMap *expr.TypeMap, facts *serviceFacts) bool { + _, reachable := facts.reachableTypes[typeMap.User.Origin()] + return reachable +} + +// collectServiceTypeFacts selects the exact named types emitted for one +// service. Linking later formats these records without repeating reachability +// or package-ownership decisions. +func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, canonical *rootTypeSet, generation *codegen.Generation) error { + seen := make(map[userTypeDataKey]struct{}) + for _, serviceError := range facts.errors { + selected, err := collectUserTypeFacts(serviceError.AttributeExpr, facts.service, nil, canonical, generation, seen) + if err != nil { + return err + } + facts.errorTypes = append(facts.errorTypes, selected...) + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil { + continue + } + location := (*codegen.Location)(nil) + inner := attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + location = codegen.UserTypeLocation(userType) + if _, normalized := generation.NormalizedMethodType(userType); normalized || location == nil { + inner = userType.Attribute() + } + } + selected, err := collectUserTypeFacts(inner, facts.service, location, canonical, generation, seen) + if err != nil { + return err + } + facts.userTypes = append(facts.userTypes, selected...) + } + for _, methodError := range method.Errors { + selected, err := collectUserTypeFacts(methodError.AttributeExpr, facts.service, nil, canonical, generation, seen) + if err != nil { + return err + } + facts.errorTypes = append(facts.errorTypes, selected...) + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || attribute.Type == expr.Empty { + continue + } + if _, raw := attribute.Type.(*expr.Object); raw { + panic(fmt.Sprintf( + "service %q method %q declares a raw object type: codegen.NewGeneration must own the finalized design before generators read it", + facts.service.Name, method.Name)) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + declaration, err := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.service, codegen.UserTypeLocation(userType), + )).Type(userType) + if err != nil { + return err + } + seen[userTypeDataKey{origin: userType.Origin(), declaration: declaration}] = struct{}{} + } + } + } + for _, userType := range rootTypes { + services, forced := userType.Attribute().Meta["type:generate:force"] + if !forced || len(services) > 0 && !containsString(services, facts.service.Name) { + continue + } + selected, err := collectUserTypeFacts( + &expr.AttributeExpr{Type: userType}, facts.service, nil, canonical, generation, seen, + ) + if err != nil { + return err + } + facts.userTypes = append(facts.userTypes, selected...) + } + for _, userType := range facts.userTypes { + facts.reachableTypes[userType.userType.Origin()] = struct{}{} + } + return nil +} + +// collectUserTypeFacts recursively selects named types while carrying the +// package location inherited from an enclosing generated type. +func collectUserTypeFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation, seen map[userTypeDataKey]struct{}) ([]*userTypeFacts, error) { + if attribute == nil || attribute.Type == expr.Empty { + return nil, nil + } + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) ([]*userTypeFacts, error) { + return collectUserTypeFacts(attribute, service, location, canonical, generation, seen) + } + var result []*userTypeFacts + switch actual := attribute.Type.(type) { + case expr.UserType: + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + declaration, err := generation.Package( + generatedPackagePath(generation.GenPkg(), service, typeLocation), + ).Type(canonical.canonical(actual)) + if err != nil { + return nil, err + } + key := userTypeDataKey{origin: actual.Origin(), declaration: declaration} + if _, exists := seen[key]; exists { + return nil, nil + } + seen[key] = struct{}{} + result = append(result, &userTypeFacts{ + userType: actual, + name: actual.Name(), + description: actual.Attribute().Description, + errorName: retainedErrorName(actual), + serviceError: actual == expr.ErrorResult, + location: typeLocation, + declaration: declaration, + }) + nested, err := collect(actual.Attribute(), typeLocation) + return append(result, nested...), err + case *expr.Object: + for _, field := range *actual { + selected, err := collect(field.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + case *expr.Array: + return collect(actual.ElemType, location) + case *expr.Map: + key, err := collect(actual.KeyType, location) + if err != nil { + return nil, err + } + value, err := collect(actual.ElemType, location) + return append(key, value...), err + case *expr.Union: + for _, branch := range actual.Values { + if userType, generated := generatedUnionBranch(branch, canonical); generated && location != nil { + selected, err := collect(&expr.AttributeExpr{Type: userType}, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + continue + } + selected, err := collect(branch.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + } + return result, nil +} + +// retainedErrorName copies the exact Go expression returned by GoaErrorName +// before error metadata can be changed by a later generator phase. +func retainedErrorName(userType expr.UserType) string { + if object := expr.AsObject(userType); object != nil { + for _, field := range *object { + if _, ok := field.Attribute.Meta["struct:error:name"]; ok { + return fmt.Sprintf("e.%s", codegen.GoifyAtt(field.Attribute, field.Name, true)) + } + } + } + if value, ok := userType.Attribute().Meta["struct:error:name"]; ok { + return fmt.Sprintf("%q", value[0]) + } + return fmt.Sprintf("%q", userType.Name()) +} + +// containsString reports whether values contains target without introducing a +// second service-selection representation. +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/codegen/service/view_data.go b/codegen/service/view_data.go new file mode 100644 index 0000000000..fe22d3f1dd --- /dev/null +++ b/codegen/service/view_data.go @@ -0,0 +1,566 @@ +// This file formats retained projected types, views, constructors, validators, and their exact declaration references. +package service + +import ( + "bytes" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// projectTypePairs rewrites a copied result graph into pointer-backed view +// types and returns each generated declaration with its exact source. The +// source Origin makes independently rebuilt plan and render graphs select the +// same package record. +func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) []*projectedTypePair { + collect := func(projected, source *expr.AttributeExpr) []*projectedTypePair { + return projectTypePairs(projected, source, seen) + } + switch projectedType := projected.Type.(type) { + case expr.UserType: + sourceType := source.Type.(expr.UserType) + origin := sourceType.Origin() + if existing, ok := seen[origin]; ok { + if existing != nil { + projected.Type = existing + } + return nil + } + seen[origin] = nil + projectedType.Rename(projectedType.Name() + "View") + nested := collect(projectedType.Attribute(), sourceType.Attribute()) + seen[origin] = projectedType + return append([]*projectedTypePair{{ + source: sourceType, + projected: projectedType, + sourceAttribute: source, + projectedAttribute: projected, + }}, nested...) + case *expr.Array: + return collect(projectedType.ElemType, source.Type.(*expr.Array).ElemType) + case *expr.Map: + sourceMap := source.Type.(*expr.Map) + pairs := collect(projectedType.KeyType, sourceMap.KeyType) + return append(pairs, collect(projectedType.ElemType, sourceMap.ElemType)...) + case *expr.Object: + sourceObject := source.Type.(*expr.Object) + var pairs []*projectedTypePair + for _, field := range *projectedType { + pairs = append(pairs, collect(field.Attribute, sourceObject.Attribute(field.Name))...) + } + return pairs + case *expr.Union: + sourceUnion := source.Type.(*expr.Union) + var pairs []*projectedTypePair + for index, branch := range projectedType.Values { + pairs = append(pairs, collect(branch.Attribute, sourceUnion.Values[index].Attribute)...) + } + return pairs + default: + return nil + } +} + +// projectedResultRoot returns the root attribute used to collect projected +// view types for m.Result. Compiler-created method wrappers retain their exact +// provenance in generation, so authored types with matching text stay intact. +func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { + if _, normalized := generation.NormalizedMethodType(ut); !normalized { + return expr.DupAtt(m.Result), m.Result + } + return expr.DupAtt(ut.Attribute()), ut.Attribute() + } + return expr.DupAtt(m.Result), m.Result +} + +// hasResultType returns true if the given attribute has a result type recursively. +func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { + if _, ok := att.Type.(*expr.ResultTypeExpr); ok { + return true + } + var seen map[expr.UserType]struct{} + if len(seens) > 0 { + seen = seens[0] + } else { + seen = make(map[expr.UserType]struct{}) + } + switch a := att.Type.(type) { + case expr.UserType: + origin := a.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasResultType(a.Attribute(), seen) + case *expr.Array: + return hasResultType(a.ElemType, seen) + case *expr.Map: + return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) + case *expr.Object: + for _, nat := range *a { + if hasResultType(nat.Attribute, seen) { + return true + } + } + case *expr.Union: + for _, nat := range a.Values { + if hasResultType(nat.Attribute, seen) { + return true + } + } + } + return false +} + +// buildProjectedType returns the render data for one pointer-backed view +// declaration and its conversions to the exact source service type. +func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ProjectedTypeData { + var ( + projections []*InitData + typeInits []*InitData + views []*ViewData + + varname = declaration.Name() + pt = facts.projectedType + ) + if facts.resultType { + typeInits = buildViewConversions(facts, serviceResolver, viewResolver, true) + projections = buildViewConversions(facts, serviceResolver, viewResolver, false) + serviceName := facts.source.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases), + ).Name() + views = buildViews(facts.views, serviceName, facts.mapDeclaration, facts.conversions) + } + validations := buildValidations(facts, viewResolver) + linked := facts.projected.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases)) + definition := facts.definition.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases)) + return &ProjectedTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: varname, + Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), + VarName: varname, + Def: definition.Def(), + Ref: linked.Ref(), + Type: pt, + }, + Projections: projections, + TypeInits: typeInits, + Validations: validations, + Views: views, + } +} + +// buildViews builds the view data for all the views in the given result type. +func buildViews(facts []*viewRenderFacts, typeName string, mapDeclaration *codegen.NameDeclaration, conversions []*viewConversionFacts) []*ViewData { + toProjected := make(map[string]*codegen.NameDeclaration) + toResult := make(map[string]*codegen.NameDeclaration) + for _, conversion := range conversions { + calls := toProjected + if conversion.toResult { + calls = toResult + } + calls[canonicalValidatorView(conversion.viewName)] = conversion.constructor + } + views := make([]*ViewData, len(facts)) + for i, view := range facts { + views[i] = &ViewData{ + Name: view.name, + Description: view.description, + Attributes: append([]string(nil), view.attributes...), + TypeVarName: typeName, + MapDeclaration: mapDeclaration, + ToProjected: toProjected[canonicalValidatorView(view.name)], + ToResult: toResult[canonicalValidatorView(view.name)], + } + } + return views +} + +// buildViewedResultType formats the retained viewed result wrapper and its +// constructors without consulting the mutable design expression. +func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { + isarr := facts.isCollection + viewName := facts.viewName + views := buildViews(facts.views, declaration.Name(), facts.mapDeclaration, facts.conversions) + + // build validation data + qualifier := retainedTypeQualifier(serviceResolver.aliases) + serviceType := facts.source.layout.Link(serviceResolver.outputPath, qualifier) + resvar, serviceRef := declaration.Name(), serviceType.Ref() + projT := facts.wrapped + wrapperViewType := facts.wrappedLayout.Link(viewResolver.outputPath, qualifier) + resref := wrapperViewType.Name() + if !isarr { + resref = "*" + resref + } + validationCalls := make([]*ValidationCallData, len(facts.views)) + for index, view := range facts.views { + validationCalls[index] = newRetainedValidationCall(facts.validationCalls[index], view.name) + } + data := map[string]any{ + "ArgVar": "result", + "Source": "result", + "ValidationCalls": validationCalls, + "IsViewed": true, + } + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + validatorDeclaration := facts.validator + name := validatorDeclaration.Name() + validate := &ValidateData{ + Declaration: validatorDeclaration, + Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), + Ref: resref, + Validate: buf.String(), + Calls: validationCalls, + } + + // build constructor to initialize viewed result type from result type + wrapperServiceType := facts.wrappedLayout.Link(serviceResolver.outputPath, qualifier) + vresref := wrapperServiceType.Name() + if !isarr { + vresref = "*" + vresref + } + data = map[string]any{ + "ToViewed": true, + "ArgVar": "res", + "ReturnVar": "vres", + "Views": views, + "ReturnTypeRef": vresref, + "IsCollection": isarr, + "TargetType": wrapperServiceType.Name(), + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toViewed.Name() + init := &InitData{ + Declaration: facts.toViewed, + Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), + Args: []*InitArgData{ + {Name: "res", Ref: serviceRef}, + {Name: "view", Ref: "string"}, + }, + ReturnTypeRef: vresref, + Code: buf.String(), + } + + // build constructor to initialize result type from viewed result type + resref = serviceRef + data = map[string]any{ + "ToResult": true, + "ArgVar": "vres", + "ReturnVar": "res", + "Views": views, + "ReturnTypeRef": resref, + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toResult.Name() + resinit := &InitData{ + Declaration: facts.toResult, + Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: vresref}}, + ReturnTypeRef: resref, + Code: buf.String(), + } + + return &ViewedResultTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: resvar, + Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), + VarName: resvar, + Def: facts.wrappedDef.Link(viewResolver.outputPath, qualifier).Def(), + Ref: resref, + Type: projT, + }, + FullName: wrapperServiceType.Name(), + FullRef: vresref, + ResultInit: resinit, + Init: init, + Views: views, + Validate: validate, + IsCollection: isarr, + ViewName: viewName, + ViewsPkg: viewspkg, + } +} + +// wrapProjected builds a viewed result type by wrapping the given projected +// in a result type with "projected" and "view" attributes. +func wrapProjected(projected expr.UserType) expr.UserType { + rt := projected.(*expr.ResultTypeExpr) + pratt := &expr.NamedAttributeExpr{ + Name: "projected", + Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, + } + prview := &expr.NamedAttributeExpr{ + Name: "view", + Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, + } + return &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{pratt, prview}, + Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, + }, + TypeName: rt.TypeName, + }, + Identifier: rt.Identifier, + Views: rt.Views, + } +} + +// buildViewConversions builds the data to generate the constructor code that +// converts between a result type and its projected type, one constructor per +// view. When toResult is true the constructors initialize the result type from +// the projected type, otherwise they project the result type to the projected +// type based on the view. +func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { + init := make([]*InitData, 0, len(facts.conversions)/2) + serviceType := facts.source.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases)) + serviceName, serviceRef := serviceType.Name(), serviceType.Ref() + projectedDeclaration := facts.declaration + projectedRef := facts.projected.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases)).Ref() + serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) + for _, conversion := range facts.conversions { + if conversion.toResult != toResult { + continue + } + viewedResolver := serviceViewResolver.bindDerived(conversion.contextType, conversion.contextIdentity) + if conversion.elementType != nil { + viewedResolver = viewedResolver.bindDerived(conversion.elementType, conversion.elementIdentity) + } + targetType := conversion.targetLayout.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases), + ).Name() + if toResult { + srcCtx := declarationContext(viewedResolver, true) + tgtCtx := declarationContext(serviceResolver, false) + resvar := serviceName + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "vres", + "res", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: projectedRef}}, + ReturnTypeRef: serviceRef, + Code: code, + Helpers: helpers, + }) + } else { + srcCtx := declarationContext(serviceResolver, false) + tgtCtx := declarationContext(viewedResolver, true) + tname := projectedDeclaration.Name() + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "res", + "vres", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, conversion.viewName), + Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, + ReturnTypeRef: projectedRef, + Code: code, + Helpers: helpers, + }) + } + } + return init +} + +// buildValidations builds the data required to generate validations for the +// projected types. +func buildValidations(projected *projectedTypeFacts, resolver *declarationResolver) []*ValidateData { + linkedType := projected.projected.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + tname := linkedType.Name() + var validations []*ValidateData + if projected.resultType { + // for result types we create a validation function containing view + // specific validation logic for each view + for _, facts := range projected.validations { + viewName := facts.viewName + data := map[string]any{ + "Projected": tname, + "ArgVar": "result", + "Source": "result", + "IsCollection": facts.collectionElem != nil, + } + declaration := facts.declaration + name := declaration.Name() + var calls []*ValidationCallData + + if facts.collectionElem != nil { + // dealing with an array type + data["Source"] = "item" + call := newRetainedValidationCall(facts.collectionCall, viewName) + data["ValidateCall"] = call + calls = append(calls, call) + } else { + fields := make([]*validationFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + call := newRetainedValidationCall(field.call, field.view) + fields = append(fields, &validationFieldData{ + Name: field.name, + Call: call, + IsRequired: field.required, + }) + calls = append(calls, call) + } + data["Validate"] = renderRetainedValidation(facts, resolver) + data["Fields"] = fields + } + + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + + validations = append(validations, &ValidateData{ + Declaration: declaration, + Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, viewName), + Ref: linkedType.Ref(), + Validate: buf.String(), + Calls: calls, + }) + } + } else { + // for a user type or a result type with single view, we generate only one validation + // function containing the validation logic + facts := projected.validations[0] + declaration := facts.declaration + name := declaration.Name() + validations = append(validations, &ValidateData{ + Declaration: declaration, + Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), + Ref: linkedType.Ref(), + Validate: renderRetainedValidation(facts, resolver), + }) + } + return validations +} + +// renderRetainedValidation formats a symbolic validation plan against the +// frozen view-package declarations and aliases. +func renderRetainedValidation(facts *validationFacts, resolver *declarationResolver) string { + linkedLayout := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + linked, err := facts.plan.Link(linkedLayout) + if err != nil { + panic(err) // bug + } + return linked.Render("result", "result") +} + +// newRetainedValidationCall formats one nested call from the exact declaration +// bound during planning. +func newRetainedValidationCall(declaration *codegen.NameDeclaration, view string) *ValidationCallData { + return &ValidationCallData{ + Declaration: declaration, + View: view, + Default: canonicalValidatorView(view) == "", + } +} + +// newValidationCall binds a nested call spelling to the exact validator +// declaration retained for attribute and view. +// buildConstructorCode builds the transformation code to create a projected +// type from a service type and vice versa. +// +// source and target contains the projected/service contextual attributes +// +// sourceVar and targetVar contains the variable name that holds the source and +// target data structures in the transformation code. +// +// view is used to generate the constructor function name. +func buildConstructorCode(facts *viewConversionFacts, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, targetType string) (string, []*codegen.TransformFunctionData) { + var ( + helpers []*codegen.TransformFunctionData + buf bytes.Buffer + ) + data := map[string]any{ + "ArgVar": sourceVar, + "ReturnVar": targetVar, + "IsCollection": facts.collection, + "TargetType": targetType, + } + + if facts.collection { + // result type collection + data["Init"] = facts.elementCall + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers + } + + data["Source"] = sourceVar + data["Target"] = targetVar + + if err := facts.plan.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + code, helpers, err := facts.plan.Render(sourceVar, targetVar, true) + if err != nil { + panic(err) // bug + } + data["Code"] = code + + fields := make([]*constructorFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + fields = append(fields, &constructorFieldData{ + VarName: codegen.Goify(field.name, true), + Declaration: field.call, + }) + } + data["Fields"] = fields + + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers +} + +// walkViewAttrs iterates through the attributes in att that are found in the +// given view and executes the walker function. +func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { + for _, nat := range *expr.AsObject(view.Type) { + if attr := obj.Attribute(nat.Name); attr != nil { + walker(nat.Name, attr, nat.Attribute) + } + } +} + +// removeMeta removes the meta attributes from the given attribute. This is +// needed to make sure that any field name overriding is removed when +// generating protobuf types (as protogen itself won't honor these overrides). +func removeMeta(att *expr.AttributeExpr) { + if err := codegen.Walk(att, func(a *expr.AttributeExpr) error { + delete(a.Meta, "struct:pkg:path") + return nil + }); err != nil { + panic(err) // bug + } +} diff --git a/codegen/service/view_validation_plan.go b/codegen/service/view_validation_plan.go new file mode 100644 index 0000000000..d7499c8405 --- /dev/null +++ b/codegen/service/view_validation_plan.go @@ -0,0 +1,235 @@ +// This file binds projected service validation rules to exact view-package +// layouts and validator declarations before generated names freeze. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// planServiceValidations retains every rule and nested validator call emitted +// by core service view validation after all view declarations exist. +func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + hasProjection := false + for _, method := range facts.orderedMethods { + hasProjection = hasProjection || method.projection != nil + } + if !hasProjection { + return nil + } + views := generation.Package(facts.viewsPath) + derived := make(map[expr.UserType]*codegen.TypeDeclaration) + wrappers := make(map[expr.UserType]*codegen.TypeDeclaration) + for _, method := range facts.orderedMethods { + if method.viewedResult != nil { + wrappers[method.viewedResult.wrapped] = method.viewedResult.declaration + } + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + declaration, err := views.DerivedType(codegen.NewProjectedTypeID(projected.pair.source)) + if err != nil { + return err + } + derived[projected.pair.projected.Origin()] = declaration + } + } + binder := func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration := wrappers[userType] + if declaration == nil { + declaration = derived[userType.Origin()] + } + if declaration == nil { + var err error + declaration, err = views.Type(userType) + if err != nil { + return codegen.GoTypeBinding{}, err + } + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: declaration}, nil + case codegen.GoUnion: + declaration, err := views.Union(request.Attribute.Type.(*expr.Union)) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported view validation type %s", request.Kind) + } + } + viewPolicy := codegen.GoLayoutPolicy{ + Pointer: true, + UseDefault: true, + SumType: true, + } + planLayout := func(attribute *expr.AttributeExpr, pointer bool) (*codegen.GoTypePlan, error) { + policy := viewPolicy + policy.Pointer = pointer + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: facts.viewsPath, + Policy: policy, + Bind: binder, + }) + } + validator := func(request codegen.ValidatorBindingRequest) (*codegen.NameDeclaration, error) { + declaration := request.Layout.TypeDeclaration() + retained := facts.validators[validatorKey{ + declaration: declaration, + view: canonicalValidatorView(request.View), + }] + if retained == nil { + return nil, fmt.Errorf( + "validator for declaration %p and view %q was not retained", + declaration, + request.View, + ) + } + return retained, nil + } + validatorCall := func(attribute *expr.AttributeExpr, view string) (*codegen.NameDeclaration, error) { + layout, err := planLayout(attribute, true) + if err != nil { + return nil, err + } + return validator(codegen.ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: view, + }) + } + for _, method := range facts.orderedMethods { + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + projected.resultType = len(projected.views) > 0 + layout, err := planLayout(projected.pair.projectedAttribute, true) + if err != nil { + return err + } + definition, err := planLayout(projected.pair.projected.Attribute(), true) + if err != nil { + return err + } + source, err := codegen.PlanGoType(projected.pair.sourceAttribute, codegen.GoTypePlanOptions{ + Owner: facts.packagePath, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: serviceGoTypeBinder(rootTypes, generation), + }) + if err != nil { + return err + } + projected.projected = layout + projected.definition = definition + projected.source = source + for _, conversion := range projected.conversions { + conversion.collection = expr.AsArray(conversion.target.Type) != nil + conversionBinder := binder + conversionOwner := facts.viewsPath + if conversion.toResult { + conversionBinder = serviceGoTypeBinder(rootTypes, generation) + conversionOwner = facts.packagePath + } else { + viewBinder := conversionBinder + targetType := conversion.target.Type.(expr.UserType) + conversionBinder = func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + if request.Kind == codegen.GoNamed && request.Attribute.Type == targetType { + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: projected.declaration}, nil + } + return viewBinder(request) + } + } + conversion.targetLayout, err = codegen.PlanGoType(conversion.target, codegen.GoTypePlanOptions{ + Owner: conversionOwner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: conversionBinder, + }) + if err != nil { + return err + } + context := conversion.target + if conversion.toResult { + context = conversion.source + } + conversion.contextType = context.Type.(expr.UserType) + conversion.contextIdentity = codegen.NewProjectedTypeID(projected.pair.source) + if projectedArray := expr.AsArray(projected.pair.projectedAttribute.Type); projectedArray != nil { + conversion.elementType = expr.AsArray(context.Type).ElemType.Type.(expr.UserType) + conversion.elementIdentity = codegen.NewProjectedTypeID( + expr.AsArray(projected.pair.sourceAttribute.Type).ElemType.Type.(expr.UserType), + ) + } + } + for _, validation := range projected.validations { + if validation.collectionElem != nil { + declaration, err := validatorCall(validation.collectionElem, validation.viewName) + if err != nil { + return err + } + validation.collectionCall = declaration + continue + } + layout, err := planLayout(validation.attribute, validation.pointer) + if err != nil { + return err + } + plan, err := codegen.NewValidationPlan( + validation.attribute, + layout, + codegen.ValidationPlanOptions{ + Required: true, + Alias: validation.alias, + Bind: validator, + }, + ) + if err != nil { + return err + } + validation.layout = layout + validation.plan = plan + for _, field := range validation.fields { + declaration, err := validatorCall(field.attribute, field.view) + if err != nil { + return err + } + field.call = declaration + } + } + } + viewed := method.viewedResult + if viewed == nil { + continue + } + wrapped, err := planLayout(&expr.AttributeExpr{Type: viewed.wrapped}, false) + if err != nil { + return err + } + if wrapped.TypeDeclaration() != viewed.declaration { + return fmt.Errorf("viewed result %q layout was bound to the wrong declaration", viewed.wrapped.Name()) + } + wrappedDef, err := planLayout(viewed.wrapped.Attribute(), false) + if err != nil { + return err + } + viewed.wrappedLayout = wrapped + viewed.wrappedDef = wrappedDef + } + for _, union := range facts.viewUnions { + if err := planUnionRenderFacts(union, binder, views); err != nil { + return err + } + } + return nil +} diff --git a/codegen/service/views.go b/codegen/service/views.go index df2818ace0..29ab133725 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -4,73 +4,29 @@ package service import ( "path/filepath" - "sort" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type viewedType struct { - // Name is the type name. - Name string + // Declaration is the exact package-level view map rendered for this type. + Declaration *codegen.NameDeclaration + // TypeName is the generated type whose fields the map indexes. + TypeName string // Views is the view data for all views defined in the type. Views []*ViewData } -// ViewsFile returns the views file for the given service which contains -// logic to render result types using the defined views. -func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// viewsFile renders the views for the exact service retained by plan. +func viewsFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) if len(svc.projectedTypes) == 0 { return nil } - // Collect union sum-type definitions for the views package. - // - // View-projected types cannot import the service package (which already - // depends on views), therefore unions must be generated in the views package - // when referenced by projected types. - unionByHash := make(map[unionDataKey]*UnionTypeData) - seenUnions := make(map[expr.UserType]struct{}) - viewLoc := &codegen.Location{RelImportPath: "views"} - resolver := newViewResolver(services.generation, services.aliases, service, svc.viewDerived) - for _, t := range svc.projectedTypes { - if err := services.collectUnionTypes( - &expr.AttributeExpr{Type: t.Type}, - service, - resolver, - viewLoc, - unionByHash, - seenUnions, - true, - ); err != nil { - panic(err) // bug - } - } - unions := make([]*UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go") - outputPackage := genpkg + "/" + svc.PathName + "/views" - importPaths := []string{codegen.GoaImport("").Path, "unicode/utf8"} - if len(unions) > 0 { - importPaths = append(importPaths, "bytes", "encoding/json", "fmt") - } - var attributes []*expr.AttributeExpr - for _, viewed := range svc.viewedResultTypes { - attributes = append(attributes, viewed.Type.Attribute()) - } - for _, projected := range svc.projectedTypes { - attributes = append(attributes, projected.Type.Attribute()) - } - imports := services.fileImports(outputPackage, importPaths, attributes...) - header := codegen.Header(service.Name+" views", "views", - imports) + header := codegen.Header(facts.name+" views", "views", facts.imports.views.specs) sections := []*codegen.SectionTemplate{header} // type definitions @@ -88,7 +44,7 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) Data: t.UserTypeData, }) } - for _, u := range unions { + for _, u := range svc.viewUnions { sections = append(sections, &codegen.SectionTemplate{ Name: "projected-union-type", Source: serviceTemplates.Read(unionTypeT), @@ -100,23 +56,31 @@ func ViewsFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) // rendered in the view as value. var ( rtdata []*viewedType - seen = make(map[string]struct{}) + seen = make(map[*codegen.NameDeclaration]struct{}) ) for _, t := range svc.viewedResultTypes { - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } for _, t := range svc.projectedTypes { if len(t.Views) == 0 { continue } - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } sections = append(sections, &codegen.SectionTemplate{ diff --git a/codegen/service/views_test.go b/codegen/service/views_test.go index ec887bf09b..22fe4cf82c 100644 --- a/codegen/service/views_test.go +++ b/codegen/service/views_test.go @@ -34,9 +34,9 @@ func TestViews(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := mustServicesData(t, root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ViewsFile("goa.design/goa/example", root.Services[0], services) + fs := viewsFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/templates/transform_go_array.go.tpl b/codegen/templates/transform_go_array.go.tpl index 99119fd38a..e88ac974b9 100644 --- a/codegen/templates/transform_go_array.go.tpl +++ b/codegen/templates/transform_go_array.go.tpl @@ -1,10 +1,12 @@ {{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for {{ .LoopVar }}, val := range {{ .SourceVar }} { -{{ if .IsStruct -}} +{{ if .SourceIsObject -}} if val == nil { {{ .TargetVar }}[{{ .LoopVar }}] = nil continue } +{{ end -}} +{{ if .UseHelper -}} {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}(val) {{ else -}} {{ transformAttribute .SourceElem .TargetElem "val" (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} diff --git a/codegen/templates/transform_go_map.go.tpl b/codegen/templates/transform_go_map.go.tpl index cc4715a029..ff646fd463 100644 --- a/codegen/templates/transform_go_map.go.tpl +++ b/codegen/templates/transform_go_map.go.tpl @@ -1,14 +1,16 @@ {{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}map[{{ .KeyTypeRef }}]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for key, val := range {{ .SourceVar }} { -{{ if .IsKeyStruct -}} - tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(val) +{{ if .UseKeyHelper -}} + tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(key) {{ else -}} {{ transformAttribute .SourceKey .TargetKey "key" "tk" true .TransformAttrs }}{{ end -}} -{{ if .IsElemStruct -}} +{{ if .ElemIsObject -}} if val == nil { {{ .TargetVar }}[tk] = nil continue } +{{ end -}} +{{ if .UseElemHelper -}} {{ .TargetVar }}[tk] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs -}}(val) {{ else -}} {{ transformAttribute .SourceElem .TargetElem "val" (printf "tv%s" .LoopVar) true .TransformAttrs -}} diff --git a/codegen/templates/validation/union.go.tpl b/codegen/templates/validation/union.go.tpl index 9460731efe..2abda6cf35 100644 --- a/codegen/templates/validation/union.go.tpl +++ b/codegen/templates/validation/union.go.tpl @@ -1,6 +1,18 @@ -switch v := {{ .target }}.(type) { -{{- range $i, $val := .values }} - case {{ index $.types $i }}: - {{ $val }} +switch v := {{ .Target }}.(type) { +{{- range .Cases }} + case {{ .Type }}: + {{- if $.Protobuf }} + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Context }})) + break + } + {{- if .PayloadRequiresPresence }} + if v.{{ .Field }} == nil { + err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Context }})) + break + } + {{- end }} + {{- end }} + {{ .Validation }} {{ end -}} -} \ No newline at end of file +} diff --git a/codegen/templates/validation/user.go.tpl b/codegen/templates/validation/user.go.tpl index cee4c6c8f6..c85d5202cd 100644 --- a/codegen/templates/validation/user.go.tpl +++ b/codegen/templates/validation/user.go.tpl @@ -1,3 +1,4 @@ -if err2 := Validate{{ .name }}({{ .target }}); err2 != nil { +if err2 := {{ .name }}({{ .target }}); err2 != nil { err = goa.MergeErrors(err, err2) -} \ No newline at end of file +} +{{- "" -}} diff --git a/codegen/testdata/golden/validation_float-pointer.go.golden b/codegen/testdata/golden/validation_float-pointer.go.golden index 51de19e361..80ca882e8e 100644 --- a/codegen/testdata/golden/validation_float-pointer.go.golden +++ b/codegen/testdata/golden/validation_float-pointer.go.golden @@ -23,8 +23,8 @@ func Validate() (err error) { } } if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-required.go.golden b/codegen/testdata/golden/validation_float-required.go.golden index 11a198a4eb..e3390a1534 100644 --- a/codegen/testdata/golden/validation_float-required.go.golden +++ b/codegen/testdata/golden/validation_float-required.go.golden @@ -18,8 +18,8 @@ func Validate() (err error) { } } if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-use-default.go.golden b/codegen/testdata/golden/validation_float-use-default.go.golden index 92efc1c091..cb21d4b8ac 100644 --- a/codegen/testdata/golden/validation_float-use-default.go.golden +++ b/codegen/testdata/golden/validation_float-use-default.go.golden @@ -16,8 +16,8 @@ func Validate() (err error) { } } if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-pointer.go.golden b/codegen/testdata/golden/validation_integer-pointer.go.golden index 2386735ad4..b0af595b72 100644 --- a/codegen/testdata/golden/validation_integer-pointer.go.golden +++ b/codegen/testdata/golden/validation_integer-pointer.go.golden @@ -23,8 +23,8 @@ func Validate() (err error) { } } if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-required.go.golden b/codegen/testdata/golden/validation_integer-required.go.golden index 84a979e80b..3c59c8b777 100644 --- a/codegen/testdata/golden/validation_integer-required.go.golden +++ b/codegen/testdata/golden/validation_integer-required.go.golden @@ -18,8 +18,8 @@ func Validate() (err error) { } } if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-use-default.go.golden b/codegen/testdata/golden/validation_integer-use-default.go.golden index 9bc2be4599..3721d4d314 100644 --- a/codegen/testdata/golden/validation_integer-use-default.go.golden +++ b/codegen/testdata/golden/validation_integer-use-default.go.golden @@ -16,8 +16,8 @@ func Validate() (err error) { } } if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/transformer.go b/codegen/transformer.go index da76376ab0..9fef332d4d 100644 --- a/codegen/transformer.go +++ b/codegen/transformer.go @@ -29,6 +29,9 @@ type ( Enter(att *expr.AttributeExpr) Attributor // IsSumType reports whether unions use Goa's generated sum-type layout. IsSumType() bool + // ValidatorName returns the package-level validation function for att and + // the selected result-type view. + ValidatorName(att *expr.AttributeExpr, view string) string } // AttributeContext contains properties which impacts the code generating @@ -74,7 +77,38 @@ type ( // Hooks are optional generator specific extension points // consulted by the transform engine. Nil selects the engine // defaults. - Hooks *TransformHooks + Hooks *TransformHooks + helpers map[TransformHelperID]TransformHelper + calls *transformCallCursor + } + + // TransformHelperID identifies one recursive helper selected by a transform + // plan. Its representation is deliberately private: generators may compare + // IDs or use them as map keys but cannot reconstruct them from generated + // names. + TransformHelperID struct { + plan *TransformPlan + index int + } + + // TransformHelper describes one recursive source-to-target operation + // retained by a transform plan. Render uses its ID and declaration for both + // calls and definitions. + TransformHelper struct { + // ID is the opaque identity owned by the transform plan. + ID TransformHelperID + // Source is the exact source attribute selected during planning. + Source *expr.AttributeExpr + // Target is the exact target attribute selected during planning. + Target *expr.AttributeExpr + // Required reports whether nil is rejected by the helper operation. + Required bool + // Occurrence is the one-based position of this helper operation in the + // transform plan's stable traversal. + Occurrence int + // Declaration is the canonical package-level function bound before render. + // Render rejects an unbound helper. + Declaration *NameDeclaration } // TransformFunctionData describes a helper function used to transform @@ -95,10 +129,58 @@ type ( // } // TransformFunctionData struct { - Name string - ParamTypeRef string + // ID is the retained helper identity used to render this definition. + ID TransformHelperID + // Declaration is the canonical package declaration for retained transforms. + // It is nil for the separate one-pass transform API. + Declaration *NameDeclaration + // Name is the generated helper name for staged legacy callers. It is empty + // when Declaration owns the final name. + Name string + // ParamTypeRef is the generated Go reference to the helper parameter type. + ParamTypeRef string + // ResultTypeRef is the generated Go reference to the helper result type. ResultTypeRef string - Code string + // Code is the helper body. + Code string + } + + // TransformPlan retains the exact source-target operations and recursive + // helpers selected for one Go transformation. Generators build the plan + // before package names freeze and render it afterward with contexts that + // resolve the final declarations. + TransformPlan struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + sourceCtx *AttributeContext + targetCtx *AttributeContext + helpers []TransformHelper + operations []*transformOperation + } + + // transformPair identifies one recursive source-target operation by its + // expression declarations rather than a provisional helper spelling. + transformPair struct { + source expr.DataType + target expr.DataType + } + + // transformOperation retains the ordered helper calls made while rendering + // the top-level transform or one helper body. + transformOperation struct { + calls []transformCall + } + + // transformCall binds one ordered call edge to the helper that renders its + // conversion. + transformCall struct { + helper TransformHelperID + } + + // transformCallCursor tracks the retained calls consumed by one render. + transformCallCursor struct { + calls []transformCall + next int } ) @@ -161,7 +243,7 @@ func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData for _, h := range newH { found := false for _, h2 := range oldH { - if h.Name == h2.Name { + if sameTransformHelper(h, h2) { found = true break } @@ -173,6 +255,15 @@ func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData return oldH } +// sameTransformHelper compares canonical declarations for catalog-backed +// helpers and generated names for staged legacy helpers. +func sameTransformHelper(left, right *TransformFunctionData) bool { + if left.Declaration != nil || right.Declaration != nil { + return left.ID == right.ID + } + return left.Name == right.Name +} + // MapDepth returns the level of nested maps. For unnested maps, it returns 0. func MapDepth(m *expr.Map) int { return mapDepth(m.ElemType.Type, 0) @@ -311,6 +402,12 @@ func (a *AttributeScope) Package(att *expr.AttributeExpr) string { return a.pkg } +// ValidatorName returns the deterministic validator convention used by +// generators whose names are already isolated in a private transport scope. +func (a *AttributeScope) ValidatorName(att *expr.AttributeExpr, view string) string { + return "Validate" + a.Name(att, "", false, true) + Goify(view, true) +} + // Enter returns a scope whose default qualifier follows att's explicit type // location. The underlying name scope remains unchanged. func (a *AttributeScope) Enter(att *expr.AttributeExpr) Attributor { diff --git a/codegen/validation.go b/codegen/validation.go index ce1a5bab40..1cba8c4ee1 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -12,6 +12,37 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // unionValidationCase describes one concrete type accepted by a generated + // interface-union switch. + unionValidationCase struct { + // Type is the generated concrete branch type. + Type string + // Field is the protobuf wrapper field that carries the branch payload. + Field string + // Name is the design branch name used in validation errors. + Name string + // PayloadRequiresPresence reports whether a selected wrapper must carry + // a non-nil message payload. + PayloadRequiresPresence bool + // Validation is the branch-specific validation code. + Validation string + } + + // unionValidationData is the complete render input for one interface-union + // validation switch. + unionValidationData struct { + // Target is the generated union expression inspected by the switch. + Target string + // Context identifies the union in generated validation errors. + Context string + // Protobuf reports whether cases are pointer-backed protobuf wrappers. + Protobuf bool + // Cases lists every concrete branch accepted by the union. + Cases []unionValidationCase + } +) + var ( enumValT *template.Template formatValT *template.Template @@ -225,8 +256,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Validate unions represented as interfaces (e.g., protobuf oneof wrappers). - var vals []string - var types []string + var cases []unionValidationCase for _, v := range u.Values { vatt := v.Attribute if view { @@ -235,25 +265,32 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A unionCtx.Pointer = false val := validateAttribute(unionCtx, vatt, put, "v", context+".value", true, view, seen) if val != "" { - types = append(types, attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt))) - vals = append(vals, val) + cases = append(cases, unionValidationCase{ + Type: attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt)), + Validation: val, + }) } } else { fieldName := attCtx.Scope.Field(vatt, v.Name, true) val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context+".value", true, view, seen) - if val != "" { - tref := attCtx.Scope.Ref(&expr.AttributeExpr{Type: put}, attCtx.Pkg(&expr.AttributeExpr{Type: put})) - types = append(types, tref+"_"+fieldName) - vals = append(vals, val) - } + parent := &expr.AttributeExpr{Type: put} + tref := attCtx.Scope.Ref(parent, attCtx.Pkg(parent)) + cases = append(cases, unionValidationCase{ + Type: tref + "_" + fieldName, + Field: fieldName, + Name: v.Name, + PayloadRequiresPresence: protobufUnionPayloadRequiresPresence(vatt), + Validation: val, + }) } } - if len(vals) > 0 { + if len(cases) > 0 { newline() - data := map[string]any{ - "target": target, - "types": types, - "values": vals, + data := unionValidationData{ + Target: target, + Context: context, + Protobuf: !view, + Cases: cases, } if err := unionValT.Execute(buf, data); err != nil { panic(err) // bug @@ -264,6 +301,14 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A return buf } +// protobufUnionPayloadRequiresPresence reports whether the generated oneof +// wrapper field holds a protobuf message pointer. Protobuf scalar fields, +// including primitive aliases and bytes, store their value directly. Any and +// every non-primitive branch compile to message pointers. +func protobufUnionPayloadRequiresPresence(att *expr.AttributeExpr) bool { + return !expr.IsPrimitive(att.Type) || unalias(att.Type).Kind() == expr.AnyKind +} + func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { ut, isUT := att.Type.(expr.UserType) if !isUT { @@ -323,11 +368,7 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. return "" } var buf bytes.Buffer - name := ctx.Scope.Name(att, "", ctx.Pointer, ctx.UseDefault) - // Use the scoped type name directly to preserve identifiers such as - // protocol buffer-reserved names that include a trailing underscore - // (e.g., Message_). Applying Goify here would drop underscores and - // cause mismatches between function declarations and call sites. + name := ctx.Scope.ValidatorName(att, "") data := map[string]any{"name": name, "target": target} if err := userValT.Execute(&buf, data); err != nil { panic(err) // bug @@ -436,7 +477,7 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia } if exclMax := validation.ExclusiveMaximum; exclMax != nil { data["exclMax"] = *exclMax - data["isExclMax"] = true + data["isExclMin"] = false if val := runTemplate(exclMinMaxValT, data); val != "" { res = append(res, val) } diff --git a/codegen/validation_plan.go b/codegen/validation_plan.go new file mode 100644 index 0000000000..3a51f36444 --- /dev/null +++ b/codegen/validation_plan.go @@ -0,0 +1,817 @@ +// This file retains service and view validation operations before generated +// package names freeze. Linked validation rendering consumes only copied rules, +// symbolic Go layouts, and exact validator declarations. +package codegen + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + "goa.design/goa/v3/expr" +) + +type ( + // ValidatorBindingRequest identifies one nested user-type validation call. + // Attribute is available only while planning; Layout supplies its already + // bound Go owner and declaration identity. + ValidatorBindingRequest struct { + // Attribute is the exact nested user-type occurrence. + Attribute *expr.AttributeExpr + // Layout is the exact symbolic Go layout for Attribute. + Layout *GoTypePlan + // View is the selected nested validator view. Service and projected-view + // validation use the default view, represented by the empty string. + View string + } + + // ValidatorDeclarationBinder returns the exact package-level validator + // declaration selected before generation freeze. + ValidatorDeclarationBinder func(ValidatorBindingRequest) (*NameDeclaration, error) + + // ValidationPlanOptions configures one root validation operation. + ValidationPlanOptions struct { + // Required reports whether the root value is required. + Required bool + // Alias validates the root as the underlying value of a user-type alias. + Alias bool + // Bind resolves every nested non-alias user validation call. + Bind ValidatorDeclarationBinder + } + + // ValidationPlan is an immutable symbolic service/view validation program. + // Expression pointers are retained only for occurrence identity; all rules, + // paths, requiredness, layouts, and validator calls are copied during + // NewValidationPlan. + ValidationPlan struct { + layout *GoTypePlan + root *validationPlanNode + declarations []*NameDeclaration + } + + // LinkedValidationPlan renders a ValidationPlan after generated declaration + // names and import aliases freeze. + LinkedValidationPlan struct { + plan *ValidationPlan + layout LinkedGoType + } + + // validationPlanNode is one retained recursive validation operation. + validationPlanNode struct { + occurrence *expr.AttributeExpr + layout *GoTypePlan + rules validationRulePlan + guard bool + call *validatorCallPlan + fields []validationFieldPlan + array *validationArrayPlan + mapValue *validationMapPlan + union *validationUnionPlan + } + + // validationRulePlan retains local effective validation values in template + // execution order. + validationRulePlan struct { + values []any + format string + pattern string + exclusiveMinimum *float64 + minimum *float64 + exclusiveMaximum *float64 + maximum *float64 + minLength *int + maxLength *int + required []validationRequiredPlan + pointer bool + dereference bool + aliasCast string + stringValue bool + arrayValue bool + mapValue bool + } + + // validationRequiredPlan retains one generated required-field check. + validationRequiredPlan struct { + name string + fieldName string + unionKind bool + } + + // validationFieldPlan retains one object child and its context path segment. + validationFieldPlan struct { + name string + node *validationPlanNode + } + + // validationArrayPlan retains one element operation and presence policy. + validationArrayPlan struct { + element *validationPlanNode + nonNullableElems bool + } + + // validationMapPlan retains map key and value validation operations. + validationMapPlan struct { + key *validationPlanNode + value *validationPlanNode + } + + // validationUnionPlan retains generated sum-type branch operations. + validationUnionPlan struct { + cases []validationUnionCasePlan + } + + // validationUnionCasePlan retains one sum-type accessor and branch program. + validationUnionCasePlan struct { + typeTag string + fieldName string + node *validationPlanNode + } + + // validatorCallPlan retains one exact nested validator declaration. + validatorCallPlan struct { + declaration *NameDeclaration + } + + // validationPlanner owns all expression reads during validation planning. + validationPlanner struct { + bind ValidatorDeclarationBinder + declarations []*NameDeclaration + } +) + +// NewValidationPlan selects every service/view validation operation for +// attribute before generated package names freeze. layout must be the exact +// sum-type Go plan built for attribute with the desired service or view policy. +func NewValidationPlan(attribute *expr.AttributeExpr, layout *GoTypePlan, options ValidationPlanOptions) (*ValidationPlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan validation: attribute must not be nil") + } + if layout == nil { + return nil, fmt.Errorf("plan validation: Go type layout must not be nil") + } + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation: Go type layout does not match the root attribute occurrence") + } + if !layout.Policy().SumType { + return nil, fmt.Errorf("plan validation: service/view validation requires a sum-type Go layout") + } + planner := validationPlanner{bind: options.Bind} + root, err := planner.plan(attribute, layout, options.Required, options.Alias, false, "root") + if err != nil { + return nil, err + } + return &ValidationPlan{ + layout: layout, + root: root, + declarations: planner.declarations, + }, nil +} + +// ValidatorDeclarations returns the exact nested validator declarations in +// stable call order. Repeated calls deliberately repeat the same pointer. +func (p *ValidationPlan) ValidatorDeclarations() []*NameDeclaration { + return append([]*NameDeclaration(nil), p.declarations...) +} + +// Link binds p to its exact linked Go layout after declaration and import +// aliases freeze. +func (p *ValidationPlan) Link(layout LinkedGoType) (LinkedValidationPlan, error) { + if layout.plan != p.layout { + return LinkedValidationPlan{}, fmt.Errorf("link validation: linked Go type does not belong to this validation plan") + } + return LinkedValidationPlan{plan: p, layout: layout}, nil +} + +// Render returns validation code for target. context is the root name included +// in validation errors and may differ from the Go target expression. +func (p LinkedValidationPlan) Render(target, context string) string { + return p.renderNode(p.plan.root, target, context) +} + +// Imports returns path-unique external validator imports with their frozen +// qualifiers. Imports already supplied by the linked Go layout are not +// repeated unless validation calls require them too. +func (p LinkedValidationPlan) Imports() []GoTypeImport { + seen := make(map[string]struct{}) + var imports []GoTypeImport + for _, declaration := range p.plan.declarations { + owner := declaration.packagePath() + if owner == p.layout.outputPath { + continue + } + if _, exists := seen[owner]; exists { + continue + } + seen[owner] = struct{}{} + imports = append(imports, GoTypeImport{ + Name: p.layout.qualify(owner), + Path: owner, + }) + } + return imports +} + +// plan copies one recursive operation. nested distinguishes a user-type field +// call from a root definition whose anonymous layout is expanded in place. +func (p *validationPlanner) plan(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias, nested bool, path string) (*validationPlanNode, error) { + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation for %s: Go type layout occurrence does not match", path) + } + policy := layout.Policy() + if userType, named := attribute.Type.(expr.UserType); named && !alias && nested { + if !userTypeNeedsValidation(userType, policy, make(map[expr.UserType]struct{})) { + return &validationPlanNode{occurrence: attribute, layout: layout}, nil + } + if p.bind == nil { + return nil, fmt.Errorf("plan validation for %s: validator binder must not be nil", path) + } + declaration, err := p.bind(ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: "", + }) + if err != nil { + return nil, fmt.Errorf("plan validation for %s: %w", path, err) + } + if declaration == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration must not be nil", path) + } + if declaration.owner == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration is not owned", path) + } + if declaration.packagePath() != layout.Owner() { + return nil, fmt.Errorf( + "plan validation for %s: validator owner %q does not match layout owner %q", + path, declaration.packagePath(), layout.Owner(), + ) + } + p.declarations = append(p.declarations, declaration) + return &validationPlanNode{ + occurrence: attribute, + layout: layout, + call: &validatorCallPlan{declaration: declaration}, + }, nil + } + + node := &validationPlanNode{ + occurrence: attribute, + layout: layout, + rules: planValidationRules(attribute, layout, required, alias), + } + switch { + case expr.IsObject(attribute.Type): + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + if len(fields) != len(*object) { + return nil, fmt.Errorf("plan validation for %s: object layout has %d fields, expected %d", path, len(fields), len(*object)) + } + node.fields = make([]validationFieldPlan, 0, len(fields)) + for index, field := range *object { + child, err := p.plan( + field.Attribute, + fields[index], + attribute.IsRequired(field.Name), + expr.IsAlias(field.Attribute.Type), + true, + fmt.Sprintf("field %q", field.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + node.fields = append(node.fields, validationFieldPlan{name: field.Name, node: child}) + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + childPolicy := policy + if childPolicy.Pointer && expr.IsPrimitive(array.ElemType.Type) { + childPolicy.Pointer = false + } + childLayout := layout.Elem() + if childLayout == nil { + return nil, fmt.Errorf("plan validation for %s: array layout has no element", path) + } + childLayout = childLayout.withPolicy(childPolicy) + child, err := p.plan(array.ElemType, childLayout, true, expr.IsAlias(array.ElemType.Type), true, path+"[*]") + if err != nil { + return nil, err + } + if !child.empty() || array.NonNullableElems { + node.array = &validationArrayPlan{ + element: child, + nonNullableElems: array.NonNullableElems, + } + } + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + childPolicy := policy + childPolicy.Pointer = false + keyLayout := layout.Key() + valueLayout := layout.Elem() + if keyLayout == nil || valueLayout == nil { + return nil, fmt.Errorf("plan validation for %s: map layout is incomplete", path) + } + key, err := p.plan(mapping.KeyType, keyLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.KeyType.Type), true, path+".key") + if err != nil { + return nil, err + } + value, err := p.plan(mapping.ElemType, valueLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.ElemType.Type), true, path+"[key]") + if err != nil { + return nil, err + } + if !key.empty() || !value.empty() { + node.mapValue = &validationMapPlan{key: key, value: value} + } + case expr.IsUnion(attribute.Type): + union := expr.AsUnion(attribute.Type) + branches := layout.Branches() + if len(branches) != len(union.Values) { + return nil, fmt.Errorf("plan validation for %s: union layout has %d branches, expected %d", path, len(branches), len(union.Values)) + } + var cases []validationUnionCasePlan + for index, branch := range union.Values { + branchPolicy := policy + branchPolicy.Pointer = branchPolicy.Pointer && expr.IsObject(branch.Attribute.Type) + child, err := p.plan( + branch.Attribute, + branches[index].withPolicy(branchPolicy), + true, + expr.IsAlias(branch.Attribute.Type), + true, + fmt.Sprintf("union branch %q", branch.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + cases = append(cases, validationUnionCasePlan{ + typeTag: branch.Name, + fieldName: Goify(branch.Name, true), + node: child, + }) + } + if len(cases) > 0 { + node.union = &validationUnionPlan{cases: cases} + } + } + if nested && !node.empty() { + node.guard = validationNeedsNilGuard(attribute, required, policy) + } + return node, nil +} + +// planValidationRules copies every local effective validation rule. +func planValidationRules(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias bool) validationRulePlan { + validation := expr.EffectiveValidation(attribute) + if validation == nil { + return validationRulePlan{} + } + policy := layout.Policy() + unaliased := unalias(attribute.Type) + pointer := policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) + rules := validationRulePlan{ + format: string(validation.Format), + pattern: validation.Pattern, + exclusiveMinimum: copyValidationFloat(validation.ExclusiveMinimum), + minimum: copyValidationFloat(validation.Minimum), + exclusiveMaximum: copyValidationFloat(validation.ExclusiveMaximum), + maximum: copyValidationFloat(validation.Maximum), + minLength: copyValidationInt(validation.MinLength), + maxLength: copyValidationInt(validation.MaxLength), + pointer: pointer, + dereference: pointer && expr.IsPrimitive(attribute.Type) && + unaliased.Kind() != expr.BytesKind && unaliased.Kind() != expr.AnyKind, + stringValue: unaliased.Kind() == expr.StringKind, + arrayValue: expr.IsArray(attribute.Type), + mapValue: expr.IsMap(attribute.Type), + } + if validation.Values != nil { + rules.values = make([]any, len(validation.Values)) + for index, value := range validation.Values { + rules.values[index] = copyValidationValue(value) + } + } + if custom, _ := GetMetaType(attribute); custom != "" { + rules.format = "" + } + if alias { + rules.aliasCast = unaliased.Name() + } + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + for _, name := range generatedRequiredValidationNames(attribute, validation, policy) { + var fieldName string + for index, field := range *object { + if field.Name == name { + fieldName = fields[index].FieldName(true) + break + } + } + requiredAttribute := object.Attribute(name) + rules.required = append(rules.required, validationRequiredPlan{ + name: name, + fieldName: fieldName, + unionKind: expr.IsUnion(requiredAttribute.Type) && + policy.SumType && !(policy.UnionPointer && policy.Pointer), + }) + } + return rules +} + +// generatedRequiredValidationNames retains required checks emitted for policy. +func generatedRequiredValidationNames(attribute *expr.AttributeExpr, validation *expr.ValidationExpr, policy GoLayoutPolicy) []string { + object := expr.AsObject(attribute.Type) + var names []string + for _, name := range validation.Required { + required := object.Attribute(name) + if required == nil { + continue + } + if !policy.Pointer && expr.IsPrimitive(required.Type) && + required.Type.Kind() != expr.BytesKind && required.Type.Kind() != expr.AnyKind { + continue + } + if policy.IgnoreRequired && expr.IsPrimitive(required.Type) { + continue + } + names = append(names, name) + } + return names +} + +// validationNeedsNilGuard retains validateAttribute's wrapper decision. +func validationNeedsNilGuard(attribute *expr.AttributeExpr, required bool, policy GoLayoutPolicy) bool { + if expr.IsArray(attribute.Type) || expr.IsMap(attribute.Type) { + return false + } + if expr.IsUnion(attribute.Type) { + return policy.UnionPointer && (!required || policy.Pointer) + } + return policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) +} + +// userTypeNeedsValidation mirrors the existing nested-validator predicate +// without allocating names or retaining expression-backed render decisions. +func userTypeNeedsValidation(userType expr.UserType, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + origin := userType.Origin() + if _, exists := seen[origin]; exists { + return false + } + seen[origin] = struct{}{} + return attributeNeedsValidation(userType.Attribute(), true, expr.IsAlias(userType), policy, seen) +} + +// attributeNeedsValidation reports whether planning the attribute can emit code. +func attributeNeedsValidation(attribute *expr.AttributeExpr, required, alias bool, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + validation := expr.EffectiveValidation(attribute) + if validation != nil { + if len(validation.Values) > 0 || validation.Pattern != "" || + validation.ExclusiveMinimum != nil || validation.Minimum != nil || + validation.ExclusiveMaximum != nil || validation.Maximum != nil || + validation.MinLength != nil || validation.MaxLength != nil { + return true + } + if validation.Format != "" { + if custom, _ := GetMetaType(attribute); custom == "" { + return true + } + } + if len(generatedRequiredValidationNames(attribute, validation, policy)) > 0 { + return true + } + } + switch { + case expr.IsObject(attribute.Type): + for _, field := range *expr.AsObject(attribute.Type) { + if nested, ok := field.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, policy, seen) { + return true + } + continue + } + if attributeNeedsValidation(field.Attribute, attribute.IsRequired(field.Name), expr.IsAlias(field.Attribute.Type), policy, seen) { + return true + } + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + if array.NonNullableElems { + return true + } + return attributeNeedsValidation(array.ElemType, true, expr.IsAlias(array.ElemType.Type), policy, seen) + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + mapPolicy := policy + mapPolicy.Pointer = false + return attributeNeedsValidation(mapping.KeyType, true, expr.IsAlias(mapping.KeyType.Type), mapPolicy, seen) || + attributeNeedsValidation(mapping.ElemType, true, expr.IsAlias(mapping.ElemType.Type), mapPolicy, seen) + case expr.IsUnion(attribute.Type): + for _, branch := range expr.AsUnion(attribute.Type).Values { + branchPolicy := policy + branchPolicy.Pointer = policy.Pointer && expr.IsObject(branch.Attribute.Type) + if nested, ok := branch.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, branchPolicy, seen) { + return true + } + continue + } + if attributeNeedsValidation(branch.Attribute, true, expr.IsAlias(branch.Attribute.Type), branchPolicy, seen) { + return true + } + } + } + return false +} + +// renderNode renders retained operations without reading expression contents. +func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, context string) string { + if node.call != nil { + name := p.validatorName(node.call.declaration) + var buffer bytes.Buffer + if err := userValT.Execute(&buffer, map[string]any{"name": name, "target": target}); err != nil { + panic(err) + } + return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buffer.String()) + } + var sections []string + if local := renderValidationRules(node.rules, target, context); local != "" { + sections = append(sections, local) + } + for _, field := range node.fields { + validation := p.renderNode( + field.node, + target+"."+field.node.layout.FieldName(true), + context+"."+field.name, + ) + if validation != "" { + sections = append(sections, validation) + } + } + if node.array != nil { + validation := p.renderNode(node.array.element, "e", context+"[*]") + var buffer bytes.Buffer + if err := arrayValT.Execute(&buffer, map[string]any{ + "target": target, + "validation": validation, + "nonNullableElems": node.array.nonNullableElems, + "context": context, + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.mapValue != nil { + keyValidation := p.renderNode(node.mapValue.key, "k", context+".key") + if keyValidation != "" { + keyValidation = "\n" + keyValidation + } + valueValidation := p.renderNode(node.mapValue.value, "v", context+"[key]") + if valueValidation != "" { + valueValidation = "\n" + valueValidation + } + var buffer bytes.Buffer + if err := mapValT.Execute(&buffer, map[string]any{ + "target": target, + "keyValidation": keyValidation, + "valueValidation": valueValidation, + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.union != nil { + cases := make([]map[string]any, len(node.union.cases)) + for index, unionCase := range node.union.cases { + cases[index] = map[string]any{ + "typeTag": unionCase.typeTag, + "fieldName": unionCase.fieldName, + "validation": p.renderNode(unionCase.node, "actual", context+".value"), + } + } + var buffer bytes.Buffer + if err := unionSumValT.Execute(&buffer, map[string]any{"target": target, "cases": cases}); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + code := strings.Join(sections, "\n") + if node.guard && code != "" { + condition := fmt.Sprintf("if %s != nil {\n", target) + if !strings.HasPrefix(code, condition) { + code = condition + code + "\n}" + } + } + return code +} + +// renderValidationRules renders copied local rules through the canonical +// validation templates. +func renderValidationRules(rules validationRulePlan, target, context string) string { + targetValue := target + if rules.dereference { + targetValue = "*" + targetValue + } + if rules.aliasCast != "" { + targetValue = fmt.Sprintf("%s(%s)", rules.aliasCast, targetValue) + } + data := map[string]any{ + "isPointer": rules.pointer, + "context": context, + "target": target, + "targetVal": targetValue, + "string": rules.stringValue, + "array": rules.arrayValue, + "map": rules.mapValue, + } + var rendered []string + if rules.values != nil { + data["values"] = rules.values + rendered = appendValidationTemplate(rendered, enumValT, data) + } + if rules.format != "" { + data["format"] = rules.format + rendered = appendValidationTemplate(rendered, formatValT, data) + } + if rules.pattern != "" { + data["pattern"] = rules.pattern + rendered = appendValidationTemplate(rendered, patternValT, data) + } + if rules.exclusiveMinimum != nil { + data["exclMin"] = *rules.exclusiveMinimum + data["isExclMin"] = true + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.minimum != nil { + data["min"] = *rules.minimum + data["isMin"] = true + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.exclusiveMaximum != nil { + data["exclMax"] = *rules.exclusiveMaximum + data["isExclMin"] = false + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.maximum != nil { + data["max"] = *rules.maximum + data["isMin"] = false + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.minLength != nil { + data["minLength"] = rules.minLength + data["isMinLength"] = true + delete(data, "maxLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + if rules.maxLength != nil { + data["maxLength"] = rules.maxLength + data["isMinLength"] = false + delete(data, "minLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + for _, required := range rules.required { + if required.unionKind { + rendered = append(rendered, fmt.Sprintf( + "if %s.%s.Kind() == \"\" {\n err = goa.MergeErrors(err, goa.MissingFieldError(%q, %q))\n}", + target, required.fieldName, required.name, context, + )) + continue + } + rendered = append(rendered, fmt.Sprintf( + "if %s.%s == nil {\n err = goa.MergeErrors(err, goa.MissingFieldError(%q, %q))\n}", + target, required.fieldName, required.name, context, + )) + } + return strings.Join(rendered, "\n") +} + +// appendValidationTemplate executes one canonical local validation template. +func appendValidationTemplate(rendered []string, validationTemplate *template.Template, data map[string]any) []string { + var buffer bytes.Buffer + if err := validationTemplate.Execute(&buffer, data); err != nil { + panic(err) + } + if validation := strings.Trim(buffer.String(), "\n"); validation != "" { + return append(rendered, validation) + } + return rendered +} + +// validatorName qualifies one exact validator declaration for the linked file. +func (p LinkedValidationPlan) validatorName(declaration *NameDeclaration) string { + name := declaration.Name() + owner := declaration.packagePath() + if owner == p.layout.outputPath { + return name + } + return p.layout.qualify(owner) + "." + name +} + +// empty reports whether node emits any validation code. +func (n *validationPlanNode) empty() bool { + return n.call == nil && n.rules.empty() && len(n.fields) == 0 && + n.array == nil && n.mapValue == nil && n.union == nil +} + +// empty reports whether no local rule was retained. +func (p validationRulePlan) empty() bool { + return p.values == nil && p.format == "" && p.pattern == "" && + p.exclusiveMinimum == nil && p.minimum == nil && + p.exclusiveMaximum == nil && p.maximum == nil && + p.minLength == nil && p.maxLength == nil && len(p.required) == 0 +} + +// withPolicy returns an occurrence-identical immutable plan view with a +// validation-specific effective policy. It does not modify the shared layout. +func (p *GoTypePlan) withPolicy(policy GoLayoutPolicy) *GoTypePlan { + clone := *p + clone.policy = policy + if p.key != nil { + clone.key = p.key.withPolicy(policy) + } + if p.element != nil { + clone.element = p.element.withPolicy(policy) + } + if len(p.fields) > 0 { + clone.fields = make([]*GoTypePlan, len(p.fields)) + for index, field := range p.fields { + clone.fields[index] = field.withPolicy(policy) + } + } + if len(p.branches) > 0 { + clone.branches = make([]*GoTypePlan, len(p.branches)) + for index, branch := range p.branches { + clone.branches[index] = branch.withPolicy(policy) + } + } + return &clone +} + +// copyValidationFloat copies one optional scalar rule value. +func copyValidationFloat(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationInt copies one optional length rule value. +func copyValidationInt(value *int) *int { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationValue detaches the mutable collection shapes accepted by Goa +// enum validations. Primitive values are immutable and remain shared. +func copyValidationValue(value any) any { + switch actual := value.(type) { + case expr.Val: + copied := make(expr.Val, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case expr.ArrayVal: + copied := make(expr.ArrayVal, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case expr.MapVal: + copied := make(expr.MapVal, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + default: + return actual + } +} diff --git a/codegen/validation_plan_test.go b/codegen/validation_plan_test.go new file mode 100644 index 0000000000..ca1d6266af --- /dev/null +++ b/codegen/validation_plan_test.go @@ -0,0 +1,238 @@ +// This file verifies that symbolic validation planning preserves service and +// view validation output without reading expressions after package freeze. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestValidationPlanPreservesRulesPathsAndRequiredness compares retained rule +// rendering with the existing service/view validation generator. +func TestValidationPlanPreservesRulesPathsAndRequiredness(t *testing.T) { + minimum := 2.0 + minLength := 3 + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"name", "nested"}}, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + attribute.Validation = nil + attribute.Type = expr.String + minimum = 99 + minLength = 99 + + linked, err := plan.Link(layout.Link("generated.local/gen/service", validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) +} + +// TestValidationPlanCopiesEnumValues verifies that accepted mutable enum +// values cannot change a validation program after planning. +func TestValidationPlanCopiesEnumValues(t *testing.T) { + bytesValue := []byte{1, 2} + arrayValue := []any{ + bytesValue, + map[string]any{"nested": []any{"kept"}}, + } + mapValue := map[string]any{"array": arrayValue} + attribute := &expr.AttributeExpr{ + Type: expr.Any, + Validation: &expr.ValidationExpr{Values: []any{ + bytesValue, + arrayValue, + mapValue, + }}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + bytesValue[0] = 9 + arrayValue[1].(map[string]any)["nested"].([]any)[0] = "changed" + mapValue["added"] = true + attribute.Validation.Values[0] = "replaced" + + require.Equal(t, []any{ + []byte{1, 2}, + []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }, + map[string]any{"array": []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }}, + }, plan.root.rules.values) +} + +// TestValidationPlanPreservesContainersUnionsAndValidatorBindings verifies all +// recursive service/view shapes retain exact nested validator declarations. +func TestValidationPlanPreservesContainersUnionsAndValidatorBindings(t *testing.T) { + const owner = "generated.local/gen/service" + minLength := 1 + minimum := 4.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "label", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }}, + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }, + } + mapKey := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + } + mapValue := &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: child}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: mapKey, + ElemType: mapValue, + }}}, + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, owner, child) + unionDeclaration := declareGoTypeTestUnion(t, generation, owner, union) + generatedPackage := generation.Package(owner) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generatedPackage.DeclareName(validator)) + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: policy, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: owner, Type: childDeclaration}, + union: {Owner: owner, Union: unionDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(request ValidatorBindingRequest) (*NameDeclaration, error) { + require.Same(t, child, request.Attribute.Type) + require.Equal(t, owner, request.Layout.Owner()) + require.Empty(t, request.View) + return validator, nil + }, + }) + require.NoError(t, err) + require.Equal(t, []*NameDeclaration{validator, validator}, plan.ValidatorDeclarations()) + + mapKey.Validation = nil + mapValue.Validation = nil + union.Values = nil + child.SetAttribute(&expr.AttributeExpr{Type: expr.String}) + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + linked, err := plan.Link(layout.Link(owner, validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) + require.Empty(t, linked.Imports()) +} + +// TestValidationPlanRejectsUnboundNestedValidator verifies planning never +// falls back to reconstructing a validator name from a user type. +func TestValidationPlanRejectsUnboundNestedValidator(t *testing.T) { + minLength := 1 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }} + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + declaration := declareGoTypeTestUserType(t, generation, "generated.local/gen/service", child) + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: "generated.local/gen/service", Type: declaration}, + }), + }) + require.NoError(t, err) + + _, err = NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.EqualError(t, err, "plan validation for field \"child\": validator binder must not be nil") +} + +// validationPlanTestQualifier resolves the focused generated package aliases. +func validationPlanTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/service": + return "service" + default: + panic(fmt.Sprintf("unexpected validation import %q", importPath)) + } +} diff --git a/codegen/validation_protobuf_union_test.go b/codegen/validation_protobuf_union_test.go new file mode 100644 index 0000000000..53825bdd2d --- /dev/null +++ b/codegen/validation_protobuf_union_test.go @@ -0,0 +1,99 @@ +// This file verifies the generic validator emitted for protobuf-style OneOf +// interfaces, including wrapper and branch-payload presence. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // protobufUnionTestScope models the wrapper references used by protoc so + // the generic validation generator can be tested without transport setup. + protobufUnionTestScope struct { + scope *NameScope + } +) + +func TestProtobufUnionValidationRequiresCompleteSelectedBranch(t *testing.T) { + root := RunDSL(t, protobufUnionValidationDSL) + message := root.UserType("Message") + ctx := NewAttributeContext(false, true, false, "pb", NewNameScope()) + ctx.Scope = &protobufUnionTestScope{scope: NewNameScope()} + + generated := AttributeValidationCode(message.Attribute(), message, ctx, true, false, "message", "message") + + require.Contains(t, generated, `goa.MissingFieldError("choice", "message")`) + require.Contains(t, generated, `goa.MissingFieldError("detail", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("inactive", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("blob", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("metadata", "message.choice")`) + require.Contains(t, generated, "if v == nil {") + require.Contains(t, generated, "if v.Detail == nil {") + require.Contains(t, generated, "if v.Inactive == nil {") + require.Contains(t, generated, "if v.Metadata == nil {") + require.NotContains(t, generated, "if v.Blob == nil {") + require.NotContains(t, generated, "if v.Token == nil {") +} + +func (s *protobufUnionTestScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { + name := Goify(att.Type.Name(), true) + if pkg != "" { + return pkg + "." + name + } + return name +} + +func (s *protobufUnionTestScope) Ref(att *expr.AttributeExpr, pkg string) string { + return "*" + s.Name(att, pkg, false, false) +} + +func (*protobufUnionTestScope) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (*protobufUnionTestScope) Package(*expr.AttributeExpr) string { + return "pb" +} + +func (s *protobufUnionTestScope) Enter(*expr.AttributeExpr) Attributor { + return s +} + +func (*protobufUnionTestScope) IsSumType() bool { + return false +} + +func (s *protobufUnionTestScope) ValidatorName(att *expr.AttributeExpr, view string) string { + return "Validate" + s.Name(att, "", false, false) + Goify(view, true) +} + +func (s *protobufUnionTestScope) Scope() *NameScope { + return s.scope +} + +// protobufUnionValidationDSL defines pointer-backed, scalar, and bytes OneOf +// branches so the generator must preserve their distinct presence semantics. +func protobufUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Attribute("label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + d.Type("Message", func() { + d.OneOf("choice", func() { + d.Attribute("number", d.Int, func() { d.Minimum(1) }) + d.Attribute("detail", detail) + d.Attribute("inactive", inactive) + d.Attribute("blob", d.Bytes) + d.Attribute("token", token) + d.Attribute("metadata", d.Any) + }) + d.Required("choice") + }) +} diff --git a/codegen/validation_test.go b/codegen/validation_test.go index 6e756c6e19..f592b29061 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -266,6 +266,36 @@ func TestValidationPredicatesPure(t *testing.T) { assertValidationsUnchanged(t, before) } +// TestValidationCodeUsesBothExclusiveBounds verifies that generating a lower +// exclusive bound cannot leave the upper bound on the lower-bound template +// branch. +func TestValidationCodeUsesBothExclusiveBounds(t *testing.T) { + exclusiveMinimum := 1.0 + exclusiveMaximum := 10.0 + attribute := &expr.AttributeExpr{ + Type: expr.Float64, + Validation: &expr.ValidationExpr{ + ExclusiveMinimum: &exclusiveMinimum, + ExclusiveMaximum: &exclusiveMaximum, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + legacy := ValidationCode(attribute, nil, context, true, false, true, "target") + require.Contains(t, legacy, "target <= 1") + require.Contains(t, legacy, "target >= 10") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link("generated.local/gen/service", nil)) + require.NoError(t, err) + require.Equal(t, legacy, linked.Render("target", "target")) +} + // validationSnapshot captures the identity and deep value of an attribute // validation so mutations can be detected after running codegen. type validationSnapshot struct { diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index c3c170e21e..b5ef6830af 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -4,7 +4,7 @@ **Goal:** Make every generated package-level declaration and every reference use one name collected once, frozen once, and retained through rendering. -**Architecture:** One generation run instantiates fresh core and plugin objects, permits root mutation only during preparation, and builds one typed `generator.Plan`. Service, HTTP, JSON-RPC, gRPC, OpenAPI, example, and goa-ai plans retain their complete render models and package-owned `NameDeclaration` records; render never rebuilds analysis or allocates a name. +**Architecture:** One generation run instantiates fresh core and plugin objects, permits root mutation only during preparation, and builds one typed `generator.Plan`. Service, HTTP, JSON-RPC, gRPC, OpenAPI, example, and goa-ai plans collect package-owned `NameDeclaration` records, freeze them, link those records once into complete immutable render models, and render without rebuilding analysis or allocating a name. **Tech Stack:** Go 1.25, Goa evaluation and code generation, Protocol Buffers and `protoc-gen-go`, goa-ai plugins, `testify/require` @@ -18,6 +18,7 @@ - Every package-level type, function, constant, and variable has a package-owned `NameDeclaration` before freeze. - Exact symbols reject normalized collisions; preferred symbols receive deterministic suffixes from stable typed ordering. - `NameDeclaration.Name()` panics before freeze and is stable after freeze. +- Linking resolves retained facts through frozen declarations exactly once; it cannot collect another declaration or import. - Render accepts retained typed plans. It does not accept roots, a generated module path, or callbacks that reconstruct analysis. - Complete import path is the only import identity. Different package identities that normalize to one output import path or directory are rejected. - Recursion uses `UserType.Origin()` only for cycle detection. Emitted declarations use complete typed declaration identities. @@ -44,7 +45,7 @@ preserve those APIs. - [x] Added a real two-service generated-module test with relocated nested unions and HTTP/gRPC compilation. - [x] Added exact relocated-name collision coverage. -- [x] Added the still-open same-label file-section preservation regression. +- [x] Added the same-label file-section regression later resolved by Task 6. ### Task 2: Generation-owned type catalog — complete @@ -222,7 +223,7 @@ go test ./... -run '^$' git diff --check ``` -The same-label merge test may remain the only unfiltered generator failure. +All commands pass. Commit the common owner and fresh-run lifecycle together because retained plans depend on both contracts. @@ -247,11 +248,13 @@ depend on both contracts. **Interfaces:** - Consumes: Task 6 `Generation`, `NameDeclaration`, and prepared root snapshot -- Produces: `service.NewPlan(root, generation) (*service.Plan, error)` +- Produces: `service.NewPlans(generation, inputs...) ([]*service.Plan, error)` +- Produces: `service.NewPlan(root, generation, examples) (*service.Plan, error)` - Produces: `generator.Plan.Service(root) *service.Plan` +- Produces: one post-freeze `service.Plan.Link()` operation before rendering - Produces: service render functions that accept retained plans only -- [ ] **Step 1: Inventory and test every service package-level symbol** +- [x] **Step 1: Inventory and test every service package-level symbol** Build a table from templates and render data covering service and views types, method wrappers, union families, endpoint constructors, clients, @@ -272,22 +275,29 @@ go test ./codegen/service ./codegen/generator \ Expected: FAIL where `NewServicesData` and private render scopes still allocate package-level endpoint, constructor, validator, conversion, or stream names. -- [ ] **Step 2: Build and retain one service plan per root** +- [x] **Step 2: Build and retain the complete service-plan batch** Replace the declaration-only `service.Plan` function and render-time -`NewServicesData` reconstruction with: +`NewServicesData` reconstruction with one run-wide constructor: ```go -func NewPlan(root *expr.RootExpr, generation *codegen.Generation) (*Plan, error) +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) ``` -The constructor collects the complete immutable render model, all package -imports and output files, and every package-level declaration. It performs -stable ordering before registering preferred names. `generator.Plan` stores -the exact result by root and returns it through `Service(root)`; unknown roots -fail fast. - -- [ ] **Step 3: Render only the retained plan** +The constructor requires every Goa root owned by the generation exactly once. +It collects every root-local design fact, package import, output owner, and +package-level declaration, then assigns shared conversion methods and relocated +files across the complete run without reading provisional names. Structurally +equivalent compiler copies may share one declaration; candidates with different +retained Go layouts or union branch facts are rejected before freeze. Exact +duplicate external conversions across roots are rejected rather than receiving +an artificial numeric suffix. `NewPlan` remains only as the strict single-root +form and rejects a multi-root generation. After Generation freezes, `Plan.Link` +resolves the frozen records into the immutable render model without another +declaration traversal. `generator.Plan` stores the exact result by root and +returns it through `Service(root)`; unknown roots fail fast. + +- [x] **Step 3: Render only the retained plan** Change service, views, client, endpoint, conversion, validation, interceptor, and starter implementation renderers to accept `*service.Plan` or typed values @@ -298,14 +308,18 @@ scopes only for locals, parameters, fields, and methods. Delete `NewServicesData`, the old `ServicesData` reconstruction constructor, duplicate planning traversals, and any record that carries a second final name. -- [ ] **Step 4: Prove aggregation, order independence, and purity** +- [x] **Step 4: Prove aggregation, order independence, and purity** Generate two roots contributing to one relocated package, reverse root and -service traversal, and assert byte-identical declarations. Run planning once, -mutate no expression, render twice, and assert identical files without new -catalog entries. Compile service, views, and example implementation packages. +service traversal, and assert byte-identical declarations. Mutate the source +service, method, type-location, field, validation, and conversion expressions +after planning, then assert core service output is byte-identical wherever the +core service plan owns those facts. Preserve distinct transport validation as +a valid counterexample: HTTP and gRPC own those validation programs when the +shared service declaration's Go layout is unchanged. Render twice without new +catalog entries and compile service, views, and example implementation packages. -- [ ] **Step 5: Verify and commit Task 7** +- [x] **Step 5: Verify and commit Task 7** Run: @@ -316,7 +330,7 @@ go test ./... -run '^$' git diff --check ``` -Only the separately assigned same-label merge regression may fail. +All commands must pass. ### Task 8: Retained HTTP and JSON-RPC plans @@ -355,6 +369,27 @@ Only the separately assigned same-label merge regression may fail. - Produces: typed retained HTTP and JSON-RPC plans with complete package declarations - Preserves: independent wire/service transform ownership and detached HTTP bodies +**Progress ledger (2026-08-21):** + +- Task 7 now reserves service-view imports only in the HTTP, JSON-RPC, and gRPC + files whose rendered sections reference them. The focused generated-module + proof covers viewed and ordinary services, unary and streaming gRPC, HTTP + SSE and WebSocket, and JSON-RPC unary code. The complete variable-view + transport behavior remains Task 8 work rather than an import-planning + exception. +- JSON-RPC unary responses currently discard the selected view: the server + always renders the first retained response-body variant and sends no view, + while the client tries to read `goa-view` from the HTTP response header. + Task 8 must make the selected representation explicit and reconstruct the + same view-specific body on both sides. +- JSON-RPC SSE and WebSocket clients currently decode `params` or `result` + bytes directly into the service result. This is not a valid shortcut. For + example, the generated Feed response body maps the wire property + `event_id` to `EventID`, but the service result has no JSON tag; direct + decoding silently leaves `EventID` unset. Task 8 must decode the retained + transport body, run its generated constructor and validation, then return + the canonical service result. + - [ ] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** Inventory request, response, WebSocket, SSE, error, union, constructor, @@ -364,6 +399,25 @@ request and response policy for one origin, and between HTTP and JSON-RPC sections sharing an output package. Require stable names under reversed endpoint order and compile the full generated module. +Add two-view streaming-result contracts for HTTP SSE, JSON-RPC SSE, and +JSON-RPC WebSocket. Prove each method/request stream implements `SetView`, +retains its own view, projects through the canonical service constructor, and +selects the response-body declaration for that exact view. Use two concurrent +requests on one JSON-RPC WebSocket connection as the counterexample: a view +stored on the connection is invalid because one request may select `summary` +while another selects `detailed`. + +Cover the direct JSON-RPC `StreamHandler` API separately. For a method whose +view is not fixed by the design, each `SendNotification` and +`SendResponse` call must carry its own view; it must not inherit a +connection-global or latest value. Fixed-view methods remain specialized and +do not expose a redundant selector. Add client runtime proofs with nested and +transport-mapped fields so SSE and WebSocket receivers cannot pass by decoding +view-specific wire JSON directly into the service result. Include a required +snake-case field such as `event_id`: decoding it into a service field named +`EventID` must fail the test unless the generated transport-body constructor +performs the mapping. + - [ ] **Step 2: Build retained HTTP plans from exact service plans** Make HTTP `NewPlan` consume the prepared root's HTTP expressions and exact @@ -372,6 +426,12 @@ validators, helpers, imports, and file membership once. Move every current `NewServicesData` and `wire_catalog` allocation into this constructor. Store canonical service and wire declaration records in transform data. +Retain one method/request-scoped view value for variable-view SSE and +WebSocket streams. The stream's `SetView` updates that value; send operations +use it to select both the service projection and the already-retained +view-specific response body. Never place mutable view selection on a shared +connection. + - [ ] **Step 3: Make JSON-RPC retain the HTTP plan it shares** Build one typed JSON-RPC plan that points at the exact HTTP plan used for HTTP @@ -379,6 +439,20 @@ codecs and body files, then collects JSON-RPC-only declarations. Do not invoke HTTP planning or analysis again. Make JSON-RPC render functions accept this plan and delete their root/service reconstruction paths. +Define one explicit viewed-stream wire contract shared by JSON-RPC SSE and +WebSocket. Every viewed streamed message carries the selected view together +with its view-specific body. Generated clients must select the matching +retained body decoder, reconstruct the projected value, validate the viewed +result, and return the canonical service result. Do not decode a projected +wire body directly into the service result, infer a default for a variable-view +method, or ask callers to construct generated views-package values. + +Apply the same representation contract to unary JSON-RPC. A variable-view +success response must carry the selected view with its view-specific body; +the server cannot choose the first body variant and the client cannot recover +the view from an unset HTTP header. Fixed-view unary methods remain fully +specialized and need no runtime discriminator. + - [ ] **Step 4: Remove context-dependent helper naming** Validators, constructors, conversions, stream helpers, and codecs must read @@ -397,7 +471,7 @@ go test ./... -run '^$' git diff --check ``` -Only the same-label merge regression may fail. +All commands must pass. ### Task 9: Versioned protobuf descriptor plan and retained gRPC plan @@ -489,7 +563,7 @@ go test ./... -run '^$' git diff --check ``` -Only the same-label merge regression may fail. +All commands must pass. ### Task 10: OpenAPI, examples, and selective lifecycle integration @@ -660,7 +734,7 @@ pull request. - Consumes: complete retained plans and package-owned declaration deduplication - Produces: lossless same-path assembly and fully verified Goa, goa-ai, and AURA branches -- [ ] **Step 1: Make same-path file assembly lossless** +- [x] **Step 1: Make same-path file assembly lossless** Merge compatible headers and imports, then append every non-header section in producer order. Never deduplicate by `SectionTemplate.Name`. Require all @@ -673,8 +747,10 @@ Run: go test ./codegen/generator -run TestMergeFilesPreservesSameLabelSections -count=1 ``` -Expected before implementation: FAIL because the second same-label body is -discarded. Expected after implementation: PASS with both bodies present. +This was completed with Task 6 because lossless file assembly is part of the +common run lifecycle. Both same-label bodies are preserved, all contributor +finalizers run in order, and incompatible headers or output paths fail before +rendering. - [ ] **Step 2: Delete every superseded mechanism** diff --git a/expr/root.go b/expr/root.go index 810155f038..d990c4a707 100644 --- a/expr/root.go +++ b/expr/root.go @@ -6,6 +6,7 @@ package expr import ( "fmt" "maps" + "reflect" "slices" "sort" @@ -217,10 +218,48 @@ func (r *RootExpr) Validate() error { } verr.Merge(r.validateRelocatedUserTypes()) + verr.Merge(validateTypeMappings("conversion", r.Conversions)) + verr.Merge(validateTypeMappings("creation", r.Creations)) return &verr } +// validateTypeMappings rejects repeated declarations that would generate the +// same method on one user type. The reflected type preserves package identity, +// so equally named external types from different packages remain distinct. +func validateTypeMappings(direction string, mappings []*TypeMap) *eval.ValidationErrors { + type mappingIdentity struct { + user UserType + external reflect.Type + } + var verr eval.ValidationErrors + seen := make(map[mappingIdentity]struct{}, len(mappings)) + for _, mapping := range mappings { + identity := mappingIdentity{ + user: mapping.User.Origin(), + external: reflect.TypeOf(mapping.External), + } + if _, ok := seen[identity]; ok { + if direction == "conversion" { + verr.Add( + mapping.User, + "conversion from user type %q to external type %q defined twice", + mapping.User.Name(), identity.external, + ) + } else { + verr.Add( + mapping.User, + "creation from external type %q to user type %q defined twice", + identity.external, mapping.User.Name(), + ) + } + continue + } + seen[identity] = struct{}{} + } + return &verr +} + // validateRelocatedUserTypes enforces that relocated user types (those with // `struct:pkg:path`) only depend on other declared user types with an explicit // generation location. diff --git a/expr/root_test.go b/expr/root_test.go index 6d1c26627c..d84e52c908 100644 --- a/expr/root_test.go +++ b/expr/root_test.go @@ -11,6 +11,10 @@ import ( "goa.design/goa/v3/eval" ) +type rootExternalType struct { + Value string +} + func TestRelocatedDependenciesUseDeclarationOrigin(t *testing.T) { dependency := &UserTypeExpr{ TypeName: "Dependency", @@ -99,6 +103,49 @@ func TestRootExprValidate(t *testing.T) { } } +// TestRootExprValidateRejectsDuplicateTypeMappings catches two identical +// conversion or creation declarations that would emit the same receiver method. +func TestRootExprValidateRejectsDuplicateTypeMappings(t *testing.T) { + user := &UserTypeExpr{ + TypeName: "Value", + UID: "value", + AttributeExpr: &AttributeExpr{Type: String}, + } + for _, test := range []struct { + name string + conversions []*TypeMap + creations []*TypeMap + }{ + { + name: "conversion", + conversions: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + { + name: "creation", + creations: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + root := &RootExpr{ + API: &APIExpr{Name: "test"}, + Types: []UserType{user}, + Conversions: test.conversions, + Creations: test.creations, + } + err := root.Validate() + if err == nil || !strings.Contains(err.Error(), test.name+" from") || !strings.Contains(err.Error(), "defined twice") { + t.Fatalf("expected precise duplicate %s error, got %v", test.name, err) + } + }) + } +} + func TestMetaExpr_Last(t *testing.T) { tt := map[string]struct { meta MetaExpr diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index a70361503c..4d0ea48cd7 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -43,9 +43,11 @@ func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File codegen.GoaNamedImport("grpc", "goagrpc"), codegen.GoaNamedImport("grpc/pb", "goapb"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } + if serviceHasViewedClientStream(data) { + imports = append(imports, services.ViewImport(svc.Name())) + } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC client", "client", imports), } @@ -133,9 +135,11 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } + if serviceHasUnaryViewedResult(data) { + imports = append(imports, services.ViewImport(svc.Name())) + } sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} fm := transTmplFuncs(svc, services) fm["hasInitArg"] = hasInitArg diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index 992478e150..a44e721aa1 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -20,13 +20,13 @@ func TestExampleCLIFiles(t *testing.T) { DSL func() PkgPath string }{ - {"no-server", ctestdata.NoServerDSL, "/"}, - {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, "/"}, - {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, "/"}, + {"no-server", ctestdata.NoServerDSL, "generated.local/gen"}, + {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, "generated.local/gen"}, + {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, "generated.local/gen"}, {"no-server-pkgpath", ctestdata.NoServerDSL, "my/pkg/path"}, {"server-hosting-service-subset-pkgpath", ctestdata.ServerHostingServiceSubsetDSL, "my/pkg/path"}, {"server-hosting-multiple-services-pkgpath", ctestdata.ServerHostingMultipleServicesDSL, "my/pkg/path"}, - {"interceptors", testdata.InterceptorsDSL, "/"}, + {"interceptors", testdata.InterceptorsDSL, "generated.local/gen"}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index 1c557eb81b..88b8d12183 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -27,7 +27,7 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := NewServicesData(createServiceServicesForPackage(root, "/")) + services := NewServicesData(createServiceServicesForPackage(root, "generated.local/gen")) fs := ClientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go index d600492aa3..5639658592 100644 --- a/grpc/codegen/plan_test.go +++ b/grpc/codegen/plan_test.go @@ -27,11 +27,12 @@ func TestPlanReservesGeneratedGRPCPackages(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() client := services.PackageImport("generated.local/gen/grpc/foo/client") server := services.PackageImport("generated.local/gen/grpc/foo/server") diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index 2ec7d7aa26..e67f1d4861 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -60,6 +60,12 @@ func (*protoBufScope) IsSumType() bool { return false } +// ValidatorName returns the protobuf validation helper convention used before +// the package catalog replaces it with an exact side-specific record. +func (p *protoBufScope) ValidatorName(att *expr.AttributeExpr, view string) string { + return "Validate" + p.Name(att, "", false, true) + codegen.Goify(view, true) +} + // Field returns the field name as generated by protocol buffer compiler. // NOTE: protoc does not care about common initialisms like api -> API so we // first transform the name into snake case to end up with Api. diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go index 6ebf69b5df..e86479d667 100644 --- a/grpc/codegen/protobuf_catalog.go +++ b/grpc/codegen/protobuf_catalog.go @@ -374,6 +374,16 @@ func (s *protobufValidationScope) Name(attribute *expr.AttributeExpr, pkg string return s.protoBufScope.Name(attribute, pkg, pointer, useDefault) } +// ValidatorName returns the exact side-specific protobuf validator retained by +// the package catalog. +func (s *protobufValidationScope) ValidatorName(attribute *expr.AttributeExpr, _ string) string { + validator := s.catalog.validationRecord(attribute, s.side) + if validator == nil { + panic("protobuf validator was not retained") + } + return validator.name +} + // collectMessageRecursive gathers imports and declarations while using record // identity itself as the cycle guard. func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string, sd *ServiceData) []string { diff --git a/grpc/codegen/required_union_validation_test.go b/grpc/codegen/required_union_validation_test.go new file mode 100644 index 0000000000..eddac0d457 --- /dev/null +++ b/grpc/codegen/required_union_validation_test.go @@ -0,0 +1,82 @@ +// This file verifies that the retained gRPC package plan renders complete +// required-OneOf validation into both transport-side type packages. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestRequiredUnionValidationUsesCompleteProtobufBranches(t *testing.T) { + root := RunGRPCDSL(t, requiredUnionValidationDSL) + services := CreateGRPCServices(root) + + for _, test := range []struct { + Name string + Files []*codegen.File + }{ + {Name: "server", Files: ServerTypeFiles(services)}, + {Name: "client", Files: ClientTypeFiles(services)}, + } { + t.Run(test.Name, func(t *testing.T) { + require.Len(t, test.Files, 1) + generated := sectionCode(t, test.Files[0].SectionTemplates[1:]...) + + require.Contains(t, generated, `goa.MissingFieldError("choice", "message")`) + require.Contains(t, generated, `goa.MissingFieldError("detail", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("inactive", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("blob", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("metadata", "message.choice")`) + require.Contains(t, generated, "if v == nil {") + require.Contains(t, generated, "if v.Detail == nil {") + require.Contains(t, generated, "if v.Inactive == nil {") + require.Contains(t, generated, "if v.Metadata == nil {") + require.NotContains(t, generated, "if v.Blob == nil {") + require.NotContains(t, generated, "if v.Token == nil {") + }) + } +} + +// requiredUnionValidationDSL covers constrained scalar, pointer-backed +// message, empty-message, bytes, primitive-alias, and Any branches. +func requiredUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + d.Service("UnionValidation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index a1fd95795e..4e44d496d5 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -45,7 +45,6 @@ func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File codegen.GoaNamedImport("grpc", "goagrpc"), {Path: "google.golang.org/grpc/codes"}, services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } for _, e := range data.Endpoints { @@ -146,9 +145,11 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } + if serviceHasViewedResult(data) { + imports = append(imports, services.ViewImport(svc.Name())) + } if responseMetadataNeedsFormat(data) { imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) } diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 7b72abeae6..68e6629253 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -506,12 +506,46 @@ func (sd *ServiceData) HasStreamingEndpoint() bool { return false } +// serviceHasViewedResult reports whether any generated transport section +// references the service views package through a viewed method result. +func serviceHasViewedResult(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + +// serviceHasUnaryViewedResult reports whether client/encode_decode.go emits a +// response decoder that constructs and validates a viewed unary result. +func serviceHasUnaryViewedResult(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ClientStream == nil && endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + +// serviceHasViewedClientStream reports whether client.go emits a receive +// method that constructs and validates a viewed streaming result. +func serviceHasViewedClientStream(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ClientStream != nil && + endpoint.ClientStream.RecvConvert != nil && + endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + // analyze creates the data necessary to render the code of the given service. func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { svc := d.ServicesData.Get(gs.Name()) transportService := *svc transportService.PkgName = d.ServiceImport(svc.Name).Name - transportService.ViewsPkg = d.ViewImport(svc.Name).Name svc = &transportService scope := codegen.NewNameScope() protobufPath := path.Join(d.GenPkg(), "grpc", svc.PathName, pbPkgName) diff --git a/grpc/codegen/templates/response_decoder.go.tpl b/grpc/codegen/templates/response_decoder.go.tpl index ed02cc497a..8bbbe1293e 100644 --- a/grpc/codegen/templates/response_decoder.go.tpl +++ b/grpc/codegen/templates/response_decoder.go.tpl @@ -59,7 +59,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Method.ViewedResult.ViewsPkg }}.Validate{{ .Method.Result }}(vres); err != nil { return nil, err } - return {{ .ServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil + return {{ .ServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Declaration.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil {{- else }} return res, nil {{- end }} diff --git a/grpc/codegen/templates/stream_recv.go.tpl b/grpc/codegen/templates/stream_recv.go.tpl index e97fda3301..5a0edb6241 100644 --- a/grpc/codegen/templates/stream_recv.go.tpl +++ b/grpc/codegen/templates/stream_recv.go.tpl @@ -65,7 +65,7 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { if err := {{ .Endpoint.Method.ViewedResult.ViewsPkg }}.Validate{{ .Endpoint.Method.Result }}(vres); err != nil { return nil, err } - return {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Name }}(vres), nil + return {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(vres), nil {{- else }} {{- if .RecvConvert.Validation }} if err = {{ .RecvConvert.Validation.Name }}(v); err != nil { diff --git a/grpc/codegen/templates/stream_send.go.tpl b/grpc/codegen/templates/stream_send.go.tpl index ef0146a0ed..6fac1dcca4 100644 --- a/grpc/codegen/templates/stream_send.go.tpl +++ b/grpc/codegen/templates/stream_send.go.tpl @@ -2,9 +2,9 @@ func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) + vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- else }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, s.view) + vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, s.view) {{- end }} {{- end }} v := {{ .SendConvert.Init.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index 8a18d520fa..9d066dbfda 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -1,9 +1,9 @@ import ( - interceptors "//interceptors" - cli "/grpc/cli/test" "fmt" "os" + cli "generated.local/gen/grpc/cli/test" + interceptors "generated.local/interceptors" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testdata/client-no-server.golden b/grpc/codegen/testdata/client-no-server.golden index 558ad34610..f7973537b0 100644 --- a/grpc/codegen/testdata/client-no-server.golden +++ b/grpc/codegen/testdata/client-no-server.golden @@ -1,8 +1,8 @@ import ( - cli "/grpc/cli/test_api" "fmt" "os" + cli "generated.local/gen/grpc/cli/test_api" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden index 39bced6089..8199afaeef 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden @@ -1,8 +1,8 @@ import ( - cli "/grpc/cli/single_host" "fmt" "os" + cli "generated.local/gen/grpc/cli/single_host" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset.golden b/grpc/codegen/testdata/client-server-hosting-service-subset.golden index 39bced6089..8199afaeef 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset.golden @@ -1,8 +1,8 @@ import ( - cli "/grpc/cli/single_host" "fmt" "os" + cli "generated.local/gen/grpc/cli/single_host" goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 4612ee935a..2a57c53057 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -6,12 +6,12 @@ package cli import ( - servicewithinterceptorsc "/grpc/service_with_interceptors/client" - servicewithinterceptors "/service_with_interceptors" "flag" "fmt" "os" + servicewithinterceptorsc "generated.local/gen/grpc/service_with_interceptors/client" + servicewithinterceptors "generated.local/gen/service_with_interceptors" goa "goa.design/goa/v3/pkg" grpc "google.golang.org/grpc" ) diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden index eb509cc136..deb3926605 100644 --- a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden @@ -6,11 +6,11 @@ package client import ( - payloadwithvalidation "/payload_with_validation" "fmt" "strconv" "unicode/utf8" + payloadwithvalidation "generated.local/gen/payload_with_validation" goa "goa.design/goa/v3/pkg" ) diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index 990acc37b6..a2e0467893 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -30,7 +30,7 @@ func CreateGRPCServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - return createServiceServicesForPackage(root, "/") + return createServiceServicesForPackage(root, "generated.local/gen") } // createServiceServicesForPackage builds test service analysis for the exact @@ -40,7 +40,8 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err != nil { panic(err) } - if err := service.Plan(root, generation); err != nil { + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { panic(err) } if err := Plan(generation); err != nil { @@ -52,11 +53,10 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - if err != nil { + if err := servicePlan.Link(); err != nil { panic(err) } - return services + return servicePlan.Services() } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index e2b565c448..d049ff3c0b 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -98,9 +98,11 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod {Path: "unicode/utf8"}, codegen.GoaImport(""), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } + if serviceHasViewedResult(sd) { + imports = append(imports, services.ViewImport(svc.Name())) + } // Add imports if Any type is used if usesAnyType(svc.GRPCEndpoints, true) { imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) diff --git a/http/codegen/client.go b/http/codegen/client.go index 6e899efcf3..26926e18b7 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -54,7 +54,9 @@ func ClientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), + } + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(svc.Name())) } for _, e := range data.Endpoints { if e.IsJSONRPC { @@ -143,22 +145,22 @@ func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "http", svcName, "client", "client.go") title := fmt.Sprintf("%s client HTTP transport", svc.Name()) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "fmt"}, + {Path: "io"}, + {Path: "mime/multipart"}, + {Path: "net/http"}, + {Path: "strconv"}, + {Path: "strings"}, + {Path: "time"}, + {Path: "github.com/gorilla/websocket"}, + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + services.ServiceImport(svc.Name()), + } sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "mime/multipart"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), - }), + codegen.Header(title, "client", imports), } sections = append(sections, &codegen.SectionTemplate{ Name: "client-struct", diff --git a/http/codegen/openapi_disabled_examples_test.go b/http/codegen/openapi_disabled_examples_test.go index a35f47cd06..2f836a49a6 100644 --- a/http/codegen/openapi_disabled_examples_test.go +++ b/http/codegen/openapi_disabled_examples_test.go @@ -27,10 +27,11 @@ func TestOpenAPIDisabledExamplesDoNotConsumeServiceState(t *testing.T) { examples := expr.NewExampleGenerator(factory) generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) - require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, examples) + servicePlan, err := service.NewPlan(root, generation, examples) require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() method := services.Get("testService").Methods[0] payloadExample := method.PayloadEx require.NotNil(t, payloadExample) diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 43a45f4360..9a7a33aa3e 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -23,11 +23,12 @@ func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() require.Equal(t, "path2", services.ServiceImport("Path").Name) } @@ -54,11 +55,12 @@ func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() client := services.PackageImport("generated.local/gen/http/foo/client") server := services.PackageImport("generated.local/gen/http/foo/server") diff --git a/http/codegen/server.go b/http/codegen/server.go index 2af67da1a2..e2dd4fadc2 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -63,7 +63,6 @@ func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -139,7 +138,9 @@ func ServerEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), + } + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(svc.Name())) } sections := []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index af41d9113a..3568c48d81 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -697,7 +697,6 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { svc := sds.ServicesData.Get(httpSvc.ServiceExpr.Name) transportService := *svc transportService.PkgName = sds.ServiceImport(svc.Name).Name - transportService.ViewsPkg = sds.ViewImport(svc.Name).Name svc = &transportService scope := codegen.NewNameScope() scope.Unique("c") // 'c' is reserved as the client's receiver name. @@ -3147,6 +3146,20 @@ func upgradeParams(e *EndpointData, fn string) map[string]any { } } +// serviceHasViewedResult reports whether the selected endpoint sections +// reference a projected result from the service views package. +func serviceHasViewedResult(service *ServiceData, selected func(*EndpointData) bool) bool { + for _, endpoint := range service.Endpoints { + if selected != nil && !selected(endpoint) { + continue + } + if endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + // NeedDialer returns true if at least one method in the defined services // uses WebSocket for sending payload or result. func NeedDialer(data []*ServiceData) bool { diff --git a/http/codegen/sse.go b/http/codegen/sse.go index 552b6449d6..2068abe331 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -163,21 +163,21 @@ func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "server", "sse.go") tmplSections := sseTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "sync"}, + {Path: "time"}, + {Path: "encoding/json"}, + {Path: "fmt"}, + services.ServiceImport(svc.Name()), + } sections = append(sections, codegen.Header( "sse", "server", - []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "sync"}, - {Path: "time"}, - {Path: "encoding/json"}, - {Path: "fmt"}, - services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), - }, + imports, ), ) sections = append(sections, tmplSections...) diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index db88b281a1..c243e59bf6 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -18,25 +18,25 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "client", "sse.go") tmplSections := sseClientTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) + imports := []*codegen.ImportSpec{ + {Path: "bytes"}, + {Path: "context"}, + {Path: "encoding/json"}, + {Path: "errors"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "fmt"}, + {Path: "strings"}, + {Path: "strconv"}, + {Path: "sync"}, + services.ServiceImport(svc.Name()), + {Path: "goa.design/goa/v3/http", Name: "goahttp"}, + } sections = append(sections, codegen.Header( "sse-client", "client", - []*codegen.ImportSpec{ - {Path: "bytes"}, - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: "strconv"}, - {Path: "sync"}, - services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), - {Path: "goa.design/goa/v3/http", Name: "goahttp"}, - }, + imports, ), ) sections = append(sections, tmplSections...) // add SSE client methods diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index 17ea5eeae8..be18ef20c4 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -46,7 +46,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) + res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) {{- else }} res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- end }} diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl index 571f98f62a..423768dc63 100644 --- a/http/codegen/templates/server_sse.go.tpl +++ b/http/codegen/templates/server_sse.go.tpl @@ -31,9 +31,9 @@ func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Conte {{- if .Method.ViewedResult }} {{- if .Method.ViewedResult.ViewName }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }}) + res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }}).Projected {{- else }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, "default") + res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, "default").Projected {{- end }} {{- else }} res := v diff --git a/http/codegen/templates/websocket_recv.go.tpl b/http/codegen/templates/websocket_recv.go.tpl index b5056a38e3..b405152670 100644 --- a/http/codegen/templates/websocket_recv.go.tpl +++ b/http/codegen/templates/websocket_recv.go.tpl @@ -74,7 +74,7 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { if err := {{ .ViewsPkg }}.Validate{{ $.Endpoint.Method.Result }}(vres); err != nil { return rv, goahttp.ErrValidationError("{{ $.Endpoint.ServiceName }}", "{{ $.Endpoint.Method.Name }}", err) } - return {{ $.PkgName }}.{{ .ResultInit.Name }}(vres){{ end }}, nil + return {{ $.PkgName }}.{{ .ResultInit.Declaration.Name }}(vres){{ end }}, nil {{- else }} return res, nil {{- end }} diff --git a/http/codegen/templates/websocket_send.go.tpl b/http/codegen/templates/websocket_send.go.tpl index 11f2f02b22..46f7ede462 100644 --- a/http/codegen/templates/websocket_send.go.tpl +++ b/http/codegen/templates/websocket_send.go.tpl @@ -9,9 +9,9 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- end }} {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) + res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- else }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, s.view) + res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, s.view) {{- end }} {{- else }} res := v diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index e6d3ed3a18..e2f72cdcd6 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -11,7 +11,7 @@ func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlin } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeToServicebodyinlinemapuserKeyType(val) + tk := marshalKeyTypeToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index 0c6fdc4900..d6eea4dea5 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyType]*ElemType) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeToServicebodyinlinemapuserKeyType(val) + tk := unmarshalKeyTypeToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden index a3ceeae6d6..654be71931 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden index f35627f687..040440eae3 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden index faadb67dc6..34aa73153b 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden index ba5d1bfda4..c8b483536b 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden index f619e4ac03..3f06b9f3d7 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden @@ -8,9 +8,9 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" + testserviceviews "generated.local/gen/test_service/views" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden index 53df220cd9..22f5c6970d 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden index 2ce9547f11..d324bed471 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden index 094974849d..d01e872d1c 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden index 8f75c0c388..9a07b8b207 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden index ac7c52aea4..03c405d894 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden index 220ad3505f..9b4d32ab20 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden index f4c0398268..7671bc270d 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden index 529ce34bcc..068c0dafdf 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden index 2dd48cf59f..16aded6d89 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden index f03105fd78..f3ca5995b8 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden index cd1c4bf4b1..f24784f0ca 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden index 764e00d6e6..fb0db2ca6a 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden index 06109588a3..3f73eaf1c0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden index e718e89ff1..6f9ba4e01a 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden index 2d098070c4..2ff0286cb4 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden index 70b1cf03c7..94c421bd5b 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden index af646086a5..8cc2142ed3 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden index 4758960f20..d605273824 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden index ff88ca3357..d0388454c2 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testing.go b/http/codegen/testing.go index df2c4db0cc..f2ebe27da7 100644 --- a/http/codegen/testing.go +++ b/http/codegen/testing.go @@ -19,11 +19,12 @@ func CreateHTTPServices(root *expr.RootExpr) *ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation, err := codegen.NewGeneration("/", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) if err != nil { panic(err) } - if err := service.Plan(root, generation); err != nil { + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { panic(err) } if err := Plan(generation); err != nil { @@ -35,9 +36,8 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - if err != nil { + if err := servicePlan.Link(); err != nil { panic(err) } - return services + return servicePlan.Services() } diff --git a/http/codegen/types.go b/http/codegen/types.go index 8ab2a57291..5cbfd67a82 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -88,15 +88,13 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod {Path: "unicode/utf8"}, services.ServiceImport(svc.Name()), } + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(svc.Name())) + } if len(unionTypes) > 0 { imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) } - views := services.ViewImport(svc.Name()) - if svr { - imports = append(imports, codegen.GoaImport(""), views) - } else { - imports = append(imports, views, codegen.GoaImport("")) - } + imports = append(imports, codegen.GoaImport("")) header := codegen.Header(svc.Name()+" "+services.label()+" "+side+" types", side, imports) var ( diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 2fe8b50d25..9a6f45aad5 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -286,9 +286,11 @@ func WebsocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ViewImport(svc.Name()), services.ServiceImport(svc.Name()), } + if serviceHasViewedResult(data, IsWebSocketEndpoint) { + imports = append(imports, services.ViewImport(svc.Name())) + } structSections := clientStructWSSections(data) wsSections := clientWSSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(structSections)+len(wsSections)) diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 7cbb4005a1..0a8f2fa351 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -60,26 +60,26 @@ func clientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") title := fmt.Sprintf("%s client JSON-RPC transport", svc.Name()) + imports := []*codegen.ImportSpec{ + {Path: "bufio"}, + {Path: "bytes"}, + {Path: "context"}, + {Path: "fmt"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "strconv"}, + {Path: "strings"}, + {Path: "sync"}, + {Path: "sync/atomic"}, + {Path: "time"}, + {Path: "github.com/gorilla/websocket"}, + codegen.GoaImport(""), + codegen.GoaImport("jsonrpc"), + codegen.GoaNamedImport("http", "goahttp"), + services.ServiceImport(svc.Name()), + } sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "bufio"}, - {Path: "bytes"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "sync/atomic"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), - }), + codegen.Header(title, "client", imports), } sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-struct", diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index 8865ff7865..304cfe63af 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -33,18 +33,18 @@ import ( func TestJSONRPCKitchenSink(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) roots := []eval.Root{root} - generation, err := goacodegen.NewGeneration("kitchensink", roots) + generation, err := goacodegen.NewGeneration("generated.local/gen", roots) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + servicePlan, err := service.NewPlan(root, generation, examples) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) require.NoError(t, jsonrpccodegen.Plan(generation)) require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) - - examples := expr.NewExampleGenerator(root.API.RandomizerFactory) - services, err := service.NewServicesData(root, generation, examples) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() tfiles := kitchenSinkTransportFiles(root, services) - efiles := kitchenSinkExampleFiles(root, services) + efiles := kitchenSinkExampleFiles(root, servicePlan) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -108,9 +108,10 @@ func kitchenSinkTransportFiles(root *expr.RootExpr, services *service.ServicesDa // kitchenSinkExampleFiles assembles example service and transport files // through their public subsystem APIs. -func kitchenSinkExampleFiles(root *expr.RootExpr, services *service.ServicesData) []*goacodegen.File { - files := service.ExampleServiceFiles(services.GenPkg(), root, services) - files = append(files, service.ExampleInterceptorsFiles(services.GenPkg(), root, services)...) +func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan) []*goacodegen.File { + services := plan.Services() + files := service.ExampleServiceFiles(plan) + files = append(files, service.ExampleInterceptorsFiles(plan)...) files = append(files, example.ServerFiles(root, services)...) files = append(files, example.CLIFiles(root)...) diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go index 4d3380e012..3c7a27e4a4 100644 --- a/jsonrpc/codegen/plan_test.go +++ b/jsonrpc/codegen/plan_test.go @@ -25,11 +25,12 @@ func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() require.Equal(t, "uuid2", services.ServiceImport("UUID").Name) } @@ -46,11 +47,12 @@ func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) - require.NoError(t, service.Plan(root, generation)) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) require.NoError(t, Plan(generation)) require.NoError(t, generation.Freeze()) - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() client := services.PackageImport("generated.local/gen/jsonrpc/foo/client") server := services.PackageImport("generated.local/gen/jsonrpc/foo/server") diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index 1bb7a8ea09..65ef9e7472 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -74,8 +74,10 @@ func serverFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), services.ServiceImport(svc.Name()), - services.ViewImport(svc.Name()), ) + if serviceHasViewedResult(data) { + imports = append(imports, services.ViewImport(svc.Name())) + } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), } @@ -132,6 +134,17 @@ func serverFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * return &codegen.File{Path: fpath, SectionTemplates: sections} } +// serviceHasViewedResult reports whether server.go emits endpoint conversion +// code that references the service views package. +func serviceHasViewedResult(service *httpcodegen.ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + // lowerInitial returns the string with the first letter in lowercase. func lowerInitial(s string) string { return strings.ToLower(s[:1]) + s[1:] diff --git a/jsonrpc/codegen/templates/response_decoder.go.tpl b/jsonrpc/codegen/templates/response_decoder.go.tpl index f53b85a866..3945a9244f 100644 --- a/jsonrpc/codegen/templates/response_decoder.go.tpl +++ b/jsonrpc/codegen/templates/response_decoder.go.tpl @@ -68,7 +68,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) + res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) {{- else }} res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- end }} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden index eb2d4af3e6..0f531f073c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - calc "kitchensink/calc" + calc "generated.local/gen/calc" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden index f569ea3367..fdd4c40270 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - chat "kitchensink/chat" + chat "generated.local/gen/chat" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden index e87b0383d2..b36ce29741 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden @@ -2,10 +2,10 @@ package main import ( "fmt" - cli "kitchensink/http/cli/kitchen_sink" "net/http" "time" + cli "generated.local/gen/http/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden index 774a6c155b..5463e09276 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden @@ -2,10 +2,10 @@ package main import ( "fmt" - cli2 "kitchensink/jsonrpc/cli/kitchen_sink" "net/http" "time" + cli2 "generated.local/gen/jsonrpc/cli/kitchen_sink" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden index da5f5da0fc..66e379e589 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden @@ -2,22 +2,22 @@ package main import ( "context" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - healthsvr "kitchensink/http/health/server" - mixedsvr "kitchensink/http/mixed/server" - calcjssvr "kitchensink/jsonrpc/calc/server" - chatjssvr "kitchensink/jsonrpc/chat/server" - feedjssvr "kitchensink/jsonrpc/feed/server" - mixedjssvr "kitchensink/jsonrpc/mixed/server" - mixed "kitchensink/mixed" "net/http" "net/url" "sync" "time" + calc "generated.local/gen/calc" + chat "generated.local/gen/chat" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + healthsvr "generated.local/gen/http/health/server" + mixedsvr "generated.local/gen/http/mixed/server" + calcjssvr "generated.local/gen/jsonrpc/calc/server" + chatjssvr "generated.local/gen/jsonrpc/chat/server" + feedjssvr "generated.local/gen/jsonrpc/feed/server" + mixedjssvr "generated.local/gen/jsonrpc/mixed/server" + mixed "generated.local/gen/mixed" "github.com/gorilla/websocket" "goa.design/clue/debug" "goa.design/clue/log" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden index 01d6a7cb4a..1040da0244 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden @@ -4,11 +4,6 @@ import ( "context" "flag" "fmt" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - mixed "kitchensink/mixed" "net" "net/url" "os" @@ -16,7 +11,12 @@ import ( "sync" "syscall" - kitchensink "." + kitchensink "generated.local" + calc "generated.local/gen/calc" + chat "generated.local/gen/chat" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + mixed "generated.local/gen/mixed" "goa.design/clue/debug" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden index 8fdbe4aca2..d2c06f5951 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - feed "kitchensink/feed" + feed "generated.local/gen/feed" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden index 6075cfb070..1562d2643d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden @@ -10,11 +10,11 @@ package cli import ( "flag" "fmt" - healthc "kitchensink/http/health/client" - mixedc "kitchensink/http/mixed/client" "net/http" "os" + healthc "generated.local/gen/http/health/client" + mixedc "generated.local/gen/http/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden index 21812454bd..75797ae4a0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - health "kitchensink/health" "net/http" + health "generated.local/gen/health" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden index ffb7c294ed..96a7b9543f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden index af9df4cd94..3945edfc8f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden @@ -11,10 +11,10 @@ import ( "bytes" "context" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden index 5f55e34495..a5fac14410 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden index 857b538a9a..35df2187ef 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "context" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden index 0d3139d9c7..0a9fa434ca 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden index cdd6f15233..01151f07dc 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden index ecafd2112a..8866e293ef 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - calc "kitchensink/calc" + + calc "generated.local/gen/calc" ) // BuildAddPayload builds the payload for the Calc add endpoint from CLI flags. diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden index 769f589798..7d2f888e70 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden @@ -11,10 +11,10 @@ import ( "bytes" "context" "io" - calc "kitchensink/calc" "net/http" "net/url" + calc "generated.local/gen/calc" "github.com/google/uuid" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index 0e168fe31b..af5082e93a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden index 11c6870d95..35f799af96 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden index 72f785418f..652315b565 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden @@ -13,9 +13,9 @@ import ( "errors" "fmt" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden index 7a32cda441..229784602a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden index d9a6e9bd34..55aba4ad27 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - chat "kitchensink/chat" + + chat "generated.local/gen/chat" ) // BuildEchoPayload builds the payload for the Chat echo endpoint from CLI diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden index 7df8db7d95..45f0e4eec4 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden @@ -11,10 +11,10 @@ import ( "bytes" "context" "io" - chat "kitchensink/chat" "net/http" "net/url" + chat "generated.local/gen/chat" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden index 73b4ab7475..8942c9dcc0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden @@ -8,7 +8,7 @@ package client import ( - chat "kitchensink/chat" + chat "generated.local/gen/chat" ) // EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden index 474ec665dc..87a8db2ff1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden @@ -13,13 +13,13 @@ import ( "encoding/json" "fmt" "io" - chat "kitchensink/chat" "net/http" "strconv" "sync" "sync/atomic" "time" + chat "generated.local/gen/chat" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden index 2678df7e92..9b99f1e7b4 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - chat "kitchensink/chat" "net/http" + chat "generated.local/gen/chat" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden index 6d511d6e1b..1196bfc314 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden @@ -10,9 +10,9 @@ package server import ( "context" "fmt" - chat "kitchensink/chat" "net/http" + chat "generated.local/gen/chat" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden index 7ec0943aed..1c2dc5e069 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden @@ -8,7 +8,7 @@ package server import ( - chat "kitchensink/chat" + chat "generated.local/gen/chat" ) // EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden index 6391bc1329..473903da48 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden @@ -10,10 +10,10 @@ package server import ( "context" "fmt" - chat "kitchensink/chat" "net/http" "time" + chat "generated.local/gen/chat" "github.com/gorilla/websocket" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index 89568c2478..bd616fa13e 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -10,13 +10,13 @@ package cli import ( "flag" "fmt" - calcc "kitchensink/jsonrpc/calc/client" - chatc "kitchensink/jsonrpc/chat/client" - feedc "kitchensink/jsonrpc/feed/client" - mixedc2 "kitchensink/jsonrpc/mixed/client" "net/http" "os" + calcc "generated.local/gen/jsonrpc/calc/client" + chatc "generated.local/gen/jsonrpc/chat/client" + feedc "generated.local/gen/jsonrpc/feed/client" + mixedc2 "generated.local/gen/jsonrpc/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden index 985370d22c..86c9749362 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - feed "kitchensink/feed" + + feed "generated.local/gen/feed" ) // BuildWatchPayload builds the payload for the Feed watch endpoint from CLI diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden index 8c82460695..8a28db0dfc 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden @@ -11,10 +11,10 @@ import ( "bytes" "context" "io" - feed "kitchensink/feed" "net/http" "net/url" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden index 8bdbe76ea3..8eebbdd70b 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden @@ -14,11 +14,11 @@ import ( "encoding/json" "fmt" "io" - feed "kitchensink/feed" "net/http" "strings" "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden index 56e7c1ac65..d60d1d6f93 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden @@ -8,7 +8,7 @@ package client import ( - feed "kitchensink/feed" + feed "generated.local/gen/feed" ) // WatchRequestBody is the type of the "Feed" service "watch" endpoint HTTP diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden index 460413bd0a..538fa5542c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - feed "kitchensink/feed" "net/http" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden index fa9536278a..2985b3bb6a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden @@ -10,9 +10,9 @@ package server import ( "context" "fmt" - feed "kitchensink/feed" "net/http" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden index 14f21e3c8e..6fd35a591f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden @@ -10,10 +10,10 @@ package server import ( "context" "fmt" - feed "kitchensink/feed" "net/http" "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden index 79512461e3..b028989817 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - feed "kitchensink/feed" - + feed "generated.local/gen/feed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden index 92afb4a877..f218a1b52a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden index 70091bef0b..c658a60a55 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden @@ -11,10 +11,10 @@ import ( "bytes" "context" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden index c179a3ff8d..5ec06e45fb 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden index 993f585576..cc08c2ce31 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden index 71c205ed09..27a6b44d55 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden @@ -13,9 +13,9 @@ import ( "errors" "fmt" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden index 33d0f1b2fa..9016c4dca3 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden index 0dc4247c85..789ab0a811 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - health "kitchensink/health" + health "generated.local/gen/health" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden index a375511629..985bb59267 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - mixed "kitchensink/mixed" + mixed "generated.local/gen/mixed" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 4f94e38ed1..27aacb5750 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -21,11 +21,12 @@ func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation, err := codegen.NewGeneration("/", []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) if err != nil { panic(err) } - if err := service.Plan(root, generation); err != nil { + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { panic(err) } if err := Plan(generation); err != nil { @@ -34,9 +35,8 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := generation.Freeze(); err != nil { panic(err) } - services, err := service.NewServicesData(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - if err != nil { + if err := servicePlan.Link(); err != nil { panic(err) } - return services + return servicePlan.Services() } From 0b908253a7bc0598d1a6e63bdf6b99f61930511b Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sat, 22 Aug 2026 01:34:51 -0700 Subject: [PATCH 30/43] fix(grpc): require a complete oneof branch --- ...uired_union_validation_integration_test.go | 16 +++--- codegen/templates/validation/required.go.tpl | 2 +- codegen/validation.go | 49 ++++++++++--------- codegen/validation_protobuf_union_test.go | 14 +++--- codegen/validation_union_context_test.go | 34 +++++++++++++ .../codegen/required_union_validation_test.go | 10 ++-- 6 files changed, 81 insertions(+), 44 deletions(-) diff --git a/codegen/generator/generate_grpc_required_union_validation_integration_test.go b/codegen/generator/generate_grpc_required_union_validation_integration_test.go index 586e2aae17..d50c124038 100644 --- a/codegen/generator/generate_grpc_required_union_validation_integration_test.go +++ b/codegen/generator/generate_grpc_required_union_validation_integration_test.go @@ -1,5 +1,5 @@ -// This file verifies generated gRPC client and server validators reject -// incomplete required OneOf branches while accepting every complete branch. +// This file checks that generated gRPC clients and servers reject an empty +// required OneOf and reject a selected branch whose value is nil. package generator import ( @@ -33,8 +33,8 @@ func TestGenerateGRPCRequiredUnionValidators(t *testing.T) { runGeneratedTests(t, dir) } -// requiredGRPCUnionValidationDSL gives request and response unions the same -// branch contract so generation must enforce it in both transport validators. +// requiredGRPCUnionValidationDSL creates request and response unions with the +// same branches so both generated checks must enforce the same rules. func requiredGRPCUnionValidationDSL() { d.API("required-union", func() {}) token := d.Type("Token", d.String) @@ -72,8 +72,8 @@ func requiredGRPCUnionValidationDSL() { }) } -// writeGRPCRequiredUnionValidationTest adds a consumer test that invokes the -// public validators generated into the server and client packages. +// writeGRPCRequiredUnionValidationTest adds a test which calls the generated +// server and client validation functions. func writeGRPCRequiredUnionValidationTest(t *testing.T, moduleDir string) { t.Helper() dir := filepath.Join(moduleDir, "uniontest") @@ -97,7 +97,7 @@ func TestServerRequestValidator(t *testing.T) { {Choice: &genpb.ExchangeRequest_Number{Number: 1}}, {Choice: &genpb.ExchangeRequest_Detail{Detail: &genpb.Detail{Label: "ready"}}}, {Choice: &genpb.ExchangeRequest_Inactive{Inactive: &genpb.Inactive{}}}, - {Choice: &genpb.ExchangeRequest_Blob{Blob: nil}}, + {Choice: &genpb.ExchangeRequest_Blob{Blob: []byte{}}}, {Choice: &genpb.ExchangeRequest_Token{Token: "ready"}}, } for _, message := range valid { @@ -112,6 +112,7 @@ func TestServerRequestValidator(t *testing.T) { assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: nilNumber}), "number", "\"number\" is missing from message.choice") assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Detail{}}), "detail", "\"detail\" is missing from message.choice") assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Blob{}}), "blob", "\"blob\" is missing from message.choice") } func TestClientResponseValidator(t *testing.T) { @@ -134,6 +135,7 @@ func TestClientResponseValidator(t *testing.T) { assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: nilDetail}), "detail", "\"detail\" is missing from message.choice") assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Detail{}}), "detail", "\"detail\" is missing from message.choice") assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Blob{}}), "blob", "\"blob\" is missing from message.choice") } func assertErrorName(t *testing.T, err error, name string) { diff --git a/codegen/templates/validation/required.go.tpl b/codegen/templates/validation/required.go.tpl index afc2a1054b..19ef72c589 100644 --- a/codegen/templates/validation/required.go.tpl +++ b/codegen/templates/validation/required.go.tpl @@ -1,4 +1,4 @@ -{{- if and (isUnion .reqAtt) (isAttributeScope .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} +{{- if and (isUnion .reqAtt) (isSumType .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }}.Kind() == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) } diff --git a/codegen/validation.go b/codegen/validation.go index 1cba8c4ee1..a94722d73d 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -1,5 +1,6 @@ -// This file generates validation code for service, view, and transport -// attributes using the package owner carried by each attribute context. +// This file generates functions that check service values and values sent over +// HTTP, gRPC, and JSON-RPC. Each function uses the Go names already chosen for +// its package. package codegen import ( @@ -13,32 +14,33 @@ import ( ) type ( - // unionValidationCase describes one concrete type accepted by a generated - // interface-union switch. + // unionValidationCase describes one possible union branch in generated + // validation code. unionValidationCase struct { - // Type is the generated concrete branch type. + // Type is the generated Go type for the branch. Type string - // Field is the protobuf wrapper field that carries the branch payload. + // Field is the field which stores the branch value. Field string - // Name is the design branch name used in validation errors. + // Name is the branch name shown in validation errors. Name string - // PayloadRequiresPresence reports whether a selected wrapper must carry - // a non-nil message payload. + // PayloadRequiresPresence is true when selecting this branch also + // requires a non-nil value. PayloadRequiresPresence bool - // Validation is the branch-specific validation code. + // Validation checks the value stored by this branch. Validation string } - // unionValidationData is the complete render input for one interface-union - // validation switch. + // unionValidationData contains the information needed to write one union + // check. unionValidationData struct { - // Target is the generated union expression inspected by the switch. + // Target is the generated union value being checked. Target string - // Context identifies the union in generated validation errors. + // Context identifies the union in validation errors. Context string - // Protobuf reports whether cases are pointer-backed protobuf wrappers. + // Protobuf is true when each selected branch is stored in its own generated + // protobuf struct. Protobuf bool - // Cases lists every concrete branch accepted by the union. + // Cases lists every branch accepted by the union. Cases []unionValidationCase } ) @@ -69,12 +71,11 @@ func init() { } return expr.IsUnion(att.Type) }, - "isAttributeScope": func(scope Attributor) bool { + "isSumType": func(scope Attributor) bool { if scope == nil { return false } - _, ok := scope.(*AttributeScope) - return ok + return scope.IsSumType() }, "isUnionPointer": func(ctx *AttributeContext, required bool) bool { return ctx.IsUnionPointer(required) @@ -301,12 +302,12 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A return buf } -// protobufUnionPayloadRequiresPresence reports whether the generated oneof -// wrapper field holds a protobuf message pointer. Protobuf scalar fields, -// including primitive aliases and bytes, store their value directly. Any and -// every non-primitive branch compile to message pointers. +// protobufUnionPayloadRequiresPresence reports whether selecting a protobuf +// union branch requires a non-nil value. Messages, byte slices, and Any values +// may be nil in Go, so their generated checks must reject nil explicitly. func protobufUnionPayloadRequiresPresence(att *expr.AttributeExpr) bool { - return !expr.IsPrimitive(att.Type) || unalias(att.Type).Kind() == expr.AnyKind + kind := unalias(att.Type).Kind() + return !expr.IsPrimitive(att.Type) || kind == expr.BytesKind || kind == expr.AnyKind } func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { diff --git a/codegen/validation_protobuf_union_test.go b/codegen/validation_protobuf_union_test.go index 53825bdd2d..98d6a0ed5a 100644 --- a/codegen/validation_protobuf_union_test.go +++ b/codegen/validation_protobuf_union_test.go @@ -1,5 +1,5 @@ -// This file verifies the generic validator emitted for protobuf-style OneOf -// interfaces, including wrapper and branch-payload presence. +// This file checks the validation generated for protobuf OneOf values. +// It rejects a missing selected branch and a selected branch whose value is nil. package codegen import ( @@ -12,8 +12,8 @@ import ( ) type ( - // protobufUnionTestScope models the wrapper references used by protoc so - // the generic validation generator can be tested without transport setup. + // protobufUnionTestScope returns the Go names used by the generated struct for + // each selected branch. This lets the test run without generating a service. protobufUnionTestScope struct { scope *NameScope } @@ -36,7 +36,7 @@ func TestProtobufUnionValidationRequiresCompleteSelectedBranch(t *testing.T) { require.Contains(t, generated, "if v.Detail == nil {") require.Contains(t, generated, "if v.Inactive == nil {") require.Contains(t, generated, "if v.Metadata == nil {") - require.NotContains(t, generated, "if v.Blob == nil {") + require.Contains(t, generated, "if v.Blob == nil {") require.NotContains(t, generated, "if v.Token == nil {") } @@ -76,8 +76,8 @@ func (s *protobufUnionTestScope) Scope() *NameScope { return s.scope } -// protobufUnionValidationDSL defines pointer-backed, scalar, and bytes OneOf -// branches so the generator must preserve their distinct presence semantics. +// protobufUnionValidationDSL creates OneOf branches stored as pointers, +// scalars, and byte slices. func protobufUnionValidationDSL() { token := d.Type("Token", d.String) detail := d.Type("Detail", func() { diff --git a/codegen/validation_union_context_test.go b/codegen/validation_union_context_test.go index 4f17d58eda..749bbe3124 100644 --- a/codegen/validation_union_context_test.go +++ b/codegen/validation_union_context_test.go @@ -11,6 +11,14 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // sumTypeTestScope reports that a union is stored directly as a Go value. The + // test checks that generated validation uses this information. + sumTypeTestScope struct { + Attributor + } +) + func TestUnionValidationPreservesValueContextForRequiredOnlyObjectBranches(t *testing.T) { root := RunDSL(t, requiredObjectUnionDSL) scope := NewNameScope() @@ -71,6 +79,32 @@ func TestUnionValidationUsesGeneratedFieldRepresentation(t *testing.T) { require.Contains(t, marshalCode, "if target.Optional != nil {") } +func TestUnionValidationUsesCustomSumTypeResolver(t *testing.T) { + union := &expr.Union{ + TypeName: "Scope", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "scope", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"scope"}}, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + context.Scope = &sumTypeTestScope{Attributor: context.Scope} + + generated := ValidationCode(attribute, nil, context, true, false, false, "target") + + require.Contains(t, generated, `if target.Scope.Kind() == "" {`) + require.NotContains(t, generated, "if target.Scope == nil {") +} + +func (*sumTypeTestScope) IsSumType() bool { + return true +} + // requiredObjectUnionDSL defines a OneOf with required-only object branches so // validation generation can distinguish pointer and value contexts. func requiredObjectUnionDSL() { diff --git a/grpc/codegen/required_union_validation_test.go b/grpc/codegen/required_union_validation_test.go index eddac0d457..66aa14baf8 100644 --- a/grpc/codegen/required_union_validation_test.go +++ b/grpc/codegen/required_union_validation_test.go @@ -1,5 +1,5 @@ -// This file verifies that the retained gRPC package plan renders complete -// required-OneOf validation into both transport-side type packages. +// This file checks the validation functions generated for gRPC server requests +// and client responses which contain a required OneOf. package codegen import ( @@ -35,14 +35,14 @@ func TestRequiredUnionValidationUsesCompleteProtobufBranches(t *testing.T) { require.Contains(t, generated, "if v.Detail == nil {") require.Contains(t, generated, "if v.Inactive == nil {") require.Contains(t, generated, "if v.Metadata == nil {") - require.NotContains(t, generated, "if v.Blob == nil {") + require.Contains(t, generated, "if v.Blob == nil {") require.NotContains(t, generated, "if v.Token == nil {") }) } } -// requiredUnionValidationDSL covers constrained scalar, pointer-backed -// message, empty-message, bytes, primitive-alias, and Any branches. +// requiredUnionValidationDSL creates branches whose values include scalars, +// messages, an empty message, a byte slice, a named string, and Any. func requiredUnionValidationDSL() { token := d.Type("Token", d.String) detail := d.Type("Detail", func() { From f797d9ceb41659902fdd68aa962e47b30b9bab4c Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sat, 22 Aug 2026 01:52:31 -0700 Subject: [PATCH 31/43] fix(codegen): retain exact HTTP and JSON-RPC plans --- codegen/cli/cli.go | 51 +- codegen/cli/symbols.go | 177 +++ codegen/cli/templates/build_payload.go.tpl | 4 +- codegen/cli/templates/command_usage.go.tpl | 6 +- codegen/cli/templates/parse_flags.go.tpl | 4 +- codegen/cli/templates/usage_commands.go.tpl | 4 +- codegen/cli/templates/usage_examples.go.tpl | 4 +- codegen/generator/example.go | 22 +- ...erated_transport_alias_integration_test.go | 167 ++- codegen/generator/plan.go | 54 +- codegen/generator/transport.go | 155 +- ...iewed_transport_import_integration_test.go | 14 +- ...ansport_representation_integration_test.go | 449 ++++++ .../viewed_transport_runtime_sources_test.go | 1258 +++++++++++++++++ codegen/service/imports.go | 9 +- .../jsonrpc_websocket_signature_test.go | 60 + codegen/service/plan_lifecycle.go | 78 +- codegen/service/projected_result_test.go | 76 + codegen/service/service.go | 1 + .../service/templates/return_type_init.go.tpl | 2 +- codegen/service/templates/service.go.tpl | 20 +- .../templates/service_endpoint_method.go.tpl | 13 + codegen/service/testdata/endpoint_code.go | 9 + ...eaming-result-with-explicit-view.go.golden | 2 +- ...onal-streaming-result-with-views.go.golden | 2 +- ...result-collection-multiple-views.go.golden | 2 +- ...-with-explicit-and-default-views.go.golden | 2 +- ...rvice-result-with-multiple-views.go.golden | 2 +- ...service-result-with-other-result.go.golden | 2 +- ...ce-result-with-result-collection.go.golden | 2 +- ...ayload-result-with-explicit-view.go.golden | 2 +- ...eaming-payload-result-with-views.go.golden | 2 +- ...eaming-result-with-explicit-view.go.golden | 2 +- ...vice-streaming-result-with-views.go.golden | 2 +- .../2026-08-20-generated-package-ownership.md | 10 +- expr/http_endpoint.go | 22 +- expr/jsonrpc_stream_contract_test.go | 45 + expr/streaming_response_mapping_test.go | 102 ++ grpc/codegen/client_cli.go | 52 +- grpc/codegen/example_cli.go | 5 + grpc/codegen/example_cli_test.go | 2 +- grpc/codegen/example_server_test.go | 2 +- grpc/codegen/parse_endpoint_test.go | 2 +- grpc/codegen/plan.go | 120 +- grpc/codegen/plan_test.go | 3 +- grpc/codegen/service_data.go | 8 +- grpc/codegen/templates/do_grpc_cli.go.tpl | 6 +- grpc/codegen/templates/parse_endpoint.go.tpl | 4 +- grpc/codegen/testing.go | 11 +- http/codegen/client.go | 14 +- http/codegen/client_body_types_test.go | 51 +- http/codegen/client_cli.go | 73 +- http/codegen/client_cli_test.go | 8 +- http/codegen/client_decode_test.go | 4 +- http/codegen/client_encode_test.go | 8 +- http/codegen/client_init_test.go | 4 +- http/codegen/clone.go | 129 ++ http/codegen/cookie_security_test.go | 8 +- http/codegen/example_cli.go | 20 +- http/codegen/example_cli_test.go | 4 +- http/codegen/example_server.go | 181 ++- http/codegen/example_server_test.go | 8 +- http/codegen/handler_test.go | 4 +- http/codegen/idempotency_test.go | 20 +- http/codegen/jsonrpc_data.go | 415 ++++++ http/codegen/multipart_test.go | 16 +- http/codegen/oneof_http_codegen_test.go | 12 +- .../openapi_order_independence_test.go | 42 +- http/codegen/paths.go | 13 +- http/codegen/paths_test.go | 8 +- http/codegen/plan.go | 1229 +++++++++++++++- http/codegen/plan_test.go | 549 ++++++- http/codegen/plan_test_helpers_test.go | 32 + http/codegen/server.go | 27 +- http/codegen/server_decode_test.go | 4 +- http/codegen/server_encode_test.go | 8 +- http/codegen/server_error_encoder_test.go | 4 +- http/codegen/server_handler_test.go | 4 +- http/codegen/server_init_test.go | 4 +- http/codegen/server_mount_test.go | 4 +- http/codegen/server_payload_types_test.go | 4 +- http/codegen/server_types_test.go | 4 +- http/codegen/service_data.go | 1136 ++++++++++----- http/codegen/service_data_purity_test.go | 4 +- .../service_data_union_nilability_test.go | 2 +- http/codegen/service_data_union_order_test.go | 25 +- http/codegen/service_imports.go | 6 +- http/codegen/sse.go | 60 +- http/codegen/sse_client.go | 23 +- http/codegen/sse_client_test.go | 4 +- http/codegen/sse_mixed_results_test.go | 6 +- http/codegen/sse_server_test.go | 8 +- http/codegen/streaming_test.go | 6 +- http/codegen/symbols.go | 396 ++++++ http/codegen/templates/append_fs.go.tpl | 18 +- .../templates/build_stream_request.go.tpl | 4 +- http/codegen/templates/cli_end.go.tpl | 4 +- http/codegen/templates/cli_usage.go.tpl | 4 +- .../templates/client_endpoint_init.go.tpl | 14 +- http/codegen/templates/client_init.go.tpl | 8 +- http/codegen/templates/client_sse.go.tpl | 206 ++- http/codegen/templates/client_struct.go.tpl | 4 +- .../dummy_multipart_request_decoder.go.tpl | 4 +- .../dummy_multipart_request_encoder.go.tpl | 4 +- http/codegen/templates/error_encoder.go.tpl | 4 +- http/codegen/templates/file_server.go.tpl | 4 +- .../templates/mount_point_struct.go.tpl | 4 +- .../multipart_request_decoder.go.tpl | 4 +- .../multipart_request_decoder_type.go.tpl | 4 +- .../multipart_request_encoder.go.tpl | 4 +- .../multipart_request_encoder_type.go.tpl | 4 +- http/codegen/templates/parse_endpoint.go.tpl | 12 +- http/codegen/templates/path.go.tpl | 2 +- http/codegen/templates/request_builder.go.tpl | 2 +- http/codegen/templates/request_decoder.go.tpl | 4 +- http/codegen/templates/request_encoder.go.tpl | 4 +- http/codegen/templates/request_init.go.tpl | 2 +- .../codegen/templates/response_decoder.go.tpl | 8 +- .../codegen/templates/response_encoder.go.tpl | 4 +- .../codegen/templates/server_configure.go.tpl | 14 +- http/codegen/templates/server_handler.go.tpl | 4 +- .../templates/server_handler_init.go.tpl | 46 +- http/codegen/templates/server_init.go.tpl | 20 +- .../templates/server_method_names.go.tpl | 2 +- http/codegen/templates/server_mount.go.tpl | 16 +- http/codegen/templates/server_service.go.tpl | 2 +- http/codegen/templates/server_sse.go.tpl | 176 ++- http/codegen/templates/server_struct.go.tpl | 6 +- http/codegen/templates/server_use.go.tpl | 2 +- .../codegen/templates/transform_helper.go.tpl | 8 +- http/codegen/templates/validate.go.tpl | 4 +- http/codegen/templates/websocket_close.go.tpl | 2 +- .../websocket_conn_configurer_struct.go.tpl | 4 +- ...bsocket_conn_configurer_struct_init.go.tpl | 6 +- http/codegen/templates/websocket_recv.go.tpl | 6 +- http/codegen/templates/websocket_send.go.tpl | 4 +- .../templates/websocket_set_view.go.tpl | 2 +- .../templates/websocket_struct_type.go.tpl | 4 +- ...dy-primitive-array-user-validate.go.golden | 2 +- ...t_body_type_init_body-user-inner.go.golden | 2 +- ...e_init_result-body-inline-object.go.golden | 6 +- ...esult-explicit-body-object-views.go.golden | 8 +- ...init_result-explicit-body-object.go.golden | 6 +- ...t_result-explicit-body-primitive.go.golden | 6 +- ...t_result-explicit-body-user-type.go.golden | 6 +- .../golden/client_cli_multi-build.go.golden | 2 +- ...ient_cli_payload-array-user-type.go.golden | 2 +- ...client_cli_payload-map-user-type.go.golden | 4 +- ...ecode_body-result-multiple-views.go.golden | 2 +- ...empty-body-result-multiple-views.go.golden | 2 +- ...e_explicit-body-primitive-result.go.golden | 2 +- ...licit-body-result-multiple-views.go.golden | 2 +- ...decode_tag-result-multiple-views.go.golden | 4 +- ...ode_validate-error-response-type.go.golden | 2 +- ...e_with-headers-dsl-viewed-result.go.golden | 2 +- ...rvices-same-payload-and-result_0.go.golden | 4 +- ...rvices-same-payload-and-result_1.go.golden | 4 +- ...types_client-mixed-payload-attrs.go.golden | 4 +- ...methods-with-array-type-payloads.go.golden | 4 +- ...nt_types_client-multiple-methods.go.golden | 2 +- ...ypes_client-result-type-validate.go.golden | 4 +- ...es_client-with-result-collection.go.golden | 2 +- ...nt_types_client-with-result-view.go.golden | 10 +- ...xtend-primitive-field-array-user.go.golden | 2 +- ...dy-extend-primitive-field-string.go.golden | 2 +- ...e_decode-body-path-user-validate.go.golden | 2 +- ...ver_decode_decode-body-path-user.go.golden | 2 +- ...dy-primitive-array-user-required.go.golden | 2 +- ...dy-primitive-array-user-validate.go.golden | 2 +- ...mitive-field-array-user-validate.go.golden | 2 +- ...-body-primitive-field-array-user.go.golden | 2 +- ...de-body-query-path-user-validate.go.golden | 2 +- ...code_decode-body-query-path-user.go.golden | 2 +- ..._decode-body-query-user-validate.go.golden | 2 +- ...er_decode_decode-body-query-user.go.golden | 2 +- ...er_decode_decode-body-union-user.go.golden | 2 +- .../server_decode_decode-body-union.go.golden | 2 +- ...r_decode_decode-body-user-nested.go.golden | 2 +- ...decode_decode-body-user-required.go.golden | 2 +- ...decode_decode-body-user-validate.go.golden | 2 +- .../server_decode_decode-body-user.go.golden | 2 +- .../server_decode_decode-deep-user.go.golden | 8 +- ...r_decode_decode-map-query-object.go.golden | 2 +- ...al_array-alias-extended_section0.go.golden | 6 +- ...al_array-alias-extended_section1.go.golden | 8 +- ...mbedded-custom-pkg-type_section0.go.golden | 4 +- ...mbedded-custom-pkg-type_section1.go.golden | 7 +- ...al_extension-with-alias_section0.go.golden | 8 +- ...al_extension-with-alias_section1.go.golden | 6 +- ...al_extension-with-alias_section2.go.golden | 10 +- ...al_extension-with-alias_section3.go.golden | 10 +- ...al_extension-with-alias_section4.go.golden | 6 +- ...oad_types_body-inline-array-user.go.golden | 6 +- ...yload_types_body-inline-map-user.go.golden | 10 +- ...types_body-inline-recursive-user.go.golden | 6 +- ...ad_types_body-path-user-validate.go.golden | 7 +- ...ver_payload_types_body-path-user.go.golden | 4 +- ...es_body-query-path-user-validate.go.golden | 4 +- ...yload_types_body-query-path-user.go.golden | 6 +- ...s_body-query-user-union-validate.go.golden | 6 +- ...load_types_body-query-user-union.go.golden | 6 +- ...d_types_body-query-user-validate.go.golden | 7 +- ...er_payload_types_body-query-user.go.golden | 4 +- .../server_payload_types_body-union.go.golden | 4 +- ...ad_types_body-user-inner-default.go.golden | 9 +- ...er_payload_types_body-user-inner.go.golden | 6 +- ...types_server-mixed-payload-attrs.go.golden | 8 +- ...er_types_server-multiple-methods.go.golden | 12 +- ...lection-sibling-user-type-fields.go.golden | 2 +- ...es_server-with-result-collection.go.golden | 2 +- ...h-result-nested-user-type-fields.go.golden | 4 +- ...-result-sibling-user-type-fields.go.golden | 4 +- ...er_types_server-with-result-view.go.golden | 2 +- .../testdata/golden/sse-all-fields.golden | 67 +- http/codegen/testdata/golden/sse-bool.golden | 44 +- .../testdata/golden/sse-client-object.golden | 2 +- .../testdata/golden/sse-data-field.golden | 42 +- .../testdata/golden/sse-data-id-field.golden | 51 +- http/codegen/testdata/golden/sse-int.golden | 44 +- .../codegen/testdata/golden/sse-object.golden | 42 +- .../testdata/golden/sse-request-id.golden | 44 +- .../codegen/testdata/golden/sse-string.golden | 44 +- ...irectional-streaming-complex-client.golden | 2 +- ...ctional-streaming-with-views-client.golden | 2 +- http/codegen/testdata/streaming_code.go | 53 +- http/codegen/testing.go | 43 - http/codegen/transform_helper_test.go | 8 +- http/codegen/typedef.go | 14 +- http/codegen/types.go | 31 +- http/codegen/viewed_sse_test.go | 376 +++++ http/codegen/websocket.go | 54 +- http/codegen/websocket_golden_test.go | 12 +- http/codegen/wire_catalog.go | 724 ++++++++-- http/codegen/wire_catalog_test.go | 68 +- jsonrpc/codegen/client.go | 181 ++- jsonrpc/codegen/example_server.go | 84 -- jsonrpc/codegen/idempotency_test.go | 4 +- jsonrpc/codegen/kitchen_sink_test.go | 61 +- jsonrpc/codegen/plan.go | 660 ++++++++- jsonrpc/codegen/plan_test.go | 579 +++++++- jsonrpc/codegen/server.go | 183 +-- jsonrpc/codegen/server_error_contract_test.go | 53 + jsonrpc/codegen/service_imports.go | 42 +- jsonrpc/codegen/sse.go | 92 +- jsonrpc/codegen/sse_dedup_test.go | 8 +- jsonrpc/codegen/sse_integration_test.go | 6 +- jsonrpc/codegen/sse_test.go | 4 +- jsonrpc/codegen/templates.go | 13 +- .../templates/client_endpoint_init.go.tpl | 43 +- jsonrpc/codegen/templates/client_init.go.tpl | 8 +- .../codegen/templates/client_struct.go.tpl | 20 +- .../templates/mixed_server_handler.go.tpl | 11 +- .../partial/element_slice_conversion.go.tpl | 2 +- .../partial/header_conversion.go.tpl | 38 + .../partial/query_type_conversion.go.tpl | 22 +- .../templates/partial/single_response.go.tpl | 16 +- .../partial/slice_item_conversion.go.tpl | 50 +- .../partial/viewed_result_metadata.go.tpl | 77 + .../codegen/templates/response_decoder.go.tpl | 38 +- .../templates/server_encode_error.go.tpl | 10 +- .../codegen/templates/server_handler.go.tpl | 22 +- .../templates/server_handler_init.go.tpl | 65 +- jsonrpc/codegen/templates/server_init.go.tpl | 24 +- .../templates/server_method_names.go.tpl | 2 + jsonrpc/codegen/templates/server_mount.go.tpl | 16 +- .../codegen/templates/server_service.go.tpl | 2 + .../codegen/templates/server_struct.go.tpl | 6 +- jsonrpc/codegen/templates/server_use.go.tpl | 2 +- .../templates/sse_client_stream.go.tpl | 70 +- .../templates/sse_server_handler.go.tpl | 42 +- .../templates/sse_server_stream.go.tpl | 85 +- .../templates/sse_server_stream_base.go.tpl | 102 +- .../viewed_result_body_decode.go.tpl | 10 + .../templates/viewed_result_decode.go.tpl | 57 + .../templates/viewed_result_encode.go.tpl | 77 + .../templates/websocket_client_conn.go.tpl | 506 ++++++- .../templates/websocket_client_stream.go.tpl | 535 +++---- .../templates/websocket_server_close.go.tpl | 23 +- .../templates/websocket_server_handler.go.tpl | 4 +- .../templates/websocket_server_recv.go.tpl | 40 +- .../templates/websocket_server_send.go.tpl | 45 +- .../templates/websocket_server_stream.go.tpl | 6 +- .../websocket_server_stream_wrapper.go.tpl | 40 +- .../websocket_stream_error_types.go.tpl | 18 +- .../testdata/golden/jsonrpc-sse-object.golden | 38 +- .../testdata/golden/jsonrpc-sse-string.golden | 30 +- .../gen/jsonrpc/calc/client/client.go.golden | 4 +- .../gen/jsonrpc/calc/client/types.go.golden | 4 +- .../gen/jsonrpc/calc/server/server.go.golden | 30 +- .../gen/jsonrpc/chat/client/client.go.golden | 519 ++++++- .../jsonrpc/chat/client/websocket.go.golden | 408 +++--- .../gen/jsonrpc/chat/server/server.go.golden | 6 +- .../jsonrpc/chat/server/websocket.go.golden | 72 +- .../gen/jsonrpc/feed/client/client.go.golden | 13 +- .../gen/jsonrpc/feed/client/stream.go.golden | 65 +- .../gen/jsonrpc/feed/server/server.go.golden | 72 +- .../gen/jsonrpc/feed/server/sse.go.golden | 134 +- .../gen/jsonrpc/mixed/client/client.go.golden | 4 +- .../gen/jsonrpc/mixed/server/server.go.golden | 22 +- jsonrpc/codegen/testing.go | 53 +- jsonrpc/codegen/viewed_result.go | 305 ++++ .../viewed_result_runtime_regression_test.go | 758 ++++++++++ jsonrpc/codegen/websocket_client.go | 107 +- .../websocket_connection_runtime_test.go | 699 +++++++++ jsonrpc/codegen/websocket_connection_test.go | 118 ++ jsonrpc/codegen/websocket_server.go | 71 +- 306 files changed, 14767 insertions(+), 3268 deletions(-) create mode 100644 codegen/cli/symbols.go create mode 100644 codegen/generator/viewed_transport_representation_integration_test.go create mode 100644 codegen/generator/viewed_transport_runtime_sources_test.go create mode 100644 codegen/service/jsonrpc_websocket_signature_test.go create mode 100644 codegen/service/projected_result_test.go create mode 100644 expr/jsonrpc_stream_contract_test.go create mode 100644 expr/streaming_response_mapping_test.go create mode 100644 http/codegen/clone.go create mode 100644 http/codegen/jsonrpc_data.go create mode 100644 http/codegen/plan_test_helpers_test.go create mode 100644 http/codegen/symbols.go delete mode 100644 http/codegen/testing.go create mode 100644 http/codegen/viewed_sse_test.go delete mode 100644 jsonrpc/codegen/example_server.go create mode 100644 jsonrpc/codegen/server_error_contract_test.go create mode 100644 jsonrpc/codegen/templates/partial/header_conversion.go.tpl create mode 100644 jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl create mode 100644 jsonrpc/codegen/templates/server_method_names.go.tpl create mode 100644 jsonrpc/codegen/templates/server_service.go.tpl create mode 100644 jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl create mode 100644 jsonrpc/codegen/templates/viewed_result_decode.go.tpl create mode 100644 jsonrpc/codegen/templates/viewed_result_encode.go.tpl create mode 100644 jsonrpc/codegen/viewed_result.go create mode 100644 jsonrpc/codegen/viewed_result_runtime_regression_test.go create mode 100644 jsonrpc/codegen/websocket_connection_runtime_test.go create mode 100644 jsonrpc/codegen/websocket_connection_test.go diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index 8151a42055..8dd4648649 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -19,6 +19,10 @@ import ( type ( // CommandData contains the data needed to render a command. CommandData struct { + // ServiceName is the design service selected by this command. + ServiceName string + // UsageDeclaration is the package function that prints help for this command. + UsageDeclaration *codegen.NameDeclaration // Name of command e.g. "cellar-storage" Name string // VarName is the name of the command variable e.g. @@ -39,6 +43,10 @@ type ( // SubcommandData contains the data needed to render a sub-command. SubcommandData struct { + // MethodName is the design method selected by this command. + MethodName string + // UsageDeclaration is the package function that prints help for this subcommand. + UsageDeclaration *codegen.NameDeclaration // Name is the sub-command name e.g. "add" Name string // FullName is the sub-command full name e.g. "storageAdd" @@ -93,8 +101,8 @@ type ( // function that builds a service method payload type from the command-line // flags. BuildFunctionData struct { - // Name is the build payload function name. - Name string + // Declaration is the package name used by the function definition and calls. + Declaration *codegen.NameDeclaration // Description describes the payload function. Description string // ActualParams is the list of passed build function parameters. @@ -118,6 +126,17 @@ type ( CheckErr bool } + // ParserDeclarations contains every package function written to one command + // parser file. + ParserDeclarations struct { + // ParseEndpoint is the function that selects and builds an endpoint call. + ParseEndpoint *codegen.NameDeclaration + // UsageCommands is the function that lists available commands. + UsageCommands *codegen.NameDeclaration + // UsageExamples is the function that prints example commands. + UsageExamples *codegen.NameDeclaration + } + // FlagArgData describes a payload initialization argument from which a // command-line flag and the code that loads the flag value into the // corresponding payload builder field are generated. @@ -202,6 +221,7 @@ func BuildCommandData(data *service.Data, clientPkgName string) *CommandData { } return &CommandData{ + ServiceName: data.Name, Name: codegen.KebabCase(data.Name), VarName: codegen.Goify(data.Name, false), Description: description, @@ -260,6 +280,7 @@ func BuildSubcommandData(data *service.Data, m *service.MethodData, buildFunctio } } sub := &SubcommandData{ + MethodName: m.Name, Name: name, FullName: fullName, Description: description, @@ -281,13 +302,14 @@ func EndpointParserFile( path, title string, specs []*codegen.ImportSpec, data []*CommandData, + declarations *ParserDeclarations, parseSection *codegen.SectionTemplate, ) *codegen.File { sections := make([]*codegen.SectionTemplate, 0, 4+len(data)) sections = append(sections, codegen.Header(title, "cli", specs), - UsageCommands(data), - UsageExamples(data), + UsageCommands(data, declarations.UsageCommands), + UsageExamples(data, declarations.UsageExamples), parseSection, ) for _, cmd := range data { @@ -340,7 +362,6 @@ func MakeFlags( } return flags, &BuildFunctionData{ - Name: "Build" + m.VarName + "Payload", ActualParams: params, FormalParams: params, ServiceName: svcn, @@ -368,7 +389,7 @@ func PayloadBuildersFile(path, title string, specs []*codegen.ImportSpec, data * // UsageCommands builds a section template that generates a help text showing // the list of allowed commands and sub-commands. -func UsageCommands(data []*CommandData) *codegen.SectionTemplate { +func UsageCommands(data []*CommandData, declaration *codegen.NameDeclaration) *codegen.SectionTemplate { usages := make([]string, len(data)) for i, cmd := range data { subs := make([]string, len(cmd.Subcommands)) @@ -383,12 +404,18 @@ func UsageCommands(data []*CommandData) *codegen.SectionTemplate { usages[i] = fmt.Sprintf("%s %s%s%s", cmd.Name, lp, strings.Join(subs, "|"), rp) } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageCommandsT), Data: usages} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageCommandsT), + Data: struct { + Declaration *codegen.NameDeclaration + Usages []string + }{declaration, usages}, + } } // UsageExamples builds a section template that generates a help text showing // a valid invocation of the CLI tool. -func UsageExamples(data []*CommandData) *codegen.SectionTemplate { +func UsageExamples(data []*CommandData, declaration *codegen.NameDeclaration) *codegen.SectionTemplate { var examples []string for i, cmd := range data { if i < 5 { @@ -396,7 +423,13 @@ func UsageExamples(data []*CommandData) *codegen.SectionTemplate { } } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageExamplesT), Data: examples} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageExamplesT), + Data: struct { + Declaration *codegen.NameDeclaration + Examples []string + }{declaration, examples}, + } } // FlagsCode returns a string containing the code that parses the command-line diff --git a/codegen/cli/symbols.go b/codegen/cli/symbols.go new file mode 100644 index 0000000000..ea63783893 --- /dev/null +++ b/codegen/cli/symbols.go @@ -0,0 +1,177 @@ +// This file assigns the package function names used by command-line client +// files. HTTP and gRPC planning call these functions before generated names +// are finalized, then pass the returned records to the shared CLI templates. +package cli + +import ( + "bytes" + "cmp" + "crypto/sha256" + "encoding/binary" + + "goa.design/goa/v3/codegen" +) + +type ( + // CommandDeclarationInput names one service command and each method command + // written to a parser file. + CommandDeclarationInput struct { + // Service is the design service name used to identify this command. + Service string + // Methods lists the design methods accepted by this service command. + Methods []string + } + + // ParserPlan contains the names written to one command parser package. + ParserPlan struct { + // Declarations contains the three functions shared by the parser file. + Declarations *ParserDeclarations + // Commands contains the help function names for each design service. + Commands map[string]*CommandPlan + } + + // CommandPlan contains the help function names for one service command. + CommandPlan struct { + // Usage is the service help function. + Usage *codegen.NameDeclaration + // Methods contains help functions indexed by design method name. + Methods map[string]*codegen.NameDeclaration + } + + // symbolOrder identifies one shared CLI function by the design names that + // select its output file and contents. + symbolOrder struct { + family string + root string + server string + service string + method string + role symbolRole + commands [sha256.Size]byte + } + + // symbolRole lists the package functions emitted by shared CLI templates. + symbolRole uint8 +) + +const ( + parseEndpointRole symbolRole = iota + 1 + usageCommandsRole + usageExamplesRole + commandUsageRole + methodUsageRole + payloadBuilderRole +) + +// DeclareParser submits every function written to one parser package. family +// is "http", "jsonrpc", or "grpc"; root and server distinguish files from +// separate designs; commands supplies the service and method help names. +func DeclareParser(pkg *codegen.GeneratedPackage, family, root, server string, commands []CommandDeclarationInput) (*ParserPlan, error) { + commandNames := commandDeclarationNames(commands) + declare := func(preferred string, role symbolRole, service, method string) (*codegen.NameDeclaration, error) { + visibility := codegen.ExportedName + if role == commandUsageRole || role == methodUsageRole { + visibility = codegen.UnexportedName + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + visibility, + symbolOrder{family: family, root: root, server: server, service: service, method: method, role: role, commands: commandNames}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + parseEndpoint, err := declare("ParseEndpoint", parseEndpointRole, "", "") + if err != nil { + return nil, err + } + usageCommands, err := declare("UsageCommands", usageCommandsRole, "", "") + if err != nil { + return nil, err + } + usageExamples, err := declare("UsageExamples", usageExamplesRole, "", "") + if err != nil { + return nil, err + } + plan := &ParserPlan{ + Declarations: &ParserDeclarations{ + ParseEndpoint: parseEndpoint, + UsageCommands: usageCommands, + UsageExamples: usageExamples, + }, + Commands: make(map[string]*CommandPlan, len(commands)), + } + for _, command := range commands { + usage, err := declare(goifyTerms(command.Service)+"Usage", commandUsageRole, command.Service, "") + if err != nil { + return nil, err + } + commandPlan := &CommandPlan{ + Usage: usage, + Methods: make(map[string]*codegen.NameDeclaration, len(command.Methods)), + } + for _, method := range command.Methods { + methodUsage, err := declare(goifyTerms(command.Service, method)+"Usage", methodUsageRole, command.Service, method) + if err != nil { + return nil, err + } + commandPlan.Methods[method] = methodUsage + } + plan.Commands[command.Service] = commandPlan + } + return plan, nil +} + +// DeclarePayloadBuilder submits the function that builds one method payload +// from command-line flags and returns the record used by its definition and +// calls. +func DeclarePayloadBuilder(pkg *codegen.GeneratedPackage, family, root, service, method, preferred string) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + codegen.ExportedName, + symbolOrder{family: family, root: root, service: service, method: method, role: payloadBuilderRole}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// ComparePackageName orders CLI functions by the design and output file that +// writes them, so reversing input designs does not change their final names. +func (order symbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(symbolOrder) + for _, compared := range []int{ + cmp.Compare(order.family, right.family), + cmp.Compare(order.root, right.root), + cmp.Compare(order.server, right.server), + cmp.Compare(order.service, right.service), + cmp.Compare(order.method, right.method), + cmp.Compare(order.role, right.role), + } { + if compared != 0 { + return compared + } + } + return bytes.Compare(order.commands[:], right.commands[:]) +} + +// commandDeclarationNames returns fixed-size bytes derived from every service +// and method name written into one parser file. +func commandDeclarationNames(commands []CommandDeclarationInput) [sha256.Size]byte { + var encoded []byte + for _, command := range commands { + encoded = binary.AppendUvarint(encoded, uint64(len(command.Service))) + encoded = append(encoded, command.Service...) + encoded = binary.AppendUvarint(encoded, uint64(len(command.Methods))) + for _, method := range command.Methods { + encoded = binary.AppendUvarint(encoded, uint64(len(method))) + encoded = append(encoded, method...) + } + } + return sha256.Sum256(encoded) +} diff --git a/codegen/cli/templates/build_payload.go.tpl b/codegen/cli/templates/build_payload.go.tpl index 4de3b35885..0dc0d5850a 100644 --- a/codegen/cli/templates/build_payload.go.tpl +++ b/codegen/cli/templates/build_payload.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s builds the payload for the %s %s endpoint from CLI flags." .Name .ServiceName .MethodName | comment }} -func {{ .Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .ResultType }}, error) { +{{ printf "%s builds the payload for the %s %s endpoint from CLI flags." .Declaration.Name .ServiceName .MethodName | comment }} +func {{ .Declaration.Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .ResultType }}, error) { {{- if .CheckErr }} var err error {{- end }} diff --git a/codegen/cli/templates/command_usage.go.tpl b/codegen/cli/templates/command_usage.go.tpl index 9832a1425d..7436e45b39 100644 --- a/codegen/cli/templates/command_usage.go.tpl +++ b/codegen/cli/templates/command_usage.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%sUsage displays the usage of the %s command and its subcommands." .VarName .Name | comment }} -func {{ .VarName }}Usage() { +{{ printf "%s displays the usage of the %s command and its subcommands." .UsageDeclaration.Name .Name | comment }} +func {{ .UsageDeclaration.Name }}() { fmt.Fprintln(os.Stderr, `{{ printDescription .Description }}`) fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] {{ .Name }} COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") @@ -12,7 +12,7 @@ func {{ .VarName }}Usage() { } {{- range .Subcommands }} -func {{ .FullName }}Usage() { +func {{ .UsageDeclaration.Name }}() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] {{ $.Name }} {{ .Name }}", os.Args[0]) {{- range .Flags }} diff --git a/codegen/cli/templates/parse_flags.go.tpl b/codegen/cli/templates/parse_flags.go.tpl index f8617fff3c..6b5ab16d2d 100644 --- a/codegen/cli/templates/parse_flags.go.tpl +++ b/codegen/cli/templates/parse_flags.go.tpl @@ -12,9 +12,9 @@ var ( ) {{ range . -}} {{ $cmd := . -}} - {{ .VarName }}Flags.Usage = {{ .VarName }}Usage + {{ .VarName }}Flags.Usage = {{ .UsageDeclaration.Name }} {{ range .Subcommands -}} - {{ .FullName }}Flags.Usage = {{ .FullName }}Usage + {{ .FullName }}Flags.Usage = {{ .UsageDeclaration.Name }} {{ end }} {{ end }} if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { diff --git a/codegen/cli/templates/usage_commands.go.tpl b/codegen/cli/templates/usage_commands.go.tpl index b63c271756..90673253cf 100644 --- a/codegen/cli/templates/usage_commands.go.tpl +++ b/codegen/cli/templates/usage_commands.go.tpl @@ -2,9 +2,9 @@ // // command (subcommand1|subcommand2|...) // -func UsageCommands() []string { +func {{ .Declaration.Name }}() []string { return []string{ -{{- range . }} +{{- range .Usages }} "{{ . }}", {{- end }} } diff --git a/codegen/cli/templates/usage_examples.go.tpl b/codegen/cli/templates/usage_examples.go.tpl index 2fb7d84239..8b72d24d0b 100644 --- a/codegen/cli/templates/usage_examples.go.tpl +++ b/codegen/cli/templates/usage_examples.go.tpl @@ -1,5 +1,5 @@ // UsageExamples produces an example of a valid invocation of the CLI tool. -func UsageExamples() string { - return {{ range . }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + +func {{ .Declaration.Name }}() string { + return {{ range .Examples }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + {{ end }}"" } diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 7e01b63a06..140516c13d 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -7,8 +7,6 @@ import ( "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" grpccodegen "goa.design/goa/v3/grpc/codegen" - httpcodegen "goa.design/goa/v3/http/codegen" - jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) // exampleFiles returns example service, server, and client files described by @@ -41,30 +39,30 @@ func exampleFiles(plan *Plan) ([]*codegen.File, error) { } // HTTP - if len(r.API.HTTP.Services) > 0 { - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(httpServices); len(fs) != 0 { - files = append(files, fs...) + if httpPlan := plan.http[r]; httpPlan != nil { + if plan.jsonrpc[r] == nil { + if fs := httpPlan.ExampleServerFiles(); len(fs) != 0 { + files = append(files, fs...) + } } - if fs := httpcodegen.ExampleCLIFiles(httpServices); len(fs) != 0 { + if fs := httpPlan.ExampleCLIFiles(); len(fs) != 0 { files = append(files, fs...) } } // JSON-RPC - if len(r.API.JSONRPC.Services) > 0 { - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(jsonrpcServices, files); len(fs) > 0 { + if jsonrpcPlan := plan.jsonrpc[r]; jsonrpcPlan != nil { + if fs := jsonrpcPlan.ExampleServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(jsonrpcServices); len(fs) > 0 { + if fs := jsonrpcPlan.ExampleCLIFiles(); len(fs) > 0 { files = append(files, fs...) } } // GRPC if len(r.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services) + grpcServices := grpccodegen.NewServicesData(services, plan.grpc) if fs := grpccodegen.ExampleServerFiles(grpcServices); len(fs) > 0 { files = append(files, fs...) } diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go index 97a55dd730..f43bf3e072 100644 --- a/codegen/generator/generated_transport_alias_integration_test.go +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -1,5 +1,5 @@ -// This file verifies generated transport packages and example applications -// consume the same complete-path aliases selected during planning. +// This file checks that generated files use the exact package names assigned +// before the files are written. package generator import ( @@ -11,11 +11,28 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) +type ( + httpHelperCollisionOrder string + jsonRPCSharedPackageMode uint8 +) + +const ( + jsonRPCUnary jsonRPCSharedPackageMode = iota + jsonRPCSSE + jsonRPCWebSocket +) + +// ComparePackageName orders names added by the collision test. +func (o httpHelperCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(httpHelperCollisionOrder))) +} + // TestGeneratedTransportPackagesCompileWithServiceAliasCollisions proves that -// client, server, protobuf, CLI, and service imports remain paired when their -// preferred qualifiers collide. +// client, server, protobuf, command-line, and service imports still name the +// service they belong to when service names produce the same Go import name. func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing.T) { root := codegen.RunDSL(t, func() { interceptor := dsl.Interceptor("Trace", func() {}) @@ -57,3 +74,145 @@ func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing. } runGeneratedTests(t, dir) } + +// TestGeneratedHTTPHelpersCompileWithPackageNameCollisions checks that file and +// mixed-result stream helpers use their chosen names in definitions and calls. +func TestGeneratedHTTPHelpersCompileWithPackageNameCollisions(t *testing.T) { + root := httpHelperCollisionRoot(t, "Foo Bar", "First") + reserve := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/foo_bar/server") + if err != nil { + return err + } + declarations := []*codegen.NameDeclaration{ + codegen.NewPreferredName(codegen.NameType, "appendFS", codegen.UnexportedName, httpHelperCollisionOrder("append-fs")), + codegen.NewPreferredName(codegen.NameFunction, "appendPrefix", codegen.UnexportedName, httpHelperCollisionOrder("append-prefix")), + codegen.NewPreferredName(codegen.NameType, "discardCreateServerStream", codegen.UnexportedName, httpHelperCollisionOrder("discard-stream")), + } + for _, declaration := range declarations { + if err := pkg.DeclareName(declaration); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, reserve, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + protocolFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, protocolFiles...) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots checks that each +// generated client and server uses the names chosen for its own declarations +// when two designs write files into the same Go packages. +func TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + mode jsonRPCSharedPackageMode + }{ + {name: "ordinary", mode: jsonRPCUnary}, + {name: "server sent events", mode: jsonRPCSSE}, + {name: "web socket", mode: jsonRPCWebSocket}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := jsonRPCSharedPackageRoot(t, "Foo Bar", "First", test.mode) + second := jsonRPCSharedPackageRoot(t, "Foo-Bar", "Second", test.mode) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{first, second}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + for _, root := range []*expr.RootExpr{first, second} { + jsonPlan := plan.jsonrpc[root] + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// jsonRPCSharedPackageRoot returns one design whose service name controls the +// generated directory. typePrefix keeps its service types separate from the +// other design that writes into the same directory. Every design uses the Call +// method, so their stream types and constructors request the same Go names. +func jsonRPCSharedPackageRoot(t *testing.T, serviceName, typePrefix string, mode jsonRPCSharedPackageMode) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Call", func() { + switch mode { + case jsonRPCUnary: + dsl.Payload(payload) + dsl.Result(result) + dsl.JSONRPC(func() {}) + case jsonRPCSSE: + dsl.Payload(payload) + dsl.StreamingResult(result) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + case jsonRPCWebSocket: + dsl.StreamingPayload(payload) + dsl.StreamingResult(result) + dsl.JSONRPC(func() {}) + default: + panic("unknown JSON-RPC test mode") + } + }) + }) + }) +} + +// httpHelperCollisionRoot returns one design with mixed HTTP results and a +// mapped file. serviceName controls the output directory and typePrefix keeps +// the service types distinct when two designs share that directory. +func httpHelperCollisionRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) +} diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go index 0860ecb978..2bd59e8fe3 100644 --- a/codegen/generator/plan.go +++ b/codegen/generator/plan.go @@ -1,6 +1,5 @@ -// This file defines the run-private plan shared by core generators and plugins. -// Task-specific retained analyses are added as typed private fields by the -// subsystem tasks that consume this lifecycle foundation. +// This file stores the input designs, chosen Go names, and output files for one +// run. Built-in file writers and plugins read the same values. package generator import ( @@ -10,29 +9,36 @@ import ( "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) type ( - // Plan retains the typed state shared by planning and rendering in one run. - // Planning may add declarations to Generation; rendering receives the same - // plan only after those declarations are frozen. + // Plan holds the input designs, chosen Go names, and generated files for one + // run. Code that writes files receives it after all Go names are known. Plan struct { generation *codegen.Generation preparedRoots []eval.Root examples map[*expr.RootExpr]*expr.ExampleGenerator services map[*expr.RootExpr]*service.Plan + http map[*expr.RootExpr]*httpcodegen.Plan + jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan + jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan + grpc *grpccodegen.PreparedPlan + transportDone bool design *designSnapshot } ) -// Generation returns the declaration and import catalog for this run. +// Generation returns the names chosen for Go declarations and imports in this +// run. func (p *Plan) Generation() *codegen.Generation { return p.generation } -// Service returns the retained service plan collected for root. It panics for -// an unplanned root because plugins and transports must consume the exact core -// analysis rather than reconstructing one. +// Service returns the generated service data for root. It panics when root was +// not included in this run. func (p *Plan) Service(root *expr.RootExpr) *service.Plan { plan, ok := p.services[root] if !ok { @@ -41,8 +47,8 @@ func (p *Plan) Service(root *expr.RootExpr) *service.Plan { return plan } -// exampleGenerator returns the mutable example state created for root in this -// run. A root outside the prepared plan is an orchestration bug. +// exampleGenerator returns the example values created for root. It panics when +// root was not included in this run. func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { generator, ok := p.examples[root] if !ok { @@ -51,8 +57,7 @@ func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { return generator } -// link resolves every collected subsystem plan through the frozen generation -// before any core or plugin renderer receives it. +// link completes each service and then builds the protocol files that use it. func (p *Plan) link() error { for _, root := range serviceRoots(p.preparedRoots) { plan, ok := p.services[root] @@ -63,11 +68,28 @@ func (p *Plan) link() error { return err } } + for _, root := range serviceRoots(p.preparedRoots) { + if plan := p.http[root]; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := p.jsonrpcHTTP[root]; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := p.jsonrpc[root]; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + } return nil } -// verifyPreparedDesign rejects the first expression change made after -// preparation and identifies the callback or render operation that made it. +// verifyPreparedDesign reports the first service design value changed after +// planning and names the code that changed it. func (p *Plan) verifyPreparedDesign(operation string) error { path, err := p.design.changedPath(p.preparedRoots) if err != nil { diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 2e55c8c808..80bfedf2cd 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -1,17 +1,17 @@ -// This file assembles HTTP, gRPC, and JSON-RPC files from service analysis; -// each transport builder owns the imports of the file it returns. +// This file builds the HTTP, gRPC, and JSON-RPC files for every service. Each +// generated file lists the Go packages that it uses. package generator import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// transportFiles returns HTTP, gRPC, and JSON-RPC files described by plan's -// frozen package declarations and run-owned example state. +// transportFiles returns all HTTP, gRPC, and JSON-RPC files for one run. func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File generation := plan.Generation() @@ -19,63 +19,142 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { for _, r := range designRoots { services := plan.Service(r).Services() // HTTP - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(httpServices)...) - files = append(files, httpcodegen.ClientFiles(httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(httpServices)...) - files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(httpServices)...) + if httpPlan := plan.http[r]; httpPlan != nil { + files = append(files, httpPlan.ServerFiles()...) + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) + } // GRPC - grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(grpcServices)...) - files = append(files, grpccodegen.ServerFiles(grpcServices)...) - files = append(files, grpccodegen.ClientFiles(grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) + if plan.grpc != nil { + grpcServices := grpccodegen.NewServicesData(services, plan.grpc) + files = append(files, grpccodegen.ProtoFiles(grpcServices)...) + files = append(files, grpccodegen.ServerFiles(grpcServices)...) + files = append(files, grpccodegen.ClientFiles(grpcServices)...) + files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) + files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) + } // JSON-RPC - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(jsonrpcServices)...) - files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(jsonrpcServices)...) + if jsonrpcPlan := plan.jsonrpc[r]; jsonrpcPlan != nil { + files = append(files, jsonrpcPlan.ServerFiles()...) + files = append(files, jsonrpcPlan.ClientFiles()...) + files = append(files, jsonrpcPlan.ServerTypeFiles()...) + files = append(files, jsonrpcPlan.ClientTypeFiles()...) + files = append(files, jsonrpcPlan.PathFiles()...) + files = append(files, jsonrpcPlan.ClientCLIFiles()...) + } } return files, nil } -// planTransportData declares service packages and the fixed import qualifiers -// required by each transport before the shared generation catalog freezes. +// planTransportData chooses all Go package, import, type, and function names +// before generated files use them. func planTransportData(plan *Plan) error { if err := planServiceData(plan); err != nil { return err } + if plan.transportDone { + return nil + } generation := plan.Generation() if err := example.Plan(generation); err != nil { return err } - var hasHTTP, hasGRPC, hasJSONRPC bool - for _, root := range serviceRoots(generation.Roots()) { - hasHTTP = hasHTTP || len(root.API.HTTP.Services) > 0 + roots := serviceRoots(generation.Roots()) + if err := planHTTPTransports(plan, roots); err != nil { + return err + } + if err := planJSONRPCTransports(plan, roots); err != nil { + return err + } + var hasGRPC bool + for _, root := range roots { hasGRPC = hasGRPC || len(root.API.GRPC.Services) > 0 - hasJSONRPC = hasJSONRPC || len(root.API.JSONRPC.Services) > 0 } - if hasHTTP { - if err := httpcodegen.Plan(generation); err != nil { + if hasGRPC { + inputs := make([]grpccodegen.PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = grpccodegen.PlanInput{Root: root, Service: plan.Service(root)} + } + grpcPlan, err := grpccodegen.Plan(generation, inputs...) + if err != nil { return err } + plan.grpc = grpcPlan } - if hasGRPC { - if err := grpccodegen.Plan(generation); err != nil { - return err + plan.transportDone = true + return nil +} + +// planHTTPTransports prepares every HTTP service together. When two services +// write to the same Go package, Goa gives their types and functions different names. +func planHTTPTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.HTTP.Services) == 0 { + continue } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + if len(inputs) == 0 { + return nil + } + plans, err := httpcodegen.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + plan.http = make(map[*expr.RootExpr]*httpcodegen.Plan, len(plans)) + for index, root := range plannedRoots { + plan.http[root] = plans[index] + } + return nil +} + +// planJSONRPCTransports prepares the HTTP request and response types used by +// JSON-RPC. It then gives those values to the JSON-RPC generator so function +// definitions and calls use the same Go names. +func planJSONRPCTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.JSONRPC.Services) == 0 { + continue + } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + if len(inputs) == 0 { + return nil + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + jsonrpcInputs := make([]jsonrpccodegen.PlanInput, len(inputs)) + plan.jsonrpcHTTP = make(map[*expr.RootExpr]*httpcodegen.Plan, len(httpPlans)) + for index, root := range plannedRoots { + plan.jsonrpcHTTP[root] = httpPlans[index] + jsonrpcInputs[index] = jsonrpccodegen.PlanInput{ + Root: root, + Service: plan.Service(root), + HTTP: httpPlans[index], + ApplicationHTTP: plan.http[root], + } + } + jsonrpcPlans, err := jsonrpccodegen.NewPlans(plan.Generation(), jsonrpcInputs...) + if err != nil { + return err } - if hasJSONRPC { - return jsonrpccodegen.Plan(generation) + plan.jsonrpc = make(map[*expr.RootExpr]*jsonrpccodegen.Plan, len(jsonrpcPlans)) + for index, root := range plannedRoots { + plan.jsonrpc[root] = jsonrpcPlans[index] } return nil } diff --git a/codegen/generator/viewed_transport_import_integration_test.go b/codegen/generator/viewed_transport_import_integration_test.go index 84bb77953d..3c211d043e 100644 --- a/codegen/generator/viewed_transport_import_integration_test.go +++ b/codegen/generator/viewed_transport_import_integration_test.go @@ -147,10 +147,10 @@ func TestViewedTransportClientImportsCompile(t *testing.T) { runGeneratedTests(t, genDir) } -// assertViewedStreamingTransportFiles verifies that HTTP SSE and WebSocket -// services render and that only the WebSocket receive file imports views -// directly. Server send files call the service constructor and therefore do -// not import the views package themselves. +// assertViewedStreamingTransportFiles checks the generated HTTP SSE and +// WebSocket files. Client files import the views package to validate each result +// after rebuilding it. Server files use the service constructor, which performs +// that validation without a direct views import. func assertViewedStreamingTransportFiles(t *testing.T, genDir string) { t.Helper() httpSSE := codegen.SnakeCase("ViewedHTTPSSE") @@ -167,9 +167,13 @@ func assertViewedStreamingTransportFiles(t *testing.T, genDir string) { filepath.Join(genDir, "http", httpWebSocket, "client", "websocket.go"), "/"+httpWebSocket+"/views\"", ) + assertImportPath( + t, + filepath.Join(genDir, "http", httpSSE, "client", "sse.go"), + "/"+httpSSE+"/views\"", + ) assertFilesOmitImportPath(t, genDir, "/"+httpSSE+"/views\"", []string{ filepath.Join("http", httpSSE, "server", "sse.go"), - filepath.Join("http", httpSSE, "client", "sse.go"), }) assertNoImportPath( t, diff --git a/codegen/generator/viewed_transport_representation_integration_test.go b/codegen/generator/viewed_transport_representation_integration_test.go new file mode 100644 index 0000000000..06abc7302b --- /dev/null +++ b/codegen/generator/viewed_transport_representation_integration_test.go @@ -0,0 +1,449 @@ +// This file checks that generated clients and servers send each result view +// with the JSON fields selected by that view. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +const jsonRPCViewedWebSocketServerTest = `package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_web_socket" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + releaseWatch chan struct{} + sendErrors chan error +} + +func (*viewedService) HandleStream(context.Context, service.Stream) error { + return nil +} + +func (s *viewedService) Watch(ctx context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + go func() { + <-s.releaseWatch + s.sendErrors <- stream.SendResponse(ctx, viewedEvent("watch-event")) + }() + return nil +} + +func (s *viewedService) Inspect(ctx context.Context, stream service.InspectServerStream) error { + stream.SetView("detailed") + s.sendErrors <- stream.SendResponse(ctx, viewedEvent("inspect-event")) + close(s.releaseWatch) + return nil +} + +func (*viewedService) Fixed(context.Context, service.FixedServerStream) error { + return nil +} + +type wireMessage struct { + ID any ` + "`" + `json:"id"` + "`" + ` + Method string ` + "`" + `json:"method"` + "`" + ` + Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` +} + +func TestViewedWebSocketServerUsesPerMessageView(t *testing.T) { + svc := &viewedService{ + releaseWatch: make(chan struct{}), + sendErrors: make(chan error, 2), + } + directError := make(chan error, 1) + acknowledged := make(chan struct{}) + var acknowledge sync.Once + releaseServer := func() { + acknowledge.Do(func() { close(acknowledged) }) + } + handler := func(ctx context.Context, stream service.Stream) error { + if err := stream.SendWatchNotification(ctx, viewedEvent("direct-watch"), "summary"); err != nil { + return err + } + if err := stream.SendInspectNotification(ctx, viewedEvent("direct-inspect"), "detailed"); err != nil { + return err + } + if err := stream.SendFixedNotification(ctx, viewedEvent("direct-fixed")); err != nil { + return err + } + directError <- stream.SendWatchNotification(ctx, viewedEvent("invalid"), "unknown") + for range 2 { + if err := stream.Recv(ctx); err != nil { + return err + } + } + <-acknowledged + return nil + } + server := New( + handler, + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { t.Errorf("serve WebSocket: %v", err) }, + &websocket.Upgrader{}, + nil, + ) + httpServer := httptest.NewServer(server) + defer httpServer.Close() + conn, _, err := websocket.DefaultDialer.Dial( + "ws"+strings.TrimPrefix(httpServer.URL, "http")+"/stream", + nil, + ) + require.NoError(t, err) + defer func() { require.NoError(t, conn.Close()) }() + defer releaseServer() + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + + directWatch := readWireMessage(t, conn) + require.Equal(t, "watch", directWatch.Method) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"direct-watch"}}` + "`" + `, + string(directWatch.Params), + ) + directInspect := readWireMessage(t, conn) + require.Equal(t, "inspect", directInspect.Method) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"direct-inspect","profile":{"display_name":"Ada"}}}` + "`" + `, + string(directInspect.Params), + ) + directFixed := readWireMessage(t, conn) + require.Equal(t, "fixed", directFixed.Method) + require.JSONEq(t, + ` + "`" + `{"event_id":"direct-fixed","profile":{"display_name":"Ada"}}` + "`" + `, + string(directFixed.Params), + ) + requireBoundaryError(t, <-directError, goa.InvalidEnumValue, "view") + + require.NoError(t, conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", "id": "watch-id", "method": "watch", "params": map[string]any{"key": "watch"}, + })) + require.NoError(t, conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", "id": "inspect-id", "method": "inspect", "params": map[string]any{"key": "inspect"}, + })) + inspect := readWireMessage(t, conn) + require.Equal(t, "inspect-id", inspect.ID) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"inspect-event","profile":{"display_name":"Ada"}}}` + "`" + `, + string(inspect.Result), + ) + watch := readWireMessage(t, conn) + require.Equal(t, "watch-id", watch.ID) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"watch-event"}}` + "`" + `, + string(watch.Result), + ) + for range 2 { + require.NoError(t, <-svc.sendErrors) + } + releaseServer() +} + +func readWireMessage(t *testing.T, conn *websocket.Conn) wireMessage { + t.Helper() + var message wireMessage + require.NoError(t, conn.ReadJSON(&message)) + return message +} + +func viewedEvent(id string) *service.Event { + return &service.Event{EventID: id, Profile: &service.Profile{DisplayName: "Ada"}} +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +// TestGeneratedHTTPViewedSSEServerUsesRequestView checks that an SSE request +// uses the view selected by the service call. A method with one fixed view does +// not choose a view while it runs. +func TestGeneratedHTTPViewedSSEServerUsesRequestView(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "server"), httpViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/server") +} + +// TestGeneratedHTTPViewedSSEClientRebuildsResult checks that an SSE client +// reads the selected HTTP body before rebuilding the service result, including +// JSON field names and nested fields. +func TestGeneratedHTTPViewedSSEClientRebuildsResult(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "client"), httpViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/client") +} + +// TestGeneratedJSONRPCUnaryViewedRepresentation checks that a one-result call +// sends both the selected view name and its JSON body. The view name must not +// come from an HTTP header. +func TestGeneratedJSONRPCUnaryViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "client"), jsonRPCViewedUnaryClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/client") +} + +// TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation checks that the +// server writes the selected view and matching body in the JSON-RPC result. A +// method with one fixed view writes only the body. +func TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "server"), jsonRPCViewedUnaryServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/server") +} + +// TestGeneratedJSONRPCSSEViewedRepresentation checks that JSON-RPC SSE pairs +// every view name with its matching body and rebuilds service results for both +// notifications and final responses. +func TestGeneratedJSONRPCSSEViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "client"), jsonRPCViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/client") +} + +// TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation checks that SSE +// notifications and final responses contain the same view name and body that +// clients read. Methods with one fixed view contain only the body. +func TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "server"), jsonRPCViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/server") +} + +// TestGeneratedJSONRPCWebSocketDirectSendsRequireView checks that each direct +// send chooses a view when several are legal. A method with one fixed view does +// not accept a view argument, and only methods that need a choice have SetView. +func TestGeneratedJSONRPCWebSocketDirectSendsRequireView(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) + writeGeneratedContractTest(t, dir, "jsonrpc_web_socket", jsonRPCViewedWebSocketInterfaceTest) + runGeneratedPackageTests(t, dir, "./jsonrpc_web_socket") +} + +// TestGeneratedJSONRPCWebSocketRoutesResponsesByRequestID checks that two +// methods can share one connection, write at the same time, and receive +// responses in reverse order without either method receiving the wrong result. +func TestGeneratedJSONRPCWebSocketRoutesResponsesByRequestID(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_web_socket", "client"), jsonRPCViewedWebSocketRuntimeTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_web_socket/client") +} + +// TestGeneratedJSONRPCWebSocketServerUsesPerCallViews checks that each request +// and direct send writes the body for its own view, even when two methods share +// one connection and finish out of order. +func TestGeneratedJSONRPCWebSocketServerUsesPerCallViews(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_web_socket", "server"), jsonRPCViewedWebSocketServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_web_socket/server") +} + +// generateViewedTransportModule generates a temporary Go module for one test. +func generateViewedTransportModule(t *testing.T, design func()) string { + t.Helper() + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + codegen.RunDSL(t, design) + dir := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, dir, "generated.local/gen") + _, err := generate(filepath.Dir(dir), "gen", false, registry) + require.NoError(t, err) + return dir +} + +// writeGeneratedContractTest adds a test that calls one generated package. +// The generated module is temporary; the source tree remains untouched. +func writeGeneratedContractTest(t *testing.T, moduleDir, packageDir, source string) { + t.Helper() + dir := filepath.Join(moduleDir, packageDir) + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_contract_test.go"), []byte(source), 0o600)) +} + +// runGeneratedPackageTests compiles and runs one generated package. Limiting +// the command to that package makes a failure point to the code under test. +func runGeneratedPackageTests(t *testing.T, dir, packagePattern string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePattern) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("test generated package %s: %v\n%s", packagePattern, err, output) + } +} + +// viewedResultType defines a result whose selected view changes both the JSON +// body fields and their JSON names. +func viewedResultType() *expr.ResultTypeExpr { + profile := dsl.Type("Profile", func() { + dsl.Attribute("display_name", dsl.String) + dsl.Required("display_name") + }) + return dsl.ResultType("application/vnd.viewed-event", func() { + dsl.TypeName("Event") + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("profile", profile) + dsl.Required("event_id", "profile") + dsl.View("summary", func() { + dsl.Attribute("event_id") + }) + dsl.View("detailed", func() { + dsl.Attribute("event_id") + dsl.Attribute("profile") + }) + }) +} + +// viewedHTTPSSEDSL creates HTTP SSE methods with selectable and fixed views. +func viewedHTTPSSEDSL() { + event := viewedResultType() + immediate := dsl.Type("Immediate", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("HTTP View Stream", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.HTTP(func() { + dsl.GET("/fixed") + dsl.ServerSentEvents() + }) + }) + dsl.Method("mixed", func() { + dsl.Result(immediate) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/mixed") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedJSONRPCUnaryDSL creates one-result JSON-RPC methods with selectable +// and fixed views. +func viewedJSONRPCUnaryDSL() { + event := viewedResultType() + dsl.Service("JSON RPC Unary", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCSSEDSL creates JSON-RPC SSE methods with selectable and fixed +// views. +func viewedJSONRPCSSEDSL() { + event := viewedResultType() + dsl.Service("JSON RPC SSE", func() { + dsl.JSONRPC(func() { + dsl.POST("/events") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) +} + +// viewedJSONRPCWebSocketDSL creates JSON-RPC WebSocket methods with selectable +// and fixed views on one service. +func viewedJSONRPCWebSocketDSL() { + event := viewedResultType() + dsl.Service("JSON RPC WebSocket", func() { + dsl.JSONRPC(func() { + dsl.Path("/stream") + }) + dsl.Method("watch", func() { + dsl.StreamingPayload(func() { + dsl.Attribute("key", dsl.String) + dsl.Required("key") + }) + dsl.StreamingResult(event) + dsl.JSONRPC(func() {}) + }) + dsl.Method("inspect", func() { + dsl.StreamingPayload(func() { + dsl.Attribute("key", dsl.String) + dsl.Required("key") + }) + dsl.StreamingResult(event) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.StreamingPayload(func() { + dsl.Attribute("key", dsl.String) + dsl.Required("key") + }) + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/codegen/generator/viewed_transport_runtime_sources_test.go b/codegen/generator/viewed_transport_runtime_sources_test.go new file mode 100644 index 0000000000..3b8541abe1 --- /dev/null +++ b/codegen/generator/viewed_transport_runtime_sources_test.go @@ -0,0 +1,1258 @@ +// This file contains source code that calls generated HTTP and JSON-RPC code +// with result views. Each source string runs in a temporary Go module. +package generator + +const httpViewedSSEServerTest = `package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + serviceDefaults + watchView string +} + +type unknownViewService struct { + serviceDefaults + sendError chan error +} + +type changingViewService struct { + serviceDefaults + sendError chan error +} + +type sendResultService struct { + serviceDefaults + sendError chan error +} + +type fixedErrorAfterEventService struct{ serviceDefaults } + +type mixedErrorAfterEventService struct{ serviceDefaults } + +type serviceDefaults struct{} + +type statusRecorder struct { + *httptest.ResponseRecorder + statuses []int +} + +type streamResponseWriter struct { + header http.Header + status int + body strings.Builder + writeError error +} + +var errEventWrite = errors.New("event write failed") +var errAfterEvent = errors.New("service failed after event") + +func (serviceDefaults) Watch(_ context.Context, _ service.WatchServerStream) error { + return nil +} + +func (serviceDefaults) Fixed(_ context.Context, _ service.FixedServerStream) error { + return nil +} + +func (serviceDefaults) Mixed(_ context.Context, _ service.MixedServerStream) (*service.Immediate, error) { + return nil, nil +} + +func (s *viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView(s.watchView) + return stream.Send(viewedEvent()) +} + +func (s *viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changingViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) + s.sendError <- err + return nil +} + +func (*changingViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *sendResultService) Watch(_ context.Context, stream service.WatchServerStream) error { + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*sendResultService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Watch(_ context.Context, stream service.WatchServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return errAfterEvent +} + +func (*mixedErrorAfterEventService) Mixed(_ context.Context, stream service.MixedServerStream) (*service.Immediate, error) { + if err := stream.Send(viewedEvent()); err != nil { + return nil, err + } + return nil, errAfterEvent +} + +func (w *statusRecorder) WriteHeader(status int) { + w.statuses = append(w.statuses, status) + w.ResponseRecorder.WriteHeader(status) +} + +func (w *streamResponseWriter) Header() http.Header { + return w.header +} + +func (w *streamResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *streamResponseWriter) Write(data []byte) (int, error) { + if w.writeError != nil { + return 0, w.writeError + } + return w.body.Write(data) +} + +func TestViewedSSEServerUsesRequestView(t *testing.T) { + svc := &viewedService{watchView: "detailed"} + handler := NewWatchHandler( + service.NewWatchEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + require.Equal(t, "detailed", recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerIsSpecialized(t *testing.T) { + _, exposesSetView := reflect.TypeOf((*service.FixedServerStream)(nil)).Elem().MethodByName("SetView") + require.False(t, exposesSetView) + + svc := &viewedService{} + handler := NewFixedHandler( + service.NewFixedEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewFixedHandler( + service.NewFixedEndpoint(&fixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestMixedResultSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewMixedHandler( + service.NewMixedEndpoint(&mixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + request := httptest.NewRequest("GET", "/mixed", nil) + request.Header.Set("Accept", "text/event-stream") + recorder := &statusRecorder{ResponseRecorder: httptest.NewRecorder()} + handler.ServeHTTP(recorder, request) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, []int{http.StatusOK}, recorder.statuses) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.JSONEq(t, + ` + "`" + `{"EventID":"event-1","Profile":{"DisplayName":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&unknownViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, http.StatusBadRequest, response.StatusCode) + var serviceError map[string]any + require.NoError(t, json.Unmarshal(body, &serviceError)) + require.Equal(t, goa.InvalidEnumValue, serviceError["name"]) + require.Contains(t, serviceError["message"], "value of view") +} + +func TestEmptyViewedSSEServerSelectionUsesDefaultView(t *testing.T) { + handler := NewWatchHandler( + service.NewWatchEndpoint(&viewedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "default", response.Header.Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, string(body)), + ) +} + +func TestViewedSSEServerRejectsViewChangesAfterFirstEvent(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&changingViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, "summary", recorder.Header().Get("goa-view")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) +} + +func TestViewedSSEServerReturnsEventWriteError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header), writeError: errEventWrite} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, errEventWrite) + require.Equal(t, http.StatusOK, writer.status) +} + +func TestViewedSSEServerReturnsFlushError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header)} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, http.ErrNotSupported) + require.Equal(t, http.StatusOK, writer.status) + require.NotEmpty(t, writer.body.String()) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func sseData(t *testing.T, event string) string { + t.Helper() + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + return strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + t.Errorf("SSE event has no data field: %q", event) + return "" +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const httpViewedSSEClientTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSEClientReconstructsSelectedBody(t *testing.T) { + cases := []struct { + name string + endpoint func(*Client) func(context.Context, any) (any, error) + recv func(any) (*service.Event, error) + view string + body string + wantProfile bool + }{ + { + name: "summary", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Watch() }, + recv: func(raw any) (*service.Event, error) { + var stream service.WatchClientStream = raw.(WatchClientStream) + return stream.Recv() + }, + view: "summary", + body: ` + "`" + `{"event_id":"summary-event"}` + "`" + `, + }, + { + name: "detailed fixed", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Fixed() }, + recv: func(raw any) (*service.Event, error) { + var stream service.FixedClientStream = raw.(FixedClientStream) + return stream.Recv() + }, + body: ` + "`" + `{"event_id":"detailed-event","profile":{"display_name":"Ada"}}` + "`" + `, + wantProfile: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + header := http.Header{"Content-Type": []string{"text/event-stream"}} + if tc.view != "" { + header.Set("goa-view", tc.view) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader("data: " + tc.body + "\n\n")), + }, nil + }) + client := NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) + rawStream, err := tc.endpoint(client)(context.Background(), nil) + require.NoError(t, err) + event, err := tc.recv(rawStream) + require.NoError(t, err) + require.Equal(t, strings.TrimSuffix(tc.name, " fixed")+"-event", event.EventID) + if tc.wantProfile { + require.Equal(t, "Ada", event.Profile.DisplayName) + } else { + require.Nil(t, event.Profile) + } + }) + } +} +` + +const jsonRPCViewedUnaryClientTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestVariableViewedUnaryResponseUsesRepresentationBody(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + `, + ) + response.Header.Set("goa-view", "summary") + var result any + var err error + require.NotPanics(t, func() { + result, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + }) + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestVariableViewedUnaryResponseRejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + result string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + response := jsonRPCResponse(` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + tc.result + "}") + var err error + require.NotPanics(t, func() { + _, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + }) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedUnaryResponseUsesBodyOnly(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + ) + result, err := DecodeFixedResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func jsonRPCResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedUnaryServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + fetchView string +} + +func (s *viewedService) Fetch(context.Context) (*service.Event, string, error) { + return viewedEvent(), s.fetchView, nil +} + +func (*viewedService) Fixed(context.Context) (*service.Event, error) { + return viewedEvent(), nil +} + +func TestVariableViewedUnaryServerEmitsRepresentation(t *testing.T) { + recorder := serveJSONRPC(t, "fetch", "detailed") + require.Empty(t, recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestFixedViewedUnaryServerEmitsBodyOnly(t *testing.T) { + recorder := serveJSONRPC(t, "fixed", "") + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestUnknownViewedUnaryServerSelectionIsRejected(t *testing.T) { + result, err := service.NewFetchEndpoint(&viewedService{fetchView: "unknown"})(context.Background(), nil) + require.Nil(t, result) + requireBoundaryError(t, err, goa.InvalidEnumValue, "view") +} + +func serveJSONRPC(t *testing.T, method, view string) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(&viewedService{fetchView: view}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/rpc", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +func jsonRPCResult(t *testing.T, recorder *httptest.ResponseRecorder) string { + t.Helper() + var response struct { + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + return string(response.Result) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEClientTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSENotificationReconstructsTransportBody(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + ` + event, err := recvWatch("notification", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestViewedSSEFinalResponseReconstructsTransportBody(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"event_id":"event-1"}}}` + "`" + ` + event, err := recvWatch("response", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Nil(t, event.Profile) +} + +func TestViewedSSERejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + params string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":` + "`" + ` + tc.params + "}" + _, err := recvWatch("notification", data) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedSSEUsesBodyOnly(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"fixed","params":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + ` + event, err := recvFixed("notification", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func recvWatch(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Watch()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*WatchStreamImpl) + var stream service.WatchClientStream = transport + return stream.Recv() +} + +func recvFixed(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Fixed()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*FixedStreamImpl) + var stream service.FixedClientStream = transport + return stream.Recv() +} + +func sseClient(eventType, data string) *Client { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + body := "event: " + eventType + "\ndata: " + data + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + return NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct{} + +type unknownViewService struct { + sendError chan error +} + +func (*viewedService) Watch(ctx context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(ctx, viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + return stream.SendAndClose(ctx, viewedEvent()) +} + +func (*viewedService) Fixed(ctx context.Context, stream service.FixedServerStream) error { + if err := stream.Send(ctx, viewedEvent()); err != nil { + return err + } + return stream.SendAndClose(ctx, viewedEvent()) +} + +func (s *unknownViewService) Watch(ctx context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(ctx, viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(ctx context.Context, stream service.FixedServerStream) error { + return stream.SendAndClose(ctx, viewedEvent()) +} + +func TestVariableViewedSSEServerEmitsRepresentation(t *testing.T) { + recorder := serveSSE(t, "watch") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 2) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + string(records[1].Result), + ) +} + +func TestFixedViewedSSEServerEmitsBodyOnly(t *testing.T) { + recorder := serveSSE(t, "fixed") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 2) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[1].Result), + ) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &unknownViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Empty(t, recorder.Body.String()) +} + +func serveSSE(t *testing.T, method string) *httptest.ResponseRecorder { + t.Helper() + return serveSSEService(t, method, &viewedService{}) +} + +func serveSSEService(t *testing.T, method string, svc service.Service) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/events", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +type sseRecord struct { + Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` +} + +func jsonRPCSSERecords(t *testing.T, event string) []sseRecord { + t.Helper() + var records []sseRecord + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + var record sseRecord + require.NoError(t, json.Unmarshal([]byte(data), &record)) + records = append(records, record) + } + } + return records +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedWebSocketInterfaceTest = `package jsonrpcWebSocket + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDirectStreamViewSelectorsMatchMethodContract(t *testing.T) { + stream := reflect.TypeOf((*Stream)(nil)).Elem() + cases := []struct { + name string + count int + hasView bool + }{ + {"SendWatchNotification", 3, true}, + {"SendWatchResponse", 4, true}, + {"SendInspectNotification", 3, true}, + {"SendInspectResponse", 4, true}, + {"SendFixedNotification", 2, false}, + {"SendFixedResponse", 3, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.hasView { + assertLastStringParameter(t, stream, tc.name, tc.count) + return + } + assertParameterCount(t, stream, tc.name, tc.count) + }) + } +} + +func TestRequestWrapperSetViewMatchesMethodContract(t *testing.T) { + for _, stream := range []reflect.Type{ + reflect.TypeOf((*WatchServerStream)(nil)).Elem(), + reflect.TypeOf((*InspectServerStream)(nil)).Elem(), + } { + _, hasSetView := stream.MethodByName("SetView") + require.True(t, hasSetView) + } + fixed := reflect.TypeOf((*FixedServerStream)(nil)).Elem() + _, hasSetView := fixed.MethodByName("SetView") + require.False(t, hasSetView) +} + +func assertLastStringParameter(t *testing.T, stream reflect.Type, name string, count int) { + t.Helper() + method, ok := stream.MethodByName(name) + require.True(t, ok) + require.Equal(t, count, method.Type.NumIn()) + require.Equal(t, reflect.String, method.Type.In(count-1).Kind()) +} + +func assertParameterCount(t *testing.T, stream reflect.Type, name string, count int) { + t.Helper() + method, ok := stream.MethodByName(name) + require.True(t, ok) + require.Equal(t, count, method.Type.NumIn()) +} +` + +const jsonRPCViewedWebSocketRuntimeTest = `package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_web_socket" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type wireRequest struct { + JSONRPC string ` + "`" + `json:"jsonrpc"` + "`" + ` + Method string ` + "`" + `json:"method"` + "`" + ` + Params map[string]any ` + "`" + `json:"params"` + "`" + ` + ID any ` + "`" + `json:"id"` + "`" + ` +} + +func TestConcurrentMethodStreamsDemultiplexReverseResponses(t *testing.T) { + requests := make(chan []wireRequest, 1) + serverErrors := make(chan error, 4) + acknowledged := make(chan struct{}) + var acknowledge sync.Once + releaseServer := func() { + acknowledge.Do(func() { close(acknowledged) }) + } + defer releaseServer() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + serverErrors <- err + return + } + defer func() { + if err := conn.Close(); err != nil { + serverErrors <- err + } + }() + got := make([]wireRequest, 2) + for i := range got { + if err := conn.ReadJSON(&got[i]); err != nil { + serverErrors <- err + return + } + } + if fmt.Sprint(got[0].ID) == fmt.Sprint(got[1].ID) { + serverErrors <- fmt.Errorf("JSON-RPC request IDs are not distinct: %v", got[0].ID) + return + } + requests <- got + for i := len(got) - 1; i >= 0; i-- { + var result any + switch got[i].Method { + case "watch": + result = map[string]any{ + "view": "summary", + "body": map[string]any{"event_id": "watch-event"}, + } + case "inspect": + result = map[string]any{ + "view": "detailed", + "body": map[string]any{ + "event_id": "inspect-event", + "profile": map[string]any{"display_name": "Ada"}, + }, + } + default: + serverErrors <- fmt.Errorf("unexpected method %q", got[i].Method) + return + } + response := map[string]any{ + "jsonrpc": "2.0", + "id": got[i].ID, + "result": result, + } + if err := conn.WriteJSON(response); err != nil { + serverErrors <- err + return + } + } + <-acknowledged + })) + t.Cleanup(server.Close) + + host := strings.TrimPrefix(server.URL, "http://") + client := NewClient( + "http", host, http.DefaultClient, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + websocket.DefaultDialer, nil, + ) + t.Cleanup(func() { + require.NoError(t, client.Close()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rawWatch, err := client.Watch()(ctx, nil) + require.NoError(t, err) + rawInspect, err := client.Inspect()(ctx, nil) + require.NoError(t, err) + watch := rawWatch.(*WatchClientStream) + inspect := rawInspect.(*InspectClientStream) + + sendErrors := make(chan error, 2) + var sends sync.WaitGroup + sends.Add(2) + go func() { + defer sends.Done() + sendErrors <- watch.Send(&service.WatchPayload{Key: "watch"}) + }() + go func() { + defer sends.Done() + sendErrors <- inspect.Send(&service.InspectPayload{Key: "inspect"}) + }() + sends.Wait() + close(sendErrors) + for err := range sendErrors { + require.NoError(t, err) + } + + select { + case got := <-requests: + require.ElementsMatch(t, []string{"watch", "inspect"}, []string{got[0].Method, got[1].Method}) + case err := <-serverErrors: + require.NoError(t, err) + case <-ctx.Done(): + t.Errorf("server did not receive both requests: %v", ctx.Err()) + } + + type received struct { + method string + event *service.Event + err error + } + receivedEvents := make(chan received, 2) + go func() { + event, err := watch.Recv() + receivedEvents <- received{method: "watch", event: event, err: err} + }() + go func() { + event, err := inspect.Recv() + receivedEvents <- received{method: "inspect", event: event, err: err} + }() + for range 2 { + select { + case result := <-receivedEvents: + require.NoError(t, result.err) + require.NotNil(t, result.event) + require.Equal(t, result.method+"-event", result.event.EventID) + if result.method == "inspect" { + require.Equal(t, "Ada", result.event.Profile.DisplayName) + } else { + require.Nil(t, result.event.Profile) + } + case err := <-serverErrors: + require.NoError(t, err) + case <-ctx.Done(): + t.Errorf("clients did not receive both responses: %v", ctx.Err()) + } + } + releaseServer() +} + +func TestVariableViewRejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + result any + errorName string + field string + }{ + { + name: "missing view", + result: map[string]any{"body": map[string]any{"event_id": "event-1"}}, + errorName: goa.MissingField, + field: "view", + }, + { + name: "null view", + result: map[string]any{"view": nil, "body": map[string]any{"event_id": "event-1"}}, + errorName: goa.MissingField, + field: "view", + }, + { + name: "missing body", + result: map[string]any{"view": "summary"}, + errorName: goa.MissingField, + field: "body", + }, + { + name: "null body", + result: map[string]any{"view": "summary", "body": nil}, + errorName: goa.MissingField, + field: "body", + }, + { + name: "unknown view", + errorName: goa.InvalidEnumValue, + field: "view", + result: map[string]any{ + "view": "unknown", + "body": map[string]any{"event_id": "event-1"}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := receiveWatchResult(t, tc.result) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func receiveWatchResult(t *testing.T, result any) error { + t.Helper() + requestRead := make(chan any, 1) + respond := make(chan struct{}) + acknowledged := make(chan struct{}) + defer close(acknowledged) + serverErrors := make(chan error, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + serverErrors <- err + return + } + defer func() { + if err := conn.Close(); err != nil { + serverErrors <- err + } + }() + var request wireRequest + if err := conn.ReadJSON(&request); err != nil { + serverErrors <- err + return + } + requestRead <- request.ID + <-respond + if err := conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": result, + }); err != nil { + serverErrors <- err + return + } + <-acknowledged + })) + t.Cleanup(server.Close) + + client := NewClient( + "http", strings.TrimPrefix(server.URL, "http://"), http.DefaultClient, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + websocket.DefaultDialer, nil, + ) + t.Cleanup(func() { + require.NoError(t, client.Close()) + }) + raw, err := client.Watch()(context.Background(), nil) + if err != nil { + return err + } + stream := raw.(*WatchClientStream) + if err := stream.Send(&service.WatchPayload{Key: "watch"}); err != nil { + return err + } + select { + case <-requestRead: + case err := <-serverErrors: + return err + case <-time.After(5 * time.Second): + return fmt.Errorf("server did not receive request") + } + received := make(chan error, 1) + go func() { + _, err := stream.Recv() + received <- err + }() + close(respond) + select { + case err := <-received: + return err + case err := <-serverErrors: + return err + case <-time.After(5 * time.Second): + return fmt.Errorf("client did not receive response") + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 41663829cb..72357e736c 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -302,8 +302,15 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat if serviceHasSchemes(facts) { endpointFixed = append(endpointFixed, securityImport) } + var endpointGenerated []*codegen.ImportSpec + for _, method := range facts.methods { + if facts.methodByExpr[method].viewedResult != nil { + endpointGenerated = append(endpointGenerated, viewsImport) + break + } + } facts.imports.endpoint, err = retainFileImports( - generation, servicePath, endpointFixed, nil, facts.referenceAttributes, nil, + generation, servicePath, endpointFixed, endpointGenerated, facts.referenceAttributes, nil, ) if err != nil { return err diff --git a/codegen/service/jsonrpc_websocket_signature_test.go b/codegen/service/jsonrpc_websocket_signature_test.go new file mode 100644 index 0000000000..e5066af2d5 --- /dev/null +++ b/codegen/service/jsonrpc_websocket_signature_test.go @@ -0,0 +1,60 @@ +// This file checks where generated JSON-RPC WebSocket methods receive their +// request values. +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestJSONRPCWebSocketBidirectionalPayloadIsStreamOwned checks that a method +// which receives many values reads them with Recv. A method which receives one +// value gets it as its first argument. +func TestJSONRPCWebSocketBidirectionalPayloadIsStreamOwned(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("socket", func() { + dsl.JSONRPC(func() { + dsl.Path("/stream") + }) + dsl.Method("bidi", func() { + dsl.StreamingPayload(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("server", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + }) + plan := mustServicePlan(t, root) + facts := plan.facts.services[0] + + serviceCode := renderSignatureFile(t, serviceFiles(plan, facts)[0]) + require.Contains(t, serviceCode, "Bidi(context.Context, BidiServerStream) (err error)") + require.Contains(t, serviceCode, "Server(context.Context, *ServerPayload, ServerServerStream) (err error)") + + endpointCode := renderSignatureFile(t, endpointFile(plan, facts)) + require.Contains(t, endpointCode, "return nil, s.Bidi(ctx, ep.Stream)") + require.Contains(t, endpointCode, "return nil, s.Server(ctx, ep.Payload, ep.Stream)") +} + +// renderSignatureFile returns the Go source produced for one file. +func renderSignatureFile(t *testing.T, file *codegen.File) string { + t.Helper() + var source strings.Builder + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + return source.String() +} diff --git a/codegen/service/plan_lifecycle.go b/codegen/service/plan_lifecycle.go index da475d6bf7..7f9fc6132d 100644 --- a/codegen/service/plan_lifecycle.go +++ b/codegen/service/plan_lifecycle.go @@ -1,6 +1,5 @@ -// This file owns the service planning lifecycle across every Goa design root -// in one generation. It validates complete input membership, collects each -// root once, and assigns files shared by multiple roots before names freeze. +// This file prepares every service design used by one run. It rejects missing +// or repeated designs and chooses each shared Go name once. package service import ( @@ -11,9 +10,21 @@ import ( "goa.design/goa/v3/expr" ) -// NewPlans collects every service root owned by generation in one operation. -// Root-local facts remain in separate plans, while declarations and files that -// can be shared across roots are assigned once across the complete input set. +type ( + // HTTPMethodNames contains the Go names used by one service method in an HTTP + // package. The HTTP generator reuses these names instead of choosing new ones. + HTTPMethodNames struct { + // Method is the name used for the service endpoint field and receiver method. + Method string + // ServerStream is the service's server stream type name. + ServerStream string + // ClientStream is the service's client stream type name. + ClientStream string + } +) + +// NewPlans reads every service design in generation. It returns the data used +// to write each service and chooses shared Go declaration names once. func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { owned := make(map[*expr.RootExpr]struct{}) for _, candidate := range generation.Roots() { @@ -59,9 +70,8 @@ func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, err return plans, nil } -// NewPlan collects the only service root owned by generation. Generations -// containing multiple service roots must use NewPlans so shared package files -// and receiver methods are planned once across the complete run. +// NewPlan reads the only service design in generation. Call NewPlans when a run +// contains several designs so shared files and methods receive names only once. func NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*Plan, error) { plans, err := NewPlans(generation, PlanInput{Root: root, Examples: examples}) if err != nil { @@ -70,8 +80,54 @@ func NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr return plans[0], nil } -// collectRootFacts retains one root's service facts and declares its -// root-owned symbols before run-wide file ownership is assigned. +// Root returns the service design used by this plan. Other file writers use it +// to reject a plan created for a different design. +func (p *Plan) Root() *expr.RootExpr { + return p.facts.root +} + +// ProjectedResult returns a copy of the result fields included in the views for +// method. It reports an error when method is absent or has no views. +func (p *Plan) ProjectedResult(method *expr.MethodExpr) (*expr.AttributeExpr, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.viewedResult == nil { + return nil, fmt.Errorf("service method %q does not have a viewed result", method.Name) + } + projected := expr.AsObject(facts.viewedResult.wrapped.Attribute().Type).Attribute("projected") + return expr.DupAtt(projected), nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// HTTPMethodNames returns the Go names already chosen for method. It returns an +// error when the service design does not contain method. +func (p *Plan) HTTPMethodNames(method *expr.MethodExpr) (HTTPMethodNames, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + return HTTPMethodNames{ + Method: facts.varName, + ServerStream: facts.serverStreamVarName, + ClientStream: facts.clientStreamVarName, + }, nil + } + if method == nil { + return HTTPMethodNames{}, fmt.Errorf("service method is not part of this plan") + } + return HTTPMethodNames{}, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// collectRootFacts reads one service design and chooses names used only by that +// design before shared files receive their names. func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*rootFacts, error) { examplePackageScope := codegen.NewNameScope() for _, service := range root.Services { diff --git a/codegen/service/projected_result_test.go b/codegen/service/projected_result_test.go new file mode 100644 index 0000000000..4363ebed01 --- /dev/null +++ b/codegen/service/projected_result_test.go @@ -0,0 +1,76 @@ +// This file checks that HTTP generation can copy the result fields selected by +// a view before names are assigned to the service package. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanProjectedResultBeforeLink(t *testing.T) { + var viewed, plain *expr.MethodExpr + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.projected-result", func() { + dsl.TypeName("ProjectedResult") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { dsl.Attribute("name") }) + dsl.View("summary", func() { dsl.Attribute("name") }) + }) + dsl.Service("Values", func() { + viewed = dsl.Method("Viewed", func() { dsl.Result(result) }) + plain = dsl.Method("Plain", func() { dsl.Result(dsl.String) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + first, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + first.Description = "changed by caller" + second, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + require.NotEqual(t, first.Description, second.Description) + require.NotSame(t, first, second) + + _, err = plan.ProjectedResult(plain) + require.EqualError(t, err, `service method "Plain" does not have a viewed result`) + foreign := &expr.MethodExpr{Name: "Foreign"} + _, err = plan.ProjectedResult(foreign) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) + + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + linked := plan.Services().Get("Values").Method("Viewed").ViewedResult + require.NotNil(t, linked) + require.NotEqual(t, "changed by caller", expr.AsObject(linked.Type).Attribute("projected").Description) +} + +func TestPlanHTTPMethodNamesBeforeLink(t *testing.T) { + var watch *expr.MethodExpr + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + watch = dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + names, err := plan.HTTPMethodNames(watch) + require.NoError(t, err) + require.Equal(t, "Watch", names.Method) + require.Equal(t, "WatchServerStream", names.ServerStream) + require.Equal(t, "WatchClientStream", names.ClientStream) + + _, err = plan.HTTPMethodNames(&expr.MethodExpr{Name: "Foreign"}) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) +} diff --git a/codegen/service/service.go b/codegen/service/service.go index e44d1b4f2d..da4bd522cc 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -464,6 +464,7 @@ func streamInterfaceFor(typ string, m *MethodData, stream *StreamData) map[strin "Endpoint": m.Name, "Stream": stream, "MethodVarName": m.VarName, + "EventDeclaration": m.EventDeclaration, "IsJSONRPC": m.IsJSONRPC, "IsJSONRPCSSE": m.IsJSONRPCSSE && typ == "server", "IsJSONRPCWebSocket": m.IsJSONRPCWebSocket, diff --git a/codegen/service/templates/return_type_init.go.tpl b/codegen/service/templates/return_type_init.go.tpl index 575a5680cb..95d387deca 100644 --- a/codegen/service/templates/return_type_init.go.tpl +++ b/codegen/service/templates/return_type_init.go.tpl @@ -9,7 +9,7 @@ {{- end }} {{- end }} {{- else -}} - var {{ .ReturnVar }} {{ .ReturnTypeRef }} + {{ if .ToViewed }}{{ .ReturnVar }} := {{ if not .IsCollection }}&{{ end }}{{ .TargetType }}{View: view}{{ else }}var {{ .ReturnVar }} {{ .ReturnTypeRef }}{{ end }} switch {{ if .ToResult }}{{ .ArgVar }}.View{{ else }}view{{ end }} { {{- range .Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: diff --git a/codegen/service/templates/service.go.tpl b/codegen/service/templates/service.go.tpl index 9fd70e3dba..c30b7af3fc 100644 --- a/codegen/service/templates/service.go.tpl +++ b/codegen/service/templates/service.go.tpl @@ -29,6 +29,8 @@ type {{ .ServiceDeclaration.Name }} interface { {{- /* Mixed results: the method may be invoked in a unary (JSON) or streaming (SSE) mode. The server stream is non-nil only when the transport negotiates streaming. */}} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) + {{- else if and .IsJSONRPCWebSocket (eq .ServerStream.Kind 4) }} + {{ .VarName }}(context.Context, {{ .ServerStream.Interface }}) (err error) {{- else }} {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 3) .PayloadRef }} {{- /* JSON-RPC WebSocket server streaming with non-streaming payload */ -}} @@ -93,12 +95,12 @@ var {{ .MethodNamesDeclaration.Name }} = [{{ len .Methods }}]string{ {{ range .M {{- define "stream_interface" }} {{- if and .IsJSONRPCSSE (eq .Type "server") }} -{{ printf "%sEvent is the interface implemented by the result type for the %s method." .MethodVarName .Endpoint | comment }} -type {{ .MethodVarName }}Event interface { +{{ printf "%s is the interface implemented by the result type for the %s method." .EventDeclaration.Name .Endpoint | comment }} +type {{ .EventDeclaration.Name }} interface { is{{ .MethodVarName }}Event() } -{{ printf "is%sEvent implements the %sEvent interface." .MethodVarName .MethodVarName | comment }} +{{ printf "is%sEvent implements the %s interface." .MethodVarName .EventDeclaration.Name | comment }} func ({{ .Stream.SendTypeRef }}) is{{ .MethodVarName }}Event() {} {{ printf "%s allows streaming instances of %s over SSE." .Stream.Interface .Stream.SendTypeRef | comment }} @@ -106,16 +108,20 @@ type {{ .Stream.Interface }} interface { {{- if .Stream.SendTypeRef }} {{ comment .Stream.SendDesc }} {{ comment "IMPORTANT: Send only sends JSON-RPC notifications. Use SendAndClose to send a final response." }} - Send(ctx context.Context, event {{ .MethodVarName }}Event) error + Send(ctx context.Context, event {{ .EventDeclaration.Name }}) error {{- if .Stream.SendAndCloseName }} {{ comment .Stream.SendAndCloseDesc }} {{ comment "The result will be sent as a JSON-RPC response with the original request ID." }} {{ comment "If the result has an ID field populated, that ID will be used instead of the request ID." }} - {{ .Stream.SendAndCloseName }}(ctx context.Context, event {{ .MethodVarName }}Event) error + {{ .Stream.SendAndCloseName }}(ctx context.Context, event {{ .EventDeclaration.Name }}) error {{- end }} {{- end }} {{ comment "SendError sends a JSON-RPC error response." }} SendError(ctx context.Context, id string, err error) error + {{- if .IsViewedResult }} + {{ comment "SetView sets the result view applied to later values sent on this stream." }} + SetView(view string) + {{- end }} } {{- else }} {{- $elemType := .Stream.SendTypeRef -}} @@ -166,9 +172,9 @@ type {{ .StreamDeclaration.Name }} interface { {{- range .Methods }} {{- if .Result }} {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method (no response expected)." .VarName .Name | comment }} - Send{{ .VarName }}Notification(ctx context.Context, result {{ .ResultRef }}) error + Send{{ .VarName }}Notification(ctx context.Context, result {{ .ResultRef }}{{ if and .ViewedResult (not .ViewedResult.ViewName) }}, view string{{ end }}) error {{ printf "Send%sResponse sends a JSON-RPC response for the %s method with the given ID." .VarName .Name | comment }} - Send{{ .VarName }}Response(ctx context.Context, id any, result {{ .ResultRef }}) error + Send{{ .VarName }}Response(ctx context.Context, id any, result {{ .ResultRef }}{{ if and .ViewedResult (not .ViewedResult.ViewName) }}, view string{{ end }}) error {{- end }} {{- end }} {{ comment "SendError sends a JSON-RPC error response." }} diff --git a/codegen/service/templates/service_endpoint_method.go.tpl b/codegen/service/templates/service_endpoint_method.go.tpl index ca331388ce..089d6d1e7f 100644 --- a/codegen/service/templates/service_endpoint_method.go.tpl +++ b/codegen/service/templates/service_endpoint_method.go.tpl @@ -131,12 +131,19 @@ func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .S {{- else }} vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return res, nil {{- end }} {{- else }} + {{- if and .IsJSONRPCWebSocket (eq .ServerStream.Kind 4) }} + return nil, s.{{ .VarName }}(ctx, ep.Stream) + {{- else }} return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) + {{- end }} {{- end }} {{- else }} {{- /* JSON-RPC WebSocket client streaming: no stream parameter, just payload */ -}} @@ -168,6 +175,9 @@ func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .S return nil, err } vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return {{ if not .ResultRef }}nil, {{ end }}s.{{ .VarName }}(ctx, {{ if .PayloadRef }}ep.Payload, {{ end }}ep.Body) @@ -178,6 +188,9 @@ func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .S return nil, err } vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else if .SkipResponseBodyEncodeDecode }} {{ if .ResultRef }}res, {{ end }}body, err := s.{{ .VarName }}(ctx{{ if .PayloadRef }}, {{ $payload}}{{ end }}) diff --git a/codegen/service/testdata/endpoint_code.go b/codegen/service/testdata/endpoint_code.go index 32566bec02..12da85749c 100644 --- a/codegen/service/testdata/endpoint_code.go +++ b/codegen/service/testdata/endpoint_code.go @@ -149,6 +149,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedRtype(res, "default") + if err := withresultviews.ValidateRtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -185,6 +188,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "tiny") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -198,6 +204,9 @@ func NewBEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "default") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden index 86076a3ff5..308cb32cfd 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden @@ -78,7 +78,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithexplicitviewservicev // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden index bbf9ce1e4a..bc9948fd96 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden @@ -94,7 +94,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithviewsserviceviews.Mu // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden index e2b842d658..de2b29f4c9 100644 --- a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden @@ -50,7 +50,7 @@ func NewMultipleViewsCollection(vres resultcollectionmultipleviewsmethodviews.Mu // MultipleViewsCollection from result type MultipleViewsCollection using the // given view. func NewViewedMultipleViewsCollection(res MultipleViewsCollection, view string) resultcollectionmultipleviewsmethodviews.MultipleViewsCollection { - var vres resultcollectionmultipleviewsmethodviews.MultipleViewsCollection + vres := resultcollectionmultipleviewsmethodviews.MultipleViewsCollection{View: view} switch view { case "default", "": p := newMultipleViewsCollectionView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden index 9d241f0196..74471509f2 100644 --- a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden @@ -49,7 +49,7 @@ func NewMultipleViews(vres *withexplicitanddefaultviewsviews.MultipleViews) *Mul // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *withexplicitanddefaultviewsviews.MultipleViews { - var vres *withexplicitanddefaultviewsviews.MultipleViews + vres := &withexplicitanddefaultviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden index ce5121e19e..d83c151637 100644 --- a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden @@ -66,7 +66,7 @@ func NewMultipleViews(vres *multiplemethodsresultmultipleviewsviews.MultipleView // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *multiplemethodsresultmultipleviewsviews.MultipleViews { - var vres *multiplemethodsresultmultipleviewsviews.MultipleViews + vres := &multiplemethodsresultmultipleviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden index 03fba976c1..0874c7ec4d 100644 --- a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden @@ -52,7 +52,7 @@ func NewMultipleViews(vres *resultwithotherresultviews.MultipleViews) *MultipleV // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *resultwithotherresultviews.MultipleViews { - var vres *resultwithotherresultviews.MultipleViews + vres := &resultwithotherresultviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden index 97dae9288a..13d5e56da1 100644 --- a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden @@ -55,7 +55,7 @@ func NewRT(vres *resultwithresulttypecollectionviews.RT) *RT { // NewViewedRT initializes viewed result type RT from result type RT using the // given view. func NewViewedRT(res *RT, view string) *resultwithresulttypecollectionviews.RT { - var vres *resultwithresulttypecollectionviews.RT + vres := &resultwithresulttypecollectionviews.RT{View: view} switch view { case "default", "": p := newRTView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden index d2ad9640c4..4d30cd2696 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden @@ -76,7 +76,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithexplicitviewserviceviews.M // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingpayloadresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden index 5492f7b9c0..4c3fa99d45 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden @@ -91,7 +91,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithviewsserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithviewsserviceviews.MultipleViews { - var vres *streamingpayloadresultwithviewsserviceviews.MultipleViews + vres := &streamingpayloadresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden index 7aad6c247f..da94ce1180 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden @@ -67,7 +67,7 @@ func NewMultipleViews(vres *streamingresultwithexplicitviewserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden index 5053b27209..12aa65c247 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden @@ -70,7 +70,7 @@ func NewMultipleViews(vres *streamingresultwithviewsserviceviews.MultipleViews) // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithviewsserviceviews.MultipleViews { - var vres *streamingresultwithviewsserviceviews.MultipleViews + vres := &streamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index b5ef6830af..548fdfff6a 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -390,7 +390,7 @@ All commands must pass. transport body, run its generated constructor and validation, then return the canonical service result. -- [ ] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** +- [x] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** Inventory request, response, WebSocket, SSE, error, union, constructor, validator, codec, stream, client, server, CLI, and example package symbols. @@ -418,7 +418,7 @@ snake-case field such as `event_id`: decoding it into a service field named `EventID` must fail the test unless the generated transport-body constructor performs the mapping. -- [ ] **Step 2: Build retained HTTP plans from exact service plans** +- [x] **Step 2: Build retained HTTP plans from exact service plans** Make HTTP `NewPlan` consume the prepared root's HTTP expressions and exact `*service.Plan`. Collect detached client and server wire models, union families, @@ -432,7 +432,7 @@ use it to select both the service projection and the already-retained view-specific response body. Never place mutable view selection on a shared connection. -- [ ] **Step 3: Make JSON-RPC retain the HTTP plan it shares** +- [x] **Step 3: Make JSON-RPC retain the HTTP plan it shares** Build one typed JSON-RPC plan that points at the exact HTTP plan used for HTTP codecs and body files, then collects JSON-RPC-only declarations. Do not invoke @@ -453,14 +453,14 @@ the server cannot choose the first body variant and the client cannot recover the view from an unset HTTP header. Fixed-view unary methods remain fully specialized and need no runtime discriminator. -- [ ] **Step 4: Remove context-dependent helper naming** +- [x] **Step 4: Remove context-dependent helper naming** Validators, constructors, conversions, stream helpers, and codecs must read their `NameDeclaration`; call-site traversal selects a record but cannot name it. Keep local field and variable scopes. Prove request/response and WebSocket/SSE transforms enter service and wire owners independently. -- [ ] **Step 5: Verify and commit Task 8** +- [x] **Step 5: Verify and commit Task 8** Run: diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index f8953b2e33..0856abf4f1 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -548,6 +548,14 @@ func (e *HTTPEndpointExpr) Validate() error { hasTags = true } if r.StatusCode < 400 { + if e.MethodExpr.IsStreaming() { + if !r.Headers.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP headers") + } + if !r.Cookies.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP cookies") + } + } if successResp && e.MethodExpr.Stream == ServerStreamKind { verr.Add(r, "At most one success response can be defined for a streaming endpoint.") if r.Body != nil && r.Body.Type == Empty { @@ -783,20 +791,6 @@ func (e *HTTPEndpointExpr) validateErrorMappings() *eval.ValidationErrors { // types so that the response encoding code can properly use the type to infer // the response that it needs to build. func (e *HTTPEndpointExpr) Finalize() { - // For JSON-RPC WebSocket endpoints with server streaming and non-streaming payload, - // move the payload to streaming payload. This is because the payload is sent as - // JSON-RPC messages after the WebSocket connection is established, making it - // effectively a streaming payload from the transport perspective. - if _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"]; isJSONRPC && e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type == Empty { - // Move payload to streaming payload - e.MethodExpr.StreamingPayload = e.MethodExpr.Payload - e.MethodExpr.Payload = &AttributeExpr{Type: Empty} - // Change stream kind to bidirectional since we now have both streaming payload and result - e.MethodExpr.Stream = BidirectionalStreamKind - } - } - // Compute security scheme attribute name and corresponding HTTP location requirements := EffectiveSecurityRequirements(e.MethodExpr.Requirements) if reqLen := len(requirements); reqLen > 0 { diff --git a/expr/jsonrpc_stream_contract_test.go b/expr/jsonrpc_stream_contract_test.go new file mode 100644 index 0000000000..b5b51eded4 --- /dev/null +++ b/expr/jsonrpc_stream_contract_test.go @@ -0,0 +1,45 @@ +// This file checks that JSON-RPC keeps the number and direction of values +// declared by each service method. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestJSONRPCWebSocketFinalizePreservesStreamKinds checks that a method which +// sends many results keeps its one initial request. It must not be changed into +// a method which also receives many requests. +func TestJSONRPCWebSocketFinalizePreservesStreamKinds(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("socket", func() { + dsl.JSONRPC(func() { + dsl.Path("/stream") + }) + dsl.Method("server", func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("bidi", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + }) + + service := root.Service("socket") + server := service.Method("server") + require.Equal(t, expr.ServerStreamKind, server.Stream) + require.Equal(t, expr.String, server.Payload.Type) + require.Equal(t, expr.Empty, server.StreamingPayload.Type) + + bidi := service.Method("bidi") + require.Equal(t, expr.BidirectionalStreamKind, bidi.Stream) + require.Equal(t, expr.String, bidi.StreamingPayload.Type) +} diff --git a/expr/streaming_response_mapping_test.go b/expr/streaming_response_mapping_test.go new file mode 100644 index 0000000000..e26f85c9f1 --- /dev/null +++ b/expr/streaming_response_mapping_test.go @@ -0,0 +1,102 @@ +// This file checks that a method which returns many results cannot put result +// fields in HTTP headers or cookies. One connection has only one HTTP response, +// so it cannot carry different values for each result. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestStreamingSuccessResponseRejectsHeadersAndCookies checks both HTTP and +// JSON-RPC methods over SSE and WebSocket connections. +func TestStreamingSuccessResponseRejectsHeadersAndCookies(t *testing.T) { + transports := []struct { + name string + dsl func(func()) + }{ + {name: "HTTP server-sent events", dsl: httpStreamingResponseMappingDSL(true)}, + {name: "HTTP WebSocket", dsl: httpStreamingResponseMappingDSL(false)}, + {name: "JSON-RPC server-sent events", dsl: jsonRPCStreamingResponseMappingDSL(true)}, + {name: "JSON-RPC WebSocket", dsl: jsonRPCStreamingResponseMappingDSL(false)}, + } + mappings := []struct { + name string + apply func() + error string + }{ + {name: "header", apply: func() { Header("metadata:X-Metadata") }, error: "streaming success response cannot map result attributes to HTTP headers"}, + {name: "cookie", apply: func() { Cookie("metadata:session") }, error: "streaming success response cannot map result attributes to HTTP cookies"}, + } + + for _, transport := range transports { + for _, mapping := range mappings { + t.Run(transport.name+" "+mapping.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + transport.dsl(mapping.apply) + }) + require.ErrorContains(t, err, mapping.error) + }) + } + } +} + +// httpStreamingResponseMappingDSL creates an HTTP method which returns many +// results and places one result field in its HTTP response. +func httpStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + Method("watch", func() { + StreamingResult(streamingMappedResult()) + HTTP(func() { + GET("/watch") + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// jsonRPCStreamingResponseMappingDSL creates a JSON-RPC method which returns +// many results and places one result field in its HTTP response. +func jsonRPCStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + JSONRPC(func() { + if sse { + POST("/watch") + } else { + GET("/watch") + } + }) + Method("watch", func() { + StreamingResult(streamingMappedResult()) + JSONRPC(func() { + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// streamingMappedResult defines the two fields used by these tests. +func streamingMappedResult() func() { + return func() { + Attribute("value", String) + Attribute("metadata", String) + } +} diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 5e60b40673..2fabd74bb4 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -3,6 +3,7 @@ package codegen import ( + "fmt" "path" "path/filepath" @@ -27,8 +28,8 @@ func ClientCLIFiles(services *ServicesData) []*codegen.File { } sd := services.Get(svc.Name()) command := cli.BuildCommandData(sd.Service, sd.ClientPkgName) - for _, e := range sd.Endpoints { - flags, buildFunction := buildFlags(e) + for index, e := range sd.Endpoints { + flags, buildFunction := buildFlags(e, services.cliPlan.builders[svc.GRPCEndpoints[index]]) subcmd := cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags) command.Subcommands = append(command.Subcommands, subcmd) } @@ -92,18 +93,44 @@ func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.Co } } + parser := services.cliPlan.parsers[svr] + if parser == nil { + panic(fmt.Sprintf("gRPC command parser names are missing for server %q", svr.Name)) + } + plannedData := make([]*cli.CommandData, len(data)) + for index, command := range data { + commandNames := parser.Commands[command.ServiceName] + if commandNames == nil { + panic(fmt.Sprintf("gRPC command names are missing for service %q", command.ServiceName)) + } + commandCopy := *command + commandCopy.UsageDeclaration = commandNames.Usage + commandCopy.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for methodIndex, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.MethodName] + if usage == nil { + panic(fmt.Sprintf("gRPC method help name is missing for %q.%q", command.ServiceName, subcommand.Name)) + } + subcommandCopy := *subcommand + subcommandCopy.UsageDeclaration = usage + commandCopy.Subcommands[methodIndex] = &subcommandCopy + } + plannedData[index] = &commandCopy + } parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint-grpc", Source: grpcTemplates.Read(grpcParseEndpointT), Data: struct { - FlagsCode string - Commands []*cli.CommandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*cli.CommandData }{ - cli.FlagsCode(data), - data, + parser.Declarations.ParseEndpoint, + cli.FlagsCode(plannedData), + plannedData, }, } - return cli.EndpointParserFile(fpath, title, specs, data, parseSection) + return cli.EndpointParserFile(fpath, title, specs, plannedData, parser.Declarations, parseSection) } // payloadBuilders returns the file that contains the payload constructors that @@ -131,9 +158,16 @@ func payloadBuilders(svc *expr.GRPCServiceExpr, data *cli.CommandData, services return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), services, svc.GRPCEndpoints...) } -func buildFlags(e *EndpointData) ([]*cli.FlagData, *cli.BuildFunctionData) { +func buildFlags(e *EndpointData, declaration *codegen.NameDeclaration) ([]*cli.FlagData, *cli.BuildFunctionData) { if e.Request != nil { - return makeFlags(e, e.Request.CLIArgs) + flags, buildFunction := makeFlags(e, e.Request.CLIArgs) + if buildFunction != nil { + if declaration == nil { + panic(fmt.Sprintf("gRPC payload builder name is missing for %q.%q", e.ServiceName, e.Method.Name)) + } + buildFunction.Declaration = declaration + } + return flags, buildFunction } return nil, nil } diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index d574b28471..c8f7e1eb42 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -34,6 +34,10 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { } rootPath := path.Dir(genpkg) cliImport := services.PackageImport(path.Join(genpkg, "grpc", "cli", svrdata.Dir)) + parser := services.cliPlan.parsers[svr] + if parser == nil { + panic("gRPC command parser names are missing for server " + svr.Name) + } specs := []*codegen.ImportSpec{ {Path: "context"}, @@ -74,6 +78,7 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { "Services": svcData, "InterceptorsPkg": interceptorsPkg, "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, }, }, } diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index a44e721aa1..cddf5638e8 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -33,7 +33,7 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(createServiceServicesForPackage(root, c.PkgPath)) + services := createServiceServicesForPackage(root, c.PkgPath) fs := ExampleCLIFiles(services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index bdbb3e71ef..a2d5a89e05 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -27,7 +27,7 @@ func TestExampleServerFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(createServiceServices(root)) + services := createServiceServices(root) fs := ExampleServerFiles(services) require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index 88b8d12183..2e33ac46ec 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -27,7 +27,7 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := NewServicesData(createServiceServicesForPackage(root, "generated.local/gen")) + services := createServiceServicesForPackage(root, "generated.local/gen") fs := ClientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go index 6dd6f327d5..ea6437ff38 100644 --- a/grpc/codegen/plan.go +++ b/grpc/codegen/plan.go @@ -1,18 +1,67 @@ -// This file declares gRPC runtime imports and generated protobuf package -// aliases before the shared generation catalog is frozen. +// This file records gRPC imports, generated protobuf package names, and command +// functions before generated files are written. package codegen import ( + "fmt" "path" "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// Plan reserves every literal gRPC import qualifier and each generated -// protobuf package alias used by gRPC render templates. -func Plan(generation *codegen.Generation) error { +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root is the design that contains the gRPC services. + Root *expr.RootExpr + // Service provides the method names selected for Root. + Service *service.Plan + } + + // PreparedPlan contains the command-line function names requested before Go + // assigns their final spellings. + PreparedPlan struct { + roots map[*expr.RootExpr]*grpcCLIPlan + } + + // grpcCLIPlan contains the command parser and payload function names for one + // design. + grpcCLIPlan struct { + parsers map[*expr.ServerExpr]*cli.ParserPlan + builders map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration + } +) + +// Plan requests the import names and command-line functions used by the gRPC +// files for inputs. ClientCLIFiles reads the returned names after Goa has made +// every name unique within its Go package. +func Plan(generation *codegen.Generation, inputs ...PlanInput) (*PreparedPlan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + owned[root] = struct{}{} + } + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("gRPC planning requires all %d service roots, got %d", len(owned), len(inputs)) + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Service == nil || input.Service.Root() != input.Root { + return nil, fmt.Errorf("gRPC plan input does not pair a design with its service plan") + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("gRPC root %p is not part of this generation", input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("gRPC root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } imports := []*codegen.ImportSpec{ codegen.SimpleImport("context"), codegen.SimpleImport("encoding/json"), @@ -42,35 +91,76 @@ func Plan(generation *codegen.Generation) error { } for _, spec := range imports { if err := generation.RequireImport(spec); err != nil { - return err + return nil, err } } - for _, root := range generation.Roots() { - design, ok := root.(*expr.RootExpr) - if !ok { - continue + plan := &PreparedPlan{roots: make(map[*expr.RootExpr]*grpcCLIPlan, len(inputs))} + for _, input := range inputs { + design := input.Root + rootPlan := &grpcCLIPlan{ + parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), } for _, service := range design.API.GRPC.Services { pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) packageName := strings.ToLower(codegen.Goify(service.Name(), false)) if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client"))); err != nil { - return err + return nil, err } if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server"))); err != nil { - return err + return nil, err } if err := generation.ReserveGeneratedImport(codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName))); err != nil { - return err + return nil, err + } + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.Payload.Type == expr.Empty { + continue + } + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + declaration, err := cli.DeclarePayloadBuilder(clientPackage, "grpc", design.API.Name, service.Name(), endpoint.Name(), "Build"+names.Method+"Payload") + if err != nil { + return nil, err + } + rootPlan.builders[endpoint] = declaration } } if len(design.API.GRPC.Services) > 0 { for _, server := range design.API.Servers { serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "grpc", "cli", serverName))); err != nil { - return err + return nil, err + } + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + if err != nil { + return nil, err + } + var commands []cli.CommandDeclarationInput + for _, grpcService := range design.API.GRPC.Services { + if len(grpcService.GRPCEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: grpcService.Name()} + for _, endpoint := range grpcService.GRPCEndpoints { + command.Methods = append(command.Methods, endpoint.Name()) + } + commands = append(commands, command) + } + parser, err := cli.DeclareParser(serverPackage, "grpc", design.API.Name, server.Name, commands) + if err != nil { + return nil, err } + rootPlan.parsers[server] = parser } } + plan.roots[design] = rootPlan } - return nil + return plan, nil } diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go index 5639658592..c0208021e4 100644 --- a/grpc/codegen/plan_test.go +++ b/grpc/codegen/plan_test.go @@ -29,7 +29,8 @@ func TestPlanReservesGeneratedGRPCPackages(t *testing.T) { require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + _, err = Plan(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 68e6629253..84f273f8f7 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -18,6 +18,7 @@ type ( ServicesData struct { *service.ServicesData GRPCServices map[string]*ServiceData + cliPlan *grpcCLIPlan } // ServiceData contains the data used to render the code related to a @@ -452,10 +453,15 @@ const ( ) // NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData) *ServicesData { +func NewServicesData(services *service.ServicesData, plan *PreparedPlan) *ServicesData { + cliPlan := plan.roots[services.Root] + if cliPlan == nil { + panic(fmt.Sprintf("gRPC command-line names are missing for design %q", services.Root.API.Name)) + } return &ServicesData{ ServicesData: services, GRPCServices: make(map[string]*ServiceData), + cliPlan: cliPlan, } } diff --git a/grpc/codegen/templates/do_grpc_cli.go.tpl b/grpc/codegen/templates/do_grpc_cli.go.tpl index 7dcf781ff3..d7928c976a 100644 --- a/grpc/codegen/templates/do_grpc_cli.go.tpl +++ b/grpc/codegen/templates/do_grpc_cli.go.tpl @@ -8,7 +8,7 @@ func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { {{ .Service.VarName }}Interceptors := {{ $.InterceptorsPkg }}.New{{ .Service.StructName }}ClientInterceptors() {{- end }} {{- end }} - return {{ .CLIPkg }}.ParseEndpoint( + return {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( conn, {{- range .Services }} {{- if .Service.ClientInterceptors }} @@ -20,10 +20,10 @@ func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { {{ if eq .DefaultTransport.Type "grpc" }} func grpcUsageCommands() []string { - return {{ .CLIPkg }}.UsageCommands() + return {{ .CLIPkg }}.{{ .Parser.UsageCommands.Name }}() } func grpcUsageExamples() string { - return {{ .CLIPkg }}.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } {{- end }} diff --git a/grpc/codegen/templates/parse_endpoint.go.tpl b/grpc/codegen/templates/parse_endpoint.go.tpl index 54d923a3f6..a96f273b59 100644 --- a/grpc/codegen/templates/parse_endpoint.go.tpl +++ b/grpc/codegen/templates/parse_endpoint.go.tpl @@ -1,6 +1,6 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( +func {{ .Declaration.Name }}( cc *grpc.ClientConn, {{- range .Commands }} {{- if .Interceptors }} @@ -29,7 +29,7 @@ func ParseEndpoint( endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName}}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + data, err = {{ $pkgName}}.{{ .BuildFunction.Declaration.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index a2e0467893..d67d19cb65 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -24,18 +24,18 @@ func RunGRPCDSL(t *testing.T, dsl func()) *expr.RootExpr { // CreateGRPCServices creates a new ServicesData instance for testing. // Generation construction normalizes the root before any planner reads it. func CreateGRPCServices(root *expr.RootExpr) *ServicesData { - return NewServicesData(createServiceServices(root)) + return createServiceServices(root) } // createServiceServices performs the complete package declaration lifecycle // required by transport test helpers. -func createServiceServices(root *expr.RootExpr) *service.ServicesData { +func createServiceServices(root *expr.RootExpr) *ServicesData { return createServiceServicesForPackage(root, "generated.local/gen") } // createServiceServicesForPackage builds test service analysis for the exact // generated module path whose imports the test renders. -func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *service.ServicesData { +func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *ServicesData { generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) if err != nil { panic(err) @@ -44,7 +44,8 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err != nil { panic(err) } - if err := Plan(generation); err != nil { + grpcPlan, err := Plan(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { panic(err) } if err := example.Plan(generation); err != nil { @@ -56,7 +57,7 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *servic if err := servicePlan.Link(); err != nil { panic(err) } - return servicePlan.Services() + return NewServicesData(servicePlan.Services(), grpcPlan) } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/http/codegen/client.go b/http/codegen/client.go index 26926e18b7..e6d6d90a95 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -11,12 +11,12 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the generated HTTP client files. -func ClientFiles(data *ServicesData) []*codegen.File { +// clientFiles builds the HTTP client files read by Plan.Link. +func clientFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { files = append(files, addEndpointImports(clientFile(svc, data), data, svc.HTTPEndpoints...)) - if f := WebsocketClientFile(svc, data); f != nil { + if f := websocketClientFile(svc, data); f != nil { files = append(files, addEndpointImports(f, data, httpWebSocketEndpoints(svc)...)) } if f := sseClientFile(svc, data); f != nil { @@ -24,16 +24,16 @@ func ClientFiles(data *ServicesData) []*codegen.File { } } for _, svc := range data.Expressions.Services { - if f := ClientEncodeDecodeFile(svc, data); f != nil { + if f := clientEncodeDecodeFile(svc, data); f != nil { files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } } return files } -// ClientEncodeDecodeFile returns the file containing the HTTP client encoding +// clientEncodeDecodeFile returns the file containing the HTTP client encoding // and decoding logic. -func ClientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "client", "encode_decode.go") @@ -76,7 +76,7 @@ func ClientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * Source: httpTemplates.Read(requestBuilderT), Data: e, }) - if e.RequestEncoder != "" && (e.Payload.Ref != "" || e.IsJSONRPC) { + if e.RequestEncoderDeclaration != nil && (e.Payload.Ref != "" || e.IsJSONRPC) { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", Source: httpTemplates.Read(requestEncoderT, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), diff --git a/http/codegen/client_body_types_test.go b/http/codegen/client_body_types_test.go index 6e986108f9..978a76ea94 100644 --- a/http/codegen/client_body_types_test.go +++ b/http/codegen/client_body_types_test.go @@ -8,6 +8,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -25,8 +26,8 @@ func TestBodyTypeDecl(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[1] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_decl_"+c.Name+".go.golden", code) @@ -56,8 +57,8 @@ func TestBodyTypeInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[c.SectionIndex] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_init_"+c.Name+".go.golden", code) @@ -65,6 +66,40 @@ func TestBodyTypeInit(t *testing.T) { } } +// TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer verifies that +// a required JSON string still enters a pointer field in the decoded view so a +// missing value can be reported. +func TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.required-viewed-primitive", func() { + dsl.TypeName("RequiredViewedPrimitive") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/values") + dsl.Response(dsl.StatusOK, func() { dsl.Body("value") }) + }) + }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + file := plan.ClientTypeFiles()[0] + var generated bytes.Buffer + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&generated)) + } + definition := codegen.FormatTestCode(t, "package client\n"+generated.String()) + + require.Contains(t, definition, `func NewFetchResultOK(body string) *valuesviews.RequiredViewedPrimitiveView`) + require.Contains(t, definition, "Value: &v,") +} + func TestClientTypes(t *testing.T) { const genpkg = "gen" cases := []struct { @@ -90,8 +125,8 @@ func TestClientTypes(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) @@ -113,8 +148,8 @@ func TestClientTypeFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fw := ClientTypeFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fw := plan.ClientTypeFiles() for i, fs := range fw { var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index f6845eccba..bcdcb82af0 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -14,6 +14,7 @@ import ( // commandData wraps the common CommandData and adds HTTP-specific fields. type commandData struct { *cli.CommandData + serviceName string // Subcommands is the list of endpoint commands. Subcommands []*subcommandData // NeedDialer if true initializes the websocket dialer. @@ -22,16 +23,19 @@ type commandData struct { // streaming endpoints are configured with a goahttp.ConnConfigureFunc // instead of a client package ConnConfigurer. JSONRPC bool + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + // Configurer is the WebSocket configuration type accepted by ParseEndpoint. + Configurer *codegen.NameDeclaration } // commandData wraps the common SubcommandData and adds HTTP-specific fields. type subcommandData struct { *cli.SubcommandData - // MultipartFuncName is the name of the function used to render a multipart - // request encoder. - MultipartFuncName string - // MultipartFuncName is the name of the variable used to render a multipart - // request encoder. + methodName string + // MultipartFuncDeclaration supplies the multipart request encoder type name. + MultipartFuncDeclaration *codegen.NameDeclaration + // MultipartVarName is the variable that holds the multipart request encoder. MultipartVarName string // StreamFlag is the flag used to identify the file to be streamed when // the endpoint uses SkipRequestBodyEncodeDecode. @@ -39,11 +43,11 @@ type subcommandData struct { // BuildStreamPayload is the name of the generated function that builds the // request data structure that wraps the payload and the file stream for // endpoints that use SkipRequestBodyEncodeDecode. - BuildStreamPayload string + BuildStreamPayload *codegen.NameDeclaration } -// ClientCLIFiles returns the client HTTP CLI support file. -func ClientCLIFiles(data *ServicesData) []*codegen.File { +// clientCLIFiles builds the client command file read by Plan.Link. +func clientCLIFiles(data *ServicesData) []*codegen.File { if len(data.Expressions.Services) == 0 { return nil } @@ -56,8 +60,11 @@ func ClientCLIFiles(data *ServicesData) []*codegen.File { if len(sd.Endpoints) > 0 { command := &commandData{ CommandData: cli.BuildCommandData(sd.Service, sd.ClientPkgName), + serviceName: sd.Service.Name, NeedDialer: HasWebSocket(sd), JSONRPC: sd.Endpoints[0].IsJSONRPC, + ClientInit: sd.ClientInitDeclaration, + Configurer: sd.ClientConnConfigurerDeclaration, } for _, e := range sd.Endpoints { @@ -92,17 +99,21 @@ func ClientCLIFiles(data *ServicesData) []*codegen.File { func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { flags, buildFunction := buildFlags(sd, e) + if buildFunction != nil { + buildFunction.Declaration = e.CLIPayloadDeclaration + } sub := &subcommandData{ SubcommandData: cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags), + methodName: e.Method.Name, } if e.MultipartRequestEncoder != nil { sub.MultipartVarName = e.MultipartRequestEncoder.VarName - sub.MultipartFuncName = e.MultipartRequestEncoder.FuncName + sub.MultipartFuncDeclaration = e.MultipartRequestEncoder.FuncDeclaration } if e.Method.SkipRequestBodyEncodeDecode { sub.StreamFlag = streamFlag(sd.Service.Name, e.Method.Name) - sub.BuildStreamPayload = e.BuildStreamPayload + sub.BuildStreamPayload = e.BuildStreamPayloadDeclaration } return sub } @@ -141,24 +152,54 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa } } + parser := services.cliParsers[svr] + if parser == nil { + panic(fmt.Sprintf("HTTP CLI parser names are missing for server %q", svr.Name)) + } + plannedData := make([]*commandData, len(data)) cliData := make([]*cli.CommandData, len(data)) - for i, cmd := range data { - cliData[i] = cmd.CommandData + for i, command := range data { + commandNames := parser.Commands[command.serviceName] + if commandNames == nil { + panic(fmt.Sprintf("HTTP CLI command names are missing for service %q", command.serviceName)) + } + commandCopy := *command + commonCommand := *command.CommandData + commonCommand.UsageDeclaration = commandNames.Usage + commandCopy.CommandData = &commonCommand + commandCopy.Subcommands = make([]*subcommandData, len(command.Subcommands)) + commonCommand.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for j, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.methodName] + if usage == nil { + panic(fmt.Sprintf("HTTP CLI method help name is missing for %q.%q", command.serviceName, subcommand.methodName)) + } + subcommandCopy := *subcommand + commonSubcommand := *subcommand.SubcommandData + commonSubcommand.UsageDeclaration = usage + subcommandCopy.SubcommandData = &commonSubcommand + commandCopy.Subcommands[j] = &subcommandCopy + commonCommand.Subcommands[j] = &commonSubcommand + } + plannedData[i] = &commandCopy + cliData[i] = &commonCommand } parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint", Source: httpTemplates.Read(parseEndpointT), Data: struct { - FlagsCode string - Commands []*commandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*commandData }{ + parser.Declarations.ParseEndpoint, cli.FlagsCode(cliData), - data, + plannedData, }, FuncMap: map[string]any{"streamingCmdExists": streamingCmdExists}, } - return cli.EndpointParserFile(path, title, specs, cliData, parseSection) + return cli.EndpointParserFile(path, title, specs, cliData, parser.Declarations, parseSection) } // payloadBuilders returns the file that contains the payload constructors that diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index 8f400b3871..e3597e7a22 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -56,8 +56,8 @@ func TestClientCLIFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientCLIFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() sections := fs[c.FileIndex].SectionTemplates code := codegen.SectionCode(t, sections[c.SectionIndex]) testutil.AssertGo(t, "testdata/golden/client_cli_"+c.Name+".go.golden", code) @@ -67,8 +67,8 @@ func TestClientCLIFiles(t *testing.T) { func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveFieldEmptyDSL) - services := CreateHTTPServices(root) - endpoint := services.Get("ServiceBodyPrimitiveArrayUser").Endpoints[0] + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("ServiceBodyPrimitiveArrayUser").Endpoints[0] require.NotNil(t, endpoint.Payload.Request.PayloadInit) require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) example := endpoint.Payload.Request.PayloadInit.ClientArgs[0].Example diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index be3e928557..021735a333 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -38,8 +38,8 @@ func TestClientDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_encode_test.go b/http/codegen/client_encode_test.go index 5c56d1aa2e..3db0ef495e 100644 --- a/http/codegen/client_encode_test.go +++ b/http/codegen/client_encode_test.go @@ -181,8 +181,8 @@ func TestClientEncode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) @@ -205,8 +205,8 @@ func TestClientBuildRequest(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_init_test.go b/http/codegen/client_init_test.go index c72dfb9813..dbeaed1b4c 100644 --- a/http/codegen/client_init_test.go +++ b/http/codegen/client_init_test.go @@ -25,8 +25,8 @@ func TestClientInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/clone.go b/http/codegen/clone.go new file mode 100644 index 0000000000..06846a127a --- /dev/null +++ b/http/codegen/clone.go @@ -0,0 +1,129 @@ +// This file copies the values used to write generated files. A caller may +// change the copy without changing the HTTP files saved for the same service. +package codegen + +import ( + "fmt" + "reflect" + + "goa.design/goa/v3/codegen" +) + +var immutableRenderPointers = map[reflect.Type]struct{}{ + reflect.TypeFor[*codegen.NameDeclaration](): {}, + reflect.TypeFor[*codegen.TypeDeclaration](): {}, + reflect.TypeFor[*codegen.UnionDeclaration](): {}, + reflect.TypeFor[*codegen.UnionBranchDeclaration](): {}, + reflect.TypeFor[*codegen.Location](): {}, + reflect.TypeFor[*codegen.GoTypePlan](): {}, + reflect.TypeFor[*wireTypeRecord](): {}, + reflect.TypeFor[*wireUnionRecord](): {}, +} + +// cloneRenderData copies maps, slices, pointers, and values stored in an +// interface. Generated name and type records are shared because they cannot be +// changed after their names are assigned. +func cloneRenderData(data any) any { + if data == nil { + return nil + } + return cloneRenderValue(reflect.ValueOf(data), make(map[clonePointer]reflect.Value)).Interface() +} + +type clonePointer struct { + typeOf reflect.Type + pointer uintptr +} + +// cloneRenderValue remembers pointers it has already copied. This preserves +// repeated references and lets it copy values that refer back to themselves. +func cloneRenderValue(source reflect.Value, seen map[clonePointer]reflect.Value) reflect.Value { + if !source.IsValid() { + return source + } + if source.Type() == reflect.TypeFor[TypeData]() { + data := source.Interface().(TypeData) + copy := data + copy.Init = copyInitData(data.Init) + copy.Example = cloneRenderData(data.Example) + return reflect.ValueOf(copy) + } + switch source.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return source + case reflect.Func: + panic(fmt.Sprintf("HTTP template data contains function value of type %s", source.Type())) + case reflect.Interface: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + copy := reflect.New(source.Type()).Elem() + copy.Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Pointer: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + if _, ok := immutableRenderPointers[source.Type()]; ok { + return source + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.New(source.Type().Elem()) + seen[key] = copy + copy.Elem().Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Slice: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), source.Pointer()} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeSlice(source.Type(), source.Len(), source.Len()) + seen[key] = copy + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + case reflect.Map: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeMapWithSize(source.Type(), source.Len()) + seen[key] = copy + iterator := source.MapRange() + for iterator.Next() { + copy.SetMapIndex(cloneRenderValue(iterator.Key(), seen), cloneRenderValue(iterator.Value(), seen)) + } + return copy + case reflect.Struct: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.NumField(); index++ { + field := source.Type().Field(index) + if field.PkgPath != "" { + panic(fmt.Sprintf("HTTP template data contains private field %s.%s", source.Type(), field.Name)) + } + copy.Field(index).Set(cloneRenderValue(source.Field(index), seen)) + } + return copy + case reflect.Array: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + default: + panic(fmt.Sprintf("HTTP template data contains unsupported %s value", source.Kind())) + } +} diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 98eb608f77..0071c0a047 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -78,9 +78,9 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("http codegen does not duplicate cookie-backed auth fields", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) - serverTypes := typesFile(root.API.HTTP.Services[0], true, services) + serverTypes := plan.ServerTypeFiles()[0] var serverTypesBuf bytes.Buffer for _, section := range serverTypes.SectionTemplates[1:] { require.NoError(t, section.Write(&serverTypesBuf)) @@ -90,7 +90,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverTypesCode, "browserSession *string, browserSession *string") require.NotContains(t, serverTypesCode, "browserSession string, browserSession string") - serverFiles := ServerFiles(services) + serverFiles := plan.ServerFiles() require.Len(t, serverFiles, 2) serverDecode := codegen.SectionCode(t, serverFiles[1].SectionTemplates[2]) require.Contains(t, serverDecode, `r.Cookie("__Host-ak_session")`) @@ -98,7 +98,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverDecode, "browserSession *string, browserSession *string") require.NotContains(t, serverDecode, "browserSession string, browserSession string") - clientFiles := ClientFiles(services) + clientFiles := plan.ClientFiles() require.Len(t, clientFiles, 2) clientEncode := codegen.SectionCode(t, clientFiles[1].SectionTemplates[2]) require.Contains(t, clientEncode, `req.AddCookie(&http.Cookie{`) diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index ca94bb3ad6..ec0e02f674 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -13,21 +13,21 @@ import ( "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example client tool implementation for the -// transport described by services for each server expression. -func ExampleCLIFiles(services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example command-line client for the HTTP services +// on each configured server. +func exampleCLIFiles(services *ServicesData) []*codegen.File { var files []*codegen.File for _, svr := range services.Root.API.Servers { - if f := ExampleCLI(svr, services); f != nil { + if f := exampleCLI(svr, services); f != nil { files = append(files, f) } } return files } -// ExampleCLI returns an example client tool implementation for the transport -// described by services and the given server expression. -func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { +// exampleCLI returns an example command-line client for the HTTP services on +// the given server. +func exampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, services.Root) outputPath := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") @@ -40,6 +40,10 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { } rootPath := path.Dir(genpkg) cliImport := services.PackageImport(path.Join(genpkg, services.dir(), "cli", svrdata.Dir)) + parser := services.cliParsers[svr] + if parser == nil { + panic("HTTP command parser names are missing for server " + svr.Name) + } specs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "encoding/json"}, @@ -106,6 +110,7 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { "Services": svcData, "APIPkg": apiPkg, "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, }, FuncMap: map[string]any{ "needDialer": NeedDialer, @@ -118,6 +123,7 @@ func ExampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { Data: map[string]any{ "VarPrefix": services.dir(), "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, }, }, } diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 89fb32933d..15f21edb37 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -31,8 +31,8 @@ func TestExampleCLIFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleCLIFiles(httpServices) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ExampleCLIFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 90c362fb04..7ce8b94807 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -4,6 +4,7 @@ package codegen import ( + "maps" "os" "path" "path/filepath" @@ -13,11 +14,11 @@ import ( "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example http service implementation. -func ExampleServerFiles(data *ServicesData) []*codegen.File { +// exampleServerFiles builds each runnable HTTP server read by Plan.Link. +func exampleServerFiles(data *ServicesData) []*codegen.File { var fw []*codegen.File for _, svr := range data.Root.API.Servers { - if m := ExampleServer(data.Root, svr, data); m != nil { + if m := exampleServer(data.Root, svr, data); m != nil { fw = append(fw, m) } } @@ -29,8 +30,8 @@ func ExampleServerFiles(data *ServicesData) []*codegen.File { return fw } -// ExampleServer returns an example HTTP server implementation. -func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { +// exampleServer returns an example HTTP server implementation. +func exampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { genpkg := services.GenPkg() svrdata := example.Servers.Get(svr, root) fpath := filepath.Join("cmd", svrdata.Dir, "http.go") @@ -123,6 +124,174 @@ func ExampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services return &codegen.File{Path: fpath, SectionTemplates: sections, SkipExist: true} } +// combinedExampleServerFiles builds runnable server files that mount both the +// JSON-RPC services and the ordinary HTTP services from one design. The caller +// may edit every returned file without changing either input plan. +func combinedExampleServerFiles(jsonrpc, application *ServicesData) []*codegen.File { + root := jsonrpc.Root + files := make([]*codegen.File, 0, len(root.API.Servers)) + for _, server := range root.API.Servers { + file := combinedExampleServer(root, server, jsonrpc, application) + if file != nil { + files = append(files, file) + } + } + if application != nil { + for _, service := range application.Expressions.Services { + if file := dummyMultipartFile(service, application); file != nil { + files = append(files, cloneGeneratedFile(file)) + } + } + } + return files +} + +// combinedExampleServer builds one main-package file for a configured server. +// It reads service membership from server and writes separate HTTP and +// JSON-RPC lists because the code that writes main initializes them differently. +func combinedExampleServer(root *expr.RootExpr, server *expr.ServerExpr, jsonrpc, application *ServicesData) *codegen.File { + serverData := example.Servers.Get(server, root) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "net/http"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "sync"}, + {Path: "time"}, + codegen.GoaNamedImport("http", "goahttp"), + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/log"}, + codegen.GoaImport("middleware"), + {Path: "github.com/gorilla/websocket"}, + } + var ordinaryServices []*ServiceData + if application != nil { + for _, name := range server.Services { + data := application.Get(name) + if data == nil { + continue + } + ordinaryServices = append(ordinaryServices, data) + imports = append(imports, + application.PackageImport(path.Join(application.GenPkg(), "http", data.Service.PathName, "server")), + application.ServiceImport(name), + ) + } + } + var jsonrpcServices []*ServiceData + for _, name := range server.Services { + data := jsonrpc.Get(name) + if data == nil { + continue + } + jsonrpcServices = append(jsonrpcServices, data) + imports = append(imports, + jsonrpc.PackageImport(path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server")), + jsonrpc.ServiceImport(name), + ) + } + if len(ordinaryServices) == 0 && len(jsonrpcServices) == 0 { + return nil + } + apiImport := jsonrpc.PackageImport(path.Dir(jsonrpc.GenPkg())) + imports = append(imports, apiImport) + imports = uniqueExampleImports(imports) + data := map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + } + sections := []*codegen.SectionTemplate{ + codegen.Header("", "main", imports), + {Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: data}, + {Name: "server-http-encoding", Source: httpTemplates.Read(serverEncodingT)}, + {Name: "server-http-mux", Source: httpTemplates.Read(serverMuxT)}, + { + Name: "server-http-init", + Source: httpTemplates.Read(serverConfigureT), + Data: map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + "APIPkg": apiImport.Name, + }, + FuncMap: map[string]any{"needDialer": NeedDialer, "hasWebSocket": HasWebSocket}, + }, + {Name: "server-http-middleware", Source: httpTemplates.Read(serverMiddlewareT)}, + {Name: "server-http-end", Source: httpTemplates.Read(serverEndT), Data: data}, + {Name: "server-http-errorhandler", Source: httpTemplates.Read(serverErrorHandlerT)}, + } + return &codegen.File{ + Path: filepath.Join("cmd", serverData.Dir, "http.go"), + SectionTemplates: sections, + SkipExist: true, + } +} + +// uniqueExampleImports keeps the first import for each Go package path. A +// service exposed over both protocols uses the same generated service package. +func uniqueExampleImports(imports []*codegen.ImportSpec) []*codegen.ImportSpec { + result := make([]*codegen.ImportSpec, 0, len(imports)) + seen := make(map[string]struct{}, len(imports)) + for _, spec := range imports { + if _, ok := seen[spec.Path]; ok { + continue + } + seen[spec.Path] = struct{}{} + result = append(result, spec) + } + return result +} + +// cloneGeneratedFile copies a generated file and its section records so the +// caller may change the copy without changing the source plan. +func cloneGeneratedFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionClone := *section + sectionClone.FuncMap = maps.Clone(section.FuncMap) + sectionClone.Data = cloneRenderData(section.Data) + clone.SectionTemplates[index] = §ionClone + } + return &clone +} + +// cloneJSONRPCCodecFile copies an encoder and decoder file and replaces each +// HTTP endpoint value with the smaller value read by JSON-RPC code. +func cloneJSONRPCCodecFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionCopy := *section + sectionCopy.FuncMap = maps.Clone(section.FuncMap) + if endpoint, ok := section.Data.(*EndpointData); ok { + switch section.Name { + case "response-decoder": + data := copyJSONRPCEndpoint(endpoint) + sectionCopy.Data = &data + case "request-builder", "request-encoder", "request-decoder": + sectionCopy.Data = copyJSONRPCRequestCodec(endpoint) + default: + panic("JSON-RPC codec contains an unsupported endpoint section " + section.Name) + } + } else if helper, ok := section.Data.(*codegen.TransformFunctionData); ok { + if section.Name != "client-transform-helper" && section.Name != "server-transform-helper" { + panic("JSON-RPC codec contains a transform helper in unsupported section " + section.Name) + } + sectionCopy.Data = copyJSONRPCTransformFunction(helper) + } else { + sectionCopy.Data = cloneRenderData(section.Data) + } + clone.SectionTemplates[index] = §ionCopy + } + return &clone +} + // dummyMultipartFile returns a dummy implementation of the multipart decoders // and encoders. func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { @@ -147,7 +316,7 @@ func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *code } specs = append(specs, services.ServiceImport(svc.Name())) rootPath := path.Dir(genpkg) - specs = append(specs, services.AttributeImports(rootPath, ServiceReferenceAttributes(multipartEndpoints...)...)...) + specs = append(specs, services.AttributeImports(rootPath, serviceReferenceAttributes(multipartEndpoints...)...)...) apiPkg := services.PackageImport(rootPath).Name sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index 207c15daca..8a41d3b257 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -35,8 +35,8 @@ func TestExampleServerFiles(t *testing.T) { example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) - httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleServerFiles(httpServices) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ExampleServerFiles() require.Len(t, fs, 2) for i, f := range fs { if i < len(fs)-1 { @@ -71,8 +71,8 @@ func TestExampleServerFiles(t *testing.T) { // reset global variable example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(createServiceServices(root), root.API.HTTP) - fs := ExampleServerFiles(httpServices) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ExampleServerFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/http/codegen/handler_test.go b/http/codegen/handler_test.go index 54c16203c7..b4858d4d2d 100644 --- a/http/codegen/handler_test.go +++ b/http/codegen/handler_test.go @@ -31,8 +31,8 @@ func TestHandlerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler-init") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/idempotency_test.go b/http/codegen/idempotency_test.go index f046c7b0bc..17b2335d75 100644 --- a/http/codegen/idempotency_test.go +++ b/http/codegen/idempotency_test.go @@ -1,5 +1,5 @@ -// This file verifies repeated HTTP analysis produces the same package-owned -// declarations and does not retain mutable state between runs. +// This file verifies repeated HTTP generation produces the same Go names and +// does not keep changeable values from an earlier run. package codegen import ( @@ -39,8 +39,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { }) }) }) - services := CreateHTTPServices(root) - clientFiles := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) @@ -66,14 +66,14 @@ func TestFileGenerationIdempotent(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) render := func(dir string) { - files := PathFiles(services) - files = append(files, ServerFiles(services)...) - files = append(files, ClientFiles(services)...) - files = append(files, ServerTypeFiles(services)...) - files = append(files, ClientTypeFiles(services)...) + files := plan.PathFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) require.NotEmpty(t, files) for _, f := range files { _, err := f.Render(dir) diff --git a/http/codegen/jsonrpc_data.go b/http/codegen/jsonrpc_data.go new file mode 100644 index 0000000000..c49f97dea8 --- /dev/null +++ b/http/codegen/jsonrpc_data.go @@ -0,0 +1,415 @@ +// This file copies the HTTP values used to write JSON-RPC files. Changing a +// copy cannot change the HTTP values saved for the same service. +package codegen + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // jsonRPCRequestCodecData contains the values used to write JSON-RPC request + // builders, encoders, and decoders. + jsonRPCRequestCodecData struct { + *JSONRPCEndpointSnapshot + BasicScheme *service.SchemeData + HeaderSchemes service.SchemesData + MultipartRequestEncoder any + MultipartRequestDecoder any + } + + // jsonRPCTransformFunctionData contains the five values used to write one + // generated conversion function. + jsonRPCTransformFunctionData struct { + Declaration *codegen.NameDeclaration + Name string + ParamTypeRef string + ResultTypeRef string + Code string + } +) + +// copyJSONRPCEndpoint returns the values read by JSON-RPC files for endpoint. +// Changing the returned value cannot change endpoint. +func copyJSONRPCEndpoint(endpoint *EndpointData) JSONRPCEndpointSnapshot { + result := JSONRPCEndpointSnapshot{ + IsJSONRPC: endpoint.IsJSONRPC, + Method: copyJSONRPCMethod(endpoint.Method), + ServiceName: endpoint.ServiceName, + ServicePkgName: endpoint.ServicePkgName, + Payload: copyJSONRPCPayload(endpoint.Payload), + Result: copyJSONRPCResult(endpoint.Result), + Errors: copyJSONRPCErrors(endpoint.Errors), + Routes: copyJSONRPCRoutes(endpoint.Routes), + RequestInit: copyInitData(endpoint.RequestInit), + EndpointInit: endpoint.EndpointInit, + HandlerInitDeclaration: endpoint.HandlerInitDeclaration, + ClientStructDeclaration: endpoint.ClientStructDeclaration, + RequestEncoderDeclaration: endpoint.RequestEncoderDeclaration, + RequestDecoderDeclaration: endpoint.RequestDecoderDeclaration, + ResponseDecoderDeclaration: endpoint.ResponseDecoderDeclaration, + SSE: copyJSONRPCSSE(endpoint.SSE), + ClientWebSocket: copyJSONRPCWebSocket(endpoint.ClientWebSocket), + ServerWebSocket: copyJSONRPCWebSocket(endpoint.ServerWebSocket), + } + return result +} + +// copyJSONRPCRequestCodec returns the values used to write one JSON-RPC request. +// JSON-RPC request bodies cannot use multipart encoding. +func copyJSONRPCRequestCodec(endpoint *EndpointData) *jsonRPCRequestCodecData { + if endpoint.MultipartRequestEncoder != nil || endpoint.MultipartRequestDecoder != nil { + panic("JSON-RPC request codec cannot use multipart encoding") + } + data := copyJSONRPCEndpoint(endpoint) + return &jsonRPCRequestCodecData{ + JSONRPCEndpointSnapshot: &data, + BasicScheme: copyJSONRPCScheme(endpoint.BasicScheme), + HeaderSchemes: copyJSONRPCSchemes(endpoint.HeaderSchemes), + } +} + +// copyJSONRPCTransformFunction returns the values written into one generated +// conversion function. +func copyJSONRPCTransformFunction(helper *codegen.TransformFunctionData) *jsonRPCTransformFunctionData { + return &jsonRPCTransformFunctionData{ + Declaration: helper.Declaration, + Name: helper.Name, + ParamTypeRef: helper.ParamTypeRef, + ResultTypeRef: helper.ResultTypeRef, + Code: helper.Code, + } +} + +// copyJSONRPCSchemes returns new security records for a generated request. +func copyJSONRPCSchemes(schemes service.SchemesData) service.SchemesData { + result := make(service.SchemesData, len(schemes)) + for index, scheme := range schemes { + result[index] = copyJSONRPCScheme(scheme) + } + return result +} + +// copyJSONRPCScheme returns a security record that callers may change independently. +func copyJSONRPCScheme(scheme *service.SchemeData) *service.SchemeData { + if scheme == nil { + return nil + } + copy := *scheme + copy.Scopes = append([]string(nil), scheme.Scopes...) + copy.Flows = make([]*expr.FlowExpr, len(scheme.Flows)) + for index, flow := range scheme.Flows { + flowCopy := *flow + copy.Flows[index] = &flowCopy + } + return © +} + +// copyJSONRPCMethod returns the method names and stream methods used by JSON-RPC files. +func copyJSONRPCMethod(method *service.MethodData) JSONRPCMethodData { + result := JSONRPCMethodData{ + Name: method.Name, + VarName: method.VarName, + EventDeclaration: method.EventDeclaration, + Result: method.Result, + Idempotent: method.Idempotent, + ServerStream: copyJSONRPCStream(method.ServerStream), + ClientStream: copyJSONRPCStream(method.ClientStream), + StreamKind: method.StreamKind, + SkipRequestBodyEncodeDecode: method.SkipRequestBodyEncodeDecode, + RequestStruct: method.RequestStruct, + } + result.Errors = make([]JSONRPCMethodErrorData, len(method.Errors)) + for index, serviceError := range method.Errors { + result.Errors[index] = JSONRPCMethodErrorData{ + ErrName: serviceError.ErrName, + Temporary: serviceError.Temporary, + } + } + if method.ViewedResult != nil { + viewed := copyJSONRPCViewedResult(method.ViewedResult) + result.ViewedResult = &JSONRPCMethodViewedResultData{ + JSONRPCViewedResultData: viewed, + ViewName: method.ViewedResult.ViewName, + } + } + return result +} + +// copyJSONRPCViewedResult returns the names used to check and convert a viewed +// result in JSON-RPC files. +func copyJSONRPCViewedResult(viewed *service.ViewedResultTypeData) JSONRPCViewedResultData { + return JSONRPCViewedResultData{ + FullRef: viewed.FullRef, + VarName: viewed.VarName, + ViewsPkg: viewed.ViewsPkg, + Validate: viewed.Validate.Declaration, + ResultInit: viewed.ResultInit.Declaration, + Init: viewed.Init.Declaration, + IsCollection: viewed.IsCollection, + } +} + +// copyJSONRPCStream returns the service stream method names read by JSON-RPC files. +func copyJSONRPCStream(stream *service.StreamData) *JSONRPCStreamData { + if stream == nil { + return nil + } + return &JSONRPCStreamData{ + Interface: stream.Interface, + VarName: stream.VarName, + SendName: stream.SendName, + SendDesc: stream.SendDesc, + SendWithContextName: stream.SendWithContextName, + SendWithContextDesc: stream.SendWithContextDesc, + SendTypeName: stream.SendTypeName, + SendTypeRef: stream.SendTypeRef, + RecvName: stream.RecvName, + RecvDesc: stream.RecvDesc, + RecvWithContextName: stream.RecvWithContextName, + RecvWithContextDesc: stream.RecvWithContextDesc, + RecvTypeName: stream.RecvTypeName, + RecvTypeRef: stream.RecvTypeRef, + EndpointStruct: stream.EndpointStruct, + Kind: stream.Kind, + } +} + +// copyJSONRPCPayload returns the request values read by JSON-RPC files. +func copyJSONRPCPayload(payload *PayloadData) *JSONRPCPayloadData { + if payload == nil { + return nil + } + result := &JSONRPCPayloadData{ + Ref: payload.Ref, + IDAttribute: payload.IDAttribute, + IDAttributeRequired: payload.IDAttributeRequired, + DecoderReturnValue: payload.DecoderReturnValue, + } + if payload.Request != nil { + request := payload.Request + result.Request = &JSONRPCRequestData{ + ClientBody: copyJSONRPCBody(request.ClientBody), + ServerBody: copyJSONRPCBody(request.ServerBody), + PayloadInit: copyInitData(request.PayloadInit), + Headers: copyJSONRPCHeaders(request.Headers), + Cookies: copyJSONRPCCookies(request.Cookies), + PayloadAttr: request.PayloadAttr, + MustHaveBody: request.MustHaveBody, + MustValidate: request.MustValidate, + } + if request.PayloadType != nil { + result.Request.PayloadTypeName = request.PayloadType.Name() + } + } + return result +} + +// copyJSONRPCResult returns the response values read by JSON-RPC files. +func copyJSONRPCResult(result *ResultData) *JSONRPCResultData { + if result == nil { + return nil + } + copy := &JSONRPCResultData{ + Ref: result.Ref, + IDAttribute: result.IDAttribute, + IDAttributeRequired: result.IDAttributeRequired, + View: result.View, + Responses: make([]JSONRPCResponseData, len(result.Responses)), + } + for index, response := range result.Responses { + copy.Responses[index] = copyJSONRPCResponse(response) + } + return copy +} + +// copyJSONRPCResponse returns the body, headers, cookies, and constructor for one response. +func copyJSONRPCResponse(response *ResponseData) JSONRPCResponseData { + serverBodies := make([]JSONRPCBodyData, len(response.ServerBody)) + for index, body := range response.ServerBody { + serverBodies[index] = *copyJSONRPCBody(body) + } + return JSONRPCResponseData{ + StatusCode: response.StatusCode, + Code: response.Code, + Headers: copyJSONRPCHeaders(response.Headers), + Cookies: copyJSONRPCCookies(response.Cookies), + ServerBody: serverBodies, + ClientBody: copyJSONRPCBody(response.ClientBody), + ResultInit: copyInitData(response.ResultInit), + MustValidate: response.MustValidate, + } +} + +// copyJSONRPCErrors returns the designed error responses read by JSON-RPC files. +func copyJSONRPCErrors(groups []*ErrorGroupData) []JSONRPCErrorGroupData { + result := make([]JSONRPCErrorGroupData, len(groups)) + for groupIndex, group := range groups { + errors := make([]JSONRPCErrorData, len(group.Errors)) + for errorIndex, serviceError := range group.Errors { + errors[errorIndex] = JSONRPCErrorData{ + Name: serviceError.Name, + Ref: serviceError.Ref, + Response: copyJSONRPCResponse(serviceError.Response), + } + } + result[groupIndex] = JSONRPCErrorGroupData{StatusCode: group.StatusCode, Errors: errors} + } + return result +} + +// copyJSONRPCRoutes returns the HTTP verbs and paths accepted by a JSON-RPC server. +func copyJSONRPCRoutes(routes []*RouteData) []JSONRPCRouteData { + result := make([]JSONRPCRouteData, len(routes)) + for index, route := range routes { + result[index] = JSONRPCRouteData{Verb: route.Verb, Path: route.Path} + } + return result +} + +// copyJSONRPCSSE returns the event-stream fields read by JSON-RPC files. +func copyJSONRPCSSE(stream *SSEData) *JSONRPCSSEData { + if stream == nil { + return nil + } + return &JSONRPCSSEData{ + StructDeclaration: stream.StructDeclaration, + ClientInterfaceDeclaration: stream.ClientInterfaceDeclaration, + ClientStructDeclaration: stream.ClientStructDeclaration, + ClientInitDeclaration: stream.ClientInitDeclaration, + EventTypeRef: stream.EventTypeRef, + RequestIDField: stream.RequestIDField, + } +} + +// copyJSONRPCWebSocket returns the WebSocket stream names read by JSON-RPC files. +func copyJSONRPCWebSocket(stream *WebSocketData) *JSONRPCWebSocketData { + if stream == nil { + return nil + } + return &JSONRPCWebSocketData{ + VarDeclaration: stream.VarDeclaration, + VarName: stream.VarName, + SendName: stream.SendName, + SendDesc: stream.SendDesc, + SendWithContextName: stream.SendWithContextName, + SendWithContextDesc: stream.SendWithContextDesc, + SendTypeName: stream.SendTypeName, + SendTypeRef: stream.SendTypeRef, + RecvName: stream.RecvName, + RecvDesc: stream.RecvDesc, + RecvWithContextName: stream.RecvWithContextName, + RecvWithContextDesc: stream.RecvWithContextDesc, + RecvTypeName: stream.RecvTypeName, + RecvTypeRef: stream.RecvTypeRef, + } +} + +// copyJSONRPCBody returns the generated body names and the code that converts +// the body value. +func copyJSONRPCBody(body *TypeData) *JSONRPCBodyData { + if body == nil { + return nil + } + return &JSONRPCBodyData{ + VarName: body.VarName, + Ref: body.Ref, + ValidateRef: body.ValidateRef, + Init: copyInitData(body.Init), + } +} + +// copyInitData returns conversion arguments that callers may change without +// changing the source values. +func copyInitData(init *InitData) *InitData { + if init == nil { + return nil + } + copy := *init + copy.ServerArgs = copyInitArgs(init.ServerArgs) + copy.ClientArgs = copyInitArgs(init.ClientArgs) + copy.CLIArgs = copyInitArgs(init.CLIArgs) + return © +} + +// copyInitArgs returns conversion arguments with new attribute records. +func copyInitArgs(args []*InitArgData) []*InitArgData { + result := make([]*InitArgData, len(args)) + for index, arg := range args { + copy := *arg + if arg.AttributeData != nil { + attribute := *arg.AttributeData + attribute.Type = copyDataType(attribute.Type) + attribute.FieldType = copyDataType(attribute.FieldType) + attribute.DefaultValue = cloneRenderData(attribute.DefaultValue) + attribute.Example = cloneRenderData(attribute.Example) + copy.AttributeData = &attribute + } + result[index] = © + } + return result +} + +// copyDataType returns a new Goa type graph, or nil when no type was supplied. +func copyDataType(dataType expr.DataType) expr.DataType { + if dataType == nil { + return nil + } + return expr.Dup(dataType) +} + +// copyJSONRPCHeaders returns header values that do not share default data with source. +func copyJSONRPCHeaders(source []*HeaderData) []JSONRPCHeaderData { + result := make([]JSONRPCHeaderData, len(source)) + for index, header := range source { + result[index] = JSONRPCHeaderData{ + JSONRPCElementData: copyJSONRPCElement(header.Element), + CanonicalName: header.CanonicalName, + } + } + return result +} + +// copyJSONRPCCookies returns cookie values that do not share default data with source. +func copyJSONRPCCookies(source []*CookieData) []JSONRPCCookieData { + result := make([]JSONRPCCookieData, len(source)) + for index, cookie := range source { + result[index] = JSONRPCCookieData{ + JSONRPCElementData: copyJSONRPCElement(cookie.Element), + MaxAge: cookie.MaxAge, + Path: cookie.Path, + Domain: cookie.Domain, + Secure: cookie.Secure, + HTTPOnly: cookie.HTTPOnly, + SameSite: cookie.SameSite, + } + } + return result +} + +// copyJSONRPCElement returns the fields used to decode one header or cookie. +func copyJSONRPCElement(element *Element) JSONRPCElementData { + dataType := element.Type + result := JSONRPCElementData{ + Name: element.Name, + VarName: element.VarName, + TypeName: dataType.Name(), + ElemTypeRef: element.ElemTypeRef, + TypeRef: element.TypeRef, + Pointer: element.Pointer, + FieldName: element.FieldName, + FieldPointer: element.FieldPointer, + IsAliased: expr.IsAlias(element.FieldType), + Required: element.Required, + DefaultValue: cloneRenderData(element.DefaultValue), + Validate: element.Validate, + HTTPName: element.HTTPName, + StringSlice: element.StringSlice, + Slice: element.Slice, + } + if array := expr.AsArray(dataType); array != nil { + result.ElemTypeName = array.ElemType.Type.Name() + } + return result +} diff --git a/http/codegen/multipart_test.go b/http/codegen/multipart_test.go index c3715247f4..0d267ab0de 100644 --- a/http/codegen/multipart_test.go +++ b/http/codegen/multipart_test.go @@ -26,8 +26,8 @@ func TestServerMultipartFuncType(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 5) @@ -51,8 +51,8 @@ func TestClientMultipartFuncType(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 4) @@ -78,8 +78,8 @@ func TestServerMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) @@ -105,8 +105,8 @@ func TestClientMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 2e3a28ede0..36d53b6d8b 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -66,8 +66,8 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientCLIFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() return codegen.SectionCode(t, fs[fileIndex].SectionTemplates[sectionIndex]) } @@ -79,8 +79,8 @@ func renderClientTypesCode(t *testing.T, dsl func()) string { const genpkg = "gen" root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { @@ -96,8 +96,8 @@ func renderClientDecodeCode(t *testing.T, dsl func()) string { t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates diff --git a/http/codegen/openapi_order_independence_test.go b/http/codegen/openapi_order_independence_test.go index eff943d61e..ea1466d9de 100644 --- a/http/codegen/openapi_order_independence_test.go +++ b/http/codegen/openapi_order_independence_test.go @@ -1,5 +1,5 @@ -// This file verifies HTTP and OpenAPI analysis produce identical examples -// regardless of which transport representation is analyzed first. +// This file verifies that HTTP and OpenAPI generation produce the same examples +// regardless of which one reads the design first. package codegen import ( @@ -15,49 +15,39 @@ import ( "goa.design/goa/v3/http/codegen/testdata" ) -// TestOpenAPIOrderIndependence verifies that the OpenAPI specifications do not -// depend on whether the HTTP transport data was computed first: the HTTP -// analyze pass must treat the design expression tree as read-only so the -// OpenAPI generators always see the pristine design. The production "goa gen" -// flow runs the transport generators before the OpenAPI one while the OpenAPI -// golden tests run on pristine roots; any difference between the two -// generations is output that production emits but no golden test covers. +// TestOpenAPIOrderIndependence verifies that building HTTP files first does not +// change the OpenAPI documents. HTTP generation must not change the design that +// the OpenAPI generator reads afterward. func TestOpenAPIOrderIndependence(t *testing.T) { cases := []struct { Name string DSL func() }{ - // Aliased payload/result attributes: makeHTTPType used to flatten - // the aliases in place which changed the schemas OpenAPI generated. + // Named request and result fields used to be flattened in place, which + // changed the OpenAPI schemas generated afterward. {"alias-type", testdata.AliasTypeDSL}, {"result-body-multiple-views", testdata.ResultBodyMultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, {"error-response", testdata.PrimitiveErrorResponseDSL}, {"streaming-result", testdata.StreamingResultDSL}, {"streaming-payload", testdata.StreamingPayloadDSL}, - // NOTE: methods declaring anonymous object results (e.g. - // testdata.SSEObjectDSL) only pass this check because the raw - // object wrapping moved out of the service analyze pass into - // codegen.NewGeneration, which CreateHTTPServices constructs before - // computing the transport data. The pristine root below is rendered - // without generation ownership, so designs whose OpenAPI output depends - // on the wrapping must prepare both roots (see - // TestGeneratorsTreatDesignAsReadOnly in codegen/generator for the - // full read-only guarantee). + // Anonymous object results pass because codegen.NewGeneration prepares + // those result objects before HTTP generation starts. See + // TestGeneratorsTreatDesignAsReadOnly for the check that covers the whole + // generation run. {"sse", testdata.SSEStringDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Golden test order: generate the OpenAPI specifications from a - // pristine root. + // First build OpenAPI documents from an untouched design. pristine := renderOpenAPI(t, expr.RunDSL(t, c.DSL)) - // Production order: compute the HTTP transport data first, then - // generate the OpenAPI specifications from the same root. + // Then build HTTP files first and OpenAPI documents second from the + // same design. root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } produced := renderOpenAPI(t, root) diff --git a/http/codegen/paths.go b/http/codegen/paths.go index ef537ccac0..627bab8bbc 100644 --- a/http/codegen/paths.go +++ b/http/codegen/paths.go @@ -8,8 +8,8 @@ import ( "goa.design/goa/v3/expr" ) -// PathFiles returns the service path files. -func PathFiles(data *ServicesData) []*codegen.File { +// pathFiles builds the service path files read by Plan.Link. +func pathFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, 2*len(data.Expressions.Services)) for i := 0; i < len(data.Expressions.Services); i++ { fw[i*2] = serverPath(data.Expressions.Services[i], data) @@ -49,10 +49,17 @@ func pathSections(svc *expr.HTTPServiceExpr, pkg string, services *ServicesData) ) sdata := services.Get(svc.Name()) for _, e := range svc.HTTPEndpoints { + data := struct { + *EndpointData + Client bool + }{ + EndpointData: sdata.Endpoint(e.Name()), + Client: pkg == "client", + } sections = append(sections, &codegen.SectionTemplate{ Name: "path", Source: httpTemplates.Read(pathT), - Data: sdata.Endpoint(e.Name()), + Data: data, }) } diff --git a/http/codegen/paths_test.go b/http/codegen/paths_test.go index 00c17dcf6f..77eefb1cc6 100644 --- a/http/codegen/paths_test.go +++ b/http/codegen/paths_test.go @@ -38,8 +38,8 @@ func TestPaths(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) @@ -64,8 +64,8 @@ func TestPathTrailingShash(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) diff --git a/http/codegen/plan.go b/http/codegen/plan.go index 8810f45936..89153a37d9 100644 --- a/http/codegen/plan.go +++ b/http/codegen/plan.go @@ -1,19 +1,788 @@ -// This file declares the fixed import qualifiers used by HTTP-generated files -// before service package aliases are frozen for the generation. +// This file builds HTTP output in two steps. NewPlans requests every Go package +// name that the output files need. Plan.Link then builds the HTTP and JSON-RPC +// data after the service names are known. package codegen import ( + "cmp" + "fmt" + "net/http" "path" "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// Plan reserves every literal import qualifier used by HTTP render templates. -// Generated service packages are planned separately and receive a suffix when -// their preferred qualifier conflicts with one of these required names. -func Plan(generation *codegen.Generation) error { +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root contains the HTTP services that Goa will generate. + Root *expr.RootExpr + // Service is the service plan created for Root. + Service *service.Plan + } + + // Plan records package names for one design and later builds its HTTP files. + Plan struct { + root *expr.RootExpr + servicePlan *service.Plan + generation *codegen.Generation + transport transportKind + constructors map[viewedConstructorKey]*codegen.NameDeclaration + payloads map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streams map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + wireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + symbols map[*expr.HTTPServiceExpr]*httpSymbols + cliParsers map[*expr.ServerExpr]*cli.ParserPlan + services *ServicesData + viewed map[viewedMethodKey]*viewedResultPlan + jsonServices map[string]*jsonRPCServicePlan + server []*codegen.File + client []*codegen.File + serverTypes []*codegen.File + clientTypes []*codegen.File + paths []*codegen.File + clientCLI []*codegen.File + example []*codegen.File + exampleCLI []*codegen.File + } + + // jsonRPCServicePlan stores the HTTP data copied for the JSON-RPC file writer. + jsonRPCServicePlan struct { + data *ServiceData + services *ServicesData + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + } + + // viewedResultPlan stores the HTTP response data copied for the JSON-RPC file + // writer. + viewedResultPlan struct { + variable bool + fixedView string + service *service.ViewedResultTypeData + representations []viewedRepresentationPlan + } + + // viewedRepresentationPlan associates one body conversion with the headers + // and cookies written by the same successful response. + viewedRepresentationPlan struct { + data *ViewedRepresentationData + headers []*HeaderData + cookies []*CookieData + } + + // JSONRPCServiceSnapshot holds a separate copy of the HTTP service data used to write + // JSON-RPC client and server files. Callers may change it without changing + // the HTTP plan or a later copy. + JSONRPCServiceSnapshot struct { + // Service is a copy of the generated Goa service description. + Service JSONRPCServiceData + // Endpoints contains the JSON-RPC method data in design order. + Endpoints []JSONRPCEndpointSnapshot + // ClientStructDeclaration supplies the client type name written in HTTP files. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // ServerStructDeclaration supplies the server type name written in HTTP files. + ServerStructDeclaration *codegen.NameDeclaration + // ServerInitDeclaration supplies the server constructor name written in HTTP files. + ServerInitDeclaration *codegen.NameDeclaration + // MountServerDeclaration supplies the route mounting function name written in HTTP files. + MountServerDeclaration *codegen.NameDeclaration + // ServerService is the generated function that returns the service implementation. + ServerService string + serviceImport *codegen.ImportSpec + viewImport *codegen.ImportSpec + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + } + + // JSONRPCServiceData contains the service names written in JSON-RPC files. + JSONRPCServiceData struct { + // Name is the design service name. + Name string + // StructName is the exported Go spelling derived from Name. + StructName string + // EndpointsDeclaration supplies the service endpoint collection name. + EndpointsDeclaration *codegen.NameDeclaration + // StreamDeclaration supplies the shared service stream name. + StreamDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration supplies the service method name list. + MethodNamesDeclaration *codegen.NameDeclaration + // PkgName is the import name of the generated service package. + PkgName string + // PathName is the generated service directory name. + PathName string + } + + // JSONRPCEndpointSnapshot holds a separate copy of the HTTP values that + // JSON-RPC files read for one service method. + JSONRPCEndpointSnapshot struct { + // IsJSONRPC is true because this value describes a JSON-RPC method. + IsJSONRPC bool + // Method contains the service method names and stream methods used in JSON-RPC files. + Method JSONRPCMethodData + // ServiceName is the design service name written in generated errors. + ServiceName string + // ServicePkgName is the import name used for service types. + ServicePkgName string + // Payload describes the JSON-RPC request. It is nil when the method has no payload. + Payload *JSONRPCPayloadData + // Result describes the JSON-RPC result. It is nil when the method has no result. + Result *JSONRPCResultData + // Errors lists the designed errors returned by the method. + Errors []JSONRPCErrorGroupData + // Routes lists the HTTP paths and verbs accepted by the JSON-RPC server. + Routes []JSONRPCRouteData + // RequestInit builds the HTTP request used for a JSON-RPC call. + RequestInit *InitData + // EndpointInit is the client method that builds the Goa endpoint. + EndpointInit string + // HandlerInitDeclaration supplies the server handler constructor name. + HandlerInitDeclaration *codegen.NameDeclaration + // ClientStructDeclaration supplies the client type name used by request builders. + ClientStructDeclaration *codegen.NameDeclaration + // RequestEncoderDeclaration supplies the request encoder name written in HTTP files. + RequestEncoderDeclaration *codegen.NameDeclaration + // RequestDecoderDeclaration supplies the request decoder name written in HTTP files. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseDecoderDeclaration supplies the response decoder name written in HTTP files. + ResponseDecoderDeclaration *codegen.NameDeclaration + // SSE contains event-stream values when the method uses server-sent events. + SSE *JSONRPCSSEData + // ClientWebSocket contains client stream names when the method uses WebSocket. + ClientWebSocket *JSONRPCWebSocketData + // ServerWebSocket contains server stream names when the method uses WebSocket. + ServerWebSocket *JSONRPCWebSocketData + } + + // JSONRPCMethodData contains the service method values written in JSON-RPC files. + JSONRPCMethodData struct { + // Name is the design method name. + Name string + // VarName is the exported Go method name. + VarName string + // EventDeclaration supplies the service event interface name used by + // server-sent-event streams. + EventDeclaration *codegen.NameDeclaration + // Result is the generated service result type name. + Result string + // Idempotent reports whether the client may retry the same call. + Idempotent bool + // Errors lists the retry properties of the method errors. + Errors []JSONRPCMethodErrorData + // ViewedResult contains result-view names when the method returns a viewed result. + ViewedResult *JSONRPCMethodViewedResultData + // ServerStream contains server stream method names when the method streams. + ServerStream *JSONRPCStreamData + // ClientStream contains client stream method names when the method streams. + ClientStream *JSONRPCStreamData + // StreamKind identifies which side sends stream values. + StreamKind expr.StreamKind + // SkipRequestBodyEncodeDecode reports whether the service reads the raw request body. + SkipRequestBodyEncodeDecode bool + // RequestStruct is the service type that carries a raw request body. + RequestStruct string + } + + // JSONRPCMethodErrorData contains the two error values used by client retry code. + JSONRPCMethodErrorData struct { + // ErrName is the service error name. + ErrName string + // Temporary reports whether retrying the call may succeed. + Temporary bool + } + + // JSONRPCMethodViewedResultData contains the result-view fields written in method files. + JSONRPCMethodViewedResultData struct { + JSONRPCViewedResultData + // ViewName is the fixed view name. It is empty when each response selects a view. + ViewName string + } + + // JSONRPCStreamData contains the stream method names written by JSON-RPC files. + JSONRPCStreamData struct { + // Interface is the service stream interface implemented by the generated stream. + Interface string + // VarName is the generated stream implementation type name. + VarName string + // SendName is the method that sends one value. + SendName string + // SendDesc documents SendName. + SendDesc string + // SendWithContextName is the send method that accepts a context. + SendWithContextName string + // SendWithContextDesc documents SendWithContextName. + SendWithContextDesc string + // SendTypeName is the sent service type name. + SendTypeName string + // SendTypeRef is the sent service type reference. + SendTypeRef string + // RecvName is the method that receives one value. + RecvName string + // RecvDesc documents RecvName. + RecvDesc string + // RecvWithContextName is the receive method that accepts a context. + RecvWithContextName string + // RecvWithContextDesc documents RecvWithContextName. + RecvWithContextDesc string + // RecvTypeName is the received service type name. + RecvTypeName string + // RecvTypeRef is the received service type reference. + RecvTypeRef string + // EndpointStruct is the service type passed to a streaming endpoint. + EndpointStruct string + // Kind identifies which side sends stream values. + Kind expr.StreamKind + } + + // JSONRPCPayloadData contains the request values read by JSON-RPC files. + JSONRPCPayloadData struct { + // Ref is the service payload type reference. + Ref string + // Request describes the request body when the payload has one. + Request *JSONRPCRequestData + // IDAttribute is the payload field that receives the JSON-RPC request ID. + IDAttribute string + // IDAttributeRequired reports whether IDAttribute is a value instead of a pointer. + IDAttributeRequired bool + // DecoderReturnValue is returned directly when the server needs no payload constructor. + DecoderReturnValue string + } + + // JSONRPCRequestData contains the request body values read by JSON-RPC files. + JSONRPCRequestData struct { + // ClientBody describes the request body encoded by the client. + ClientBody *JSONRPCBodyData + // ServerBody describes the request body decoded by the server. + ServerBody *JSONRPCBodyData + // PayloadInit builds the service payload from decoded request values. + PayloadInit *InitData + // PayloadTypeName is the Goa name for the payload type. + PayloadTypeName string + // Headers contains the HTTP request headers read by shared JSON code. + Headers []JSONRPCHeaderData + // Cookies contains the HTTP request cookies read by shared JSON code. + Cookies []JSONRPCCookieData + // QueryParams is empty because JSON-RPC parameters are carried in the JSON request. + QueryParams []any + // PathParams is empty because every JSON-RPC method uses the service route. + PathParams []any + // PayloadAttr is the payload field encoded as the JSON request body. + PayloadAttr string + // MustHaveBody reports whether an empty JSON request is invalid. + MustHaveBody bool + // MustValidate reports whether decoded request values require validation. + MustValidate bool + } + + // JSONRPCResultData contains the response values read by JSON-RPC files. + JSONRPCResultData struct { + // Ref is the service result type reference. + Ref string + // Responses contains the successful HTTP responses in design order. + Responses []JSONRPCResponseData + // IDAttribute is the result field that supplies the JSON-RPC response ID. + IDAttribute string + // IDAttributeRequired reports whether IDAttribute is a value instead of a pointer. + IDAttributeRequired bool + // View is the default result view selected by the design. + View string + } + + // JSONRPCResponseData contains one HTTP response read by JSON-RPC files. + JSONRPCResponseData struct { + // StatusCode is the JSON-RPC code used for a designed error. + StatusCode string + // Code is the numeric JSON-RPC code used for a designed error. + Code int + // Headers contains fields decoded from HTTP response headers. + Headers []JSONRPCHeaderData + // Cookies contains fields decoded from HTTP response cookies. + Cookies []JSONRPCCookieData + // ServerBody contains the response bodies written by the server. + ServerBody []JSONRPCBodyData + // ClientBody describes the response body read by the client. + ClientBody *JSONRPCBodyData + // ResultInit builds the service result or error from decoded response values. + ResultInit *InitData + // MustValidate reports whether decoded header or cookie values require validation. + MustValidate bool + } + + // JSONRPCErrorGroupData contains errors that use the same JSON-RPC code. + JSONRPCErrorGroupData struct { + // StatusCode is the JSON-RPC code shared by Errors. + StatusCode string + // Errors contains the designed errors for StatusCode. + Errors []JSONRPCErrorData + } + + // JSONRPCErrorData contains one designed error and its response conversion. + JSONRPCErrorData struct { + // Name is the design error name. + Name string + // Ref is the generated service error type reference. + Ref string + // Response describes the encoded error data. + Response JSONRPCResponseData + } + + // JSONRPCRouteData contains one HTTP path and verb used for JSON-RPC calls. + JSONRPCRouteData struct { + // Verb is the uppercase HTTP method. + Verb string + // Path is the full request path. + Path string + } + + // JSONRPCSSEData contains the event fields read by JSON-RPC stream files. + JSONRPCSSEData struct { + // StructDeclaration supplies the server stream type name. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration supplies the client stream interface name. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration supplies the client stream implementation name. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client stream constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // EventTypeRef is the service result type carried by each event. + EventTypeRef string + // RequestIDField is the payload field that receives Last-Event-ID. + RequestIDField string + } + + // JSONRPCWebSocketData contains the stream names read by JSON-RPC WebSocket files. + JSONRPCWebSocketData struct { + // VarName is the generated stream implementation type name. + VarName string + // VarDeclaration supplies the stream implementation type name. + VarDeclaration *codegen.NameDeclaration + // SendName is the method that sends a stream value. + SendName string + // SendDesc documents SendName. + SendDesc string + // SendWithContextName is the send method that accepts a context. + SendWithContextName string + // SendWithContextDesc documents SendWithContextName. + SendWithContextDesc string + // SendTypeName is the sent service type name. + SendTypeName string + // SendTypeRef is the sent service type reference. + SendTypeRef string + // RecvName is the method that receives a stream value. + RecvName string + // RecvDesc documents RecvName. + RecvDesc string + // RecvWithContextName is the receive method that accepts a context. + RecvWithContextName string + // RecvWithContextDesc documents RecvWithContextName. + RecvWithContextDesc string + // RecvTypeName is the received service type name. + RecvTypeName string + // RecvTypeRef is the received service type reference. + RecvTypeRef string + } + + // JSONRPCBodyData contains only the JSON body fields read by JSON-RPC files. + JSONRPCBodyData struct { + // VarName is the generated body type name. + VarName string + // Ref is the generated body type reference. + Ref string + // ValidateRef is the validation statement run after decoding. + ValidateRef string + // Init converts between the body and the service value. + Init *InitData + } + + // JSONRPCElementData contains one header or cookie value read from a response. + JSONRPCElementData struct { + // Name is the service attribute name used in errors. + Name string + // VarName is the local Go variable name. + VarName string + // TypeName is the Goa primitive or array name. + TypeName string + // ElemTypeName is the Goa name of an array element. It is empty for non-arrays. + ElemTypeName string + // ElemTypeRef is the generated Go reference for an array element. It is empty for non-arrays. + ElemTypeRef string + // TypeRef is the generated Go type reference. + TypeRef string + // Pointer reports whether TypeRef is a pointer. + Pointer bool + // FieldName is the service result field that supplies the value on the server. + FieldName string + // FieldPointer reports whether FieldName holds a pointer. + FieldPointer bool + // IsAliased reports whether FieldName uses a user-defined primitive type. + IsAliased bool + // Required reports whether the response must contain the value. + Required bool + // DefaultValue is written when the response omits an optional value. + DefaultValue any + // Validate contains the validation code run after conversion. + Validate string + // HTTPName is the header or cookie name sent over HTTP. + HTTPName string + // StringSlice reports whether the value is an array of strings. + StringSlice bool + // Slice reports whether the value is an array. + Slice bool + } + + // JSONRPCHeaderData contains one response header read by JSON-RPC clients. + JSONRPCHeaderData struct { + JSONRPCElementData + // CanonicalName is the standard HTTP spelling, such as "Content-Type". + CanonicalName string + } + + // JSONRPCCookieData contains one response cookie read by JSON-RPC clients. + JSONRPCCookieData struct { + JSONRPCElementData + // MaxAge is the cookie max-age text written to generated code. + MaxAge string + // Path is the cookie path written to generated code. + Path string + // Domain is the cookie domain written to generated code. + Domain string + // Secure reports whether the Secure cookie flag is set. + Secure bool + // HTTPOnly reports whether the HttpOnly cookie flag is set. + HTTPOnly bool + // SameSite is the cookie SameSite text written to generated code. + SameSite string + } + + // ViewedResultSnapshot holds a separate copy of the result views and HTTP response bodies + // used by one JSON-RPC method. + ViewedResultSnapshot struct { + // Variable reports whether each response carries its selected view. + Variable bool + // FixedView is the view selected in the generated method when it cannot vary. + FixedView string + // Service contains the viewed-result names written in JSON-RPC files. + Service JSONRPCViewedResultData + // Representations contains one copied response conversion for each legal view. + Representations []ViewedRepresentationSnapshot + } + + // JSONRPCViewedResultData contains the service package names and functions + // needed to validate and convert one viewed result. + JSONRPCViewedResultData struct { + // FullRef is the complete Go reference to the viewed-result type. + FullRef string + // VarName is the viewed-result type name without its package. + VarName string + // ViewsPkg is the import name of the generated views package. + ViewsPkg string + // Validate is the function that validates the viewed result. + Validate *codegen.NameDeclaration + // ResultInit converts a viewed result into the service result. + ResultInit *codegen.NameDeclaration + // Init converts the service result into a viewed result. + Init *codegen.NameDeclaration + // IsCollection reports whether the viewed result is a collection. + IsCollection bool + } + + // ViewedRepresentationSnapshot holds copied client and server body data + // for one legal result view. + ViewedRepresentationSnapshot struct { + // View is the result view carried by the response. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the server converts the complete projected result. + ResultAttr string + // ServerBody describes the value encoded by the server. It is nil when a + // successful response carries only headers or cookies. + ServerBody *JSONRPCBodyData + // ClientBody describes the value decoded by the client. It is nil when a + // successful response carries only headers or cookies. + ClientBody *JSONRPCBodyData + // ResultInit describes how the decoded body rebuilds the service result. + ResultInit InitData + // Headers contains copied response header mappings. + Headers []JSONRPCHeaderData + // Cookies contains copied response cookie mappings. + Cookies []JSONRPCCookieData + } + + // viewedMethodKey identifies one service method without joining its names. + viewedMethodKey struct { + service string + method string + } + + // viewedConstructorKey identifies one view-specific client result function + // in the input design. + viewedConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + response *expr.HTTPResponseExpr + view string + } + + // viewedConstructorOrder provides a stable total order for colliding + // constructor preferences in one generated client package. + viewedConstructorOrder struct { + transport string + service string + method string + status int + tagName string + tagValue string + view string + role string + } + + // plannedWireTypes stores each copied request and response field with the + // client or server package that defines it. Plan.Link uses the same copies + // after Goa assigns every generated package name. + plannedWireTypes struct { + bodies shapedBodies + server *wireTypeCatalog + client *wireTypeCatalog + streamPayloads map[*expr.HTTPEndpointExpr]*wireTypeRecord + } + + // transportKind records whether a plan writes HTTP or JSON-RPC files. + transportKind uint8 +) + +const ( + httpTransport transportKind = iota + 1 + jsonrpcTransport +) + +// NewPlans submits the Go names used by every ordinary HTTP design in inputs. +// All inputs are required so two designs that write the same package resolve +// name conflicts together. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, httpTransport, inputs) +} + +// NewJSONRPCPlans requests the HTTP body, encoder, and decoder names used by +// every JSON-RPC design in inputs. JSON-RPC writes its files from these plans. +func NewJSONRPCPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, jsonrpcTransport, inputs) +} + +// MatchesHTTP reports whether NewPlans created p for root and servicePlan. +func (p *Plan) MatchesHTTP(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == httpTransport && p.root == root && p.servicePlan == servicePlan +} + +// MatchesJSONRPC reports whether NewJSONRPCPlans created p for root and +// servicePlan. +func (p *Plan) MatchesJSONRPC(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == jsonrpcTransport && p.root == root && p.servicePlan == servicePlan +} + +// Link reads the assigned package names, builds data for each HTTP service once, and +// builds every file returned by this plan. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("HTTP plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("HTTP plan is already linked") + } + return p.link() +} + +// ServerFiles returns the HTTP server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the HTTP client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server request and response type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverTypes +} + +// ClientTypeFiles returns the client request and response type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientTypes +} + +// PathFiles returns the URL path helper files built by Link. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.paths +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ExampleServerFiles returns the runnable HTTP server files built by Link. +func (p *Plan) ExampleServerFiles() []*codegen.File { + p.requireLinked() + return p.example +} + +// ExampleCLIFiles returns the runnable HTTP client files built by Link. +func (p *Plan) ExampleCLIFiles() []*codegen.File { + p.requireLinked() + return p.exampleCLI +} + +// CombinedExampleServerFiles returns new runnable server files containing this +// plan's JSON-RPC services and application's ordinary HTTP services. Pass nil +// when the design has no ordinary HTTP services. +func (p *Plan) CombinedExampleServerFiles(application *Plan) []*codegen.File { + p.requireLinked() + if p.transport != jsonrpcTransport { + panic("combined example servers require a JSON-RPC HTTP plan") + } + var applicationServices *ServicesData + if application != nil { + application.requireLinked() + if application.transport != httpTransport || application.root != p.root || application.servicePlan != p.servicePlan { + panic("ordinary HTTP and JSON-RPC plans must use the same design root and service plan") + } + applicationServices = application.services + } + return combinedExampleServerFiles(p.services, applicationServices) +} + +// ViewedResult returns copied HTTP response data for the named method's result +// views. The second result is false when the method does not use result views. +func (p *Plan) ViewedResult(serviceName, methodName string) (ViewedResultSnapshot, bool) { + p.requireLinked() + viewed, ok := p.viewed[viewedMethodKey{service: serviceName, method: methodName}] + if !ok { + return ViewedResultSnapshot{}, false + } + representations := make([]ViewedRepresentationSnapshot, len(viewed.representations)) + for index, planned := range viewed.representations { + representation := planned.data + if representation.ResultInit == nil { + panic("viewed result representation is missing its result constructor") + } + representations[index] = ViewedRepresentationSnapshot{ + View: representation.View, + ResultAttr: representation.ResultAttr, + ServerBody: copyJSONRPCBody(representation.ServerBody), + ClientBody: copyJSONRPCBody(representation.ClientBody), + ResultInit: *copyInitData(representation.ResultInit), + Headers: copyJSONRPCHeaders(planned.headers), + Cookies: copyJSONRPCCookies(planned.cookies), + } + } + return ViewedResultSnapshot{ + Variable: viewed.variable, + FixedView: viewed.fixedView, + Service: copyJSONRPCViewedResult(viewed.service), + Representations: representations, + }, true +} + +// JSONRPCService returns copied HTTP information used to write one JSON-RPC +// service. The second result is false when the plan has no service with name. +func (p *Plan) JSONRPCService(name string) (JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.jsonServices[name] + if !ok { + return JSONRPCServiceSnapshot{}, false + } + endpoints := make([]JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + for index, endpoint := range planned.data.Endpoints { + endpoints[index] = copyJSONRPCEndpoint(endpoint) + } + fileImports := make(map[string][]*codegen.ImportSpec, len(planned.fileImports)) + for filePath, imports := range planned.fileImports { + fileImports[filePath] = cloneImportSpecs(imports) + } + var viewImport *codegen.ImportSpec + if serviceHasViewedResult(planned.data, nil) { + viewImport = planned.services.ViewImport(planned.data.Service.Name) + } + return JSONRPCServiceSnapshot{ + Service: JSONRPCServiceData{ + Name: planned.data.Service.Name, + StructName: planned.data.Service.StructName, + EndpointsDeclaration: planned.data.Service.EndpointsDeclaration, + StreamDeclaration: planned.data.Service.StreamDeclaration, + MethodNamesDeclaration: planned.data.Service.MethodNamesDeclaration, + PkgName: planned.data.Service.PkgName, + PathName: planned.data.Service.PathName, + }, + Endpoints: endpoints, + ClientStructDeclaration: planned.data.ClientStructDeclaration, + ClientInitDeclaration: planned.data.ClientInitDeclaration, + ServerStructDeclaration: planned.data.ServerStructDeclaration, + ServerInitDeclaration: planned.data.ServerInitDeclaration, + MountServerDeclaration: planned.data.MountServerDeclaration, + ServerService: planned.data.ServerService, + serviceImport: cloneImportSpec(planned.services.ServiceImport(planned.data.Service.Name)), + viewImport: cloneImportSpec(viewImport), + fileImports: fileImports, + clientCodec: planned.clientCodec, + serverCodec: planned.serverCodec, + }, true +} + +// ServiceImport returns the import for the generated Goa service package. +func (p JSONRPCServiceSnapshot) ServiceImport() *codegen.ImportSpec { + return cloneImportSpec(p.serviceImport) +} + +// ViewImport returns the import for the generated result-view package. +func (p JSONRPCServiceSnapshot) ViewImport() *codegen.ImportSpec { + if p.viewImport == nil { + panic("JSON-RPC service does not use result views") + } + return cloneImportSpec(p.viewImport) +} + +// FileImports returns a new copy of the service-type imports needed by one +// JSON-RPC output file. It rejects paths that this service does not generate. +func (p JSONRPCServiceSnapshot) FileImports(filePath string) []*codegen.ImportSpec { + imports, ok := p.fileImports[strings.ReplaceAll(filePath, "\\", "/")] + if !ok { + panic("JSON-RPC file is not part of this HTTP service plan") + } + return cloneImportSpecs(imports) +} + +// ClientCodecFile returns a new client encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ClientCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.clientCodec) +} + +// ServerCodecFile returns a new server encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ServerCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.serverCodec) +} + +// planImports requests every import name written directly in an HTTP file. +// This happens before generated service packages receive their import names. +func planImports(generation *codegen.Generation, transport transportKind) error { imports := []*codegen.ImportSpec{ codegen.SimpleImport("bufio"), codegen.SimpleImport("bytes"), @@ -51,20 +820,22 @@ func Plan(generation *codegen.Generation) error { if !ok { continue } - for _, service := range design.API.HTTP.Services { + expressions := transportExpressions(design, transport) + dir := transportDirectory(transport) + for _, service := range expressions.Services { pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) packageName := strings.ToLower(codegen.Goify(service.Name(), false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "http", pathName, "client"))); err != nil { + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), dir, pathName, "client"))); err != nil { return err } - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "http", pathName, "server"))); err != nil { + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), dir, pathName, "server"))); err != nil { return err } } - if len(design.API.HTTP.Services) > 0 { + if len(expressions.Services) > 0 { for _, server := range design.API.Servers { serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) - if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "http", "cli", serverName))); err != nil { + if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), dir, "cli", serverName))); err != nil { return err } } @@ -72,3 +843,439 @@ func Plan(generation *codegen.Generation) error { } return nil } + +// newPlans validates the full input set and submits names for every plan. +func newPlans(generation *codegen.Generation, transport transportKind, inputs []PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("HTTP plans require a generation") + } + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(transportExpressions(root, transport).Services) > 0 { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil { + return nil, fmt.Errorf("HTTP plan requires a prepared design root") + } + if input.Service == nil { + return nil, fmt.Errorf("HTTP plan requires a service plan") + } + if input.Service.Root() != input.Root { + return nil, fmt.Errorf("%s root does not match its service plan root", transportLabel(transport)) + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("%s root %p is not a transport root owned by generation", transportLabel(transport), input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("%s root %p is planned more than once", transportLabel(transport), input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("%s planning requires all %d transport roots, got %d", transportLabel(transport), len(owned), len(inputs)) + } + if err := planImports(generation, transport); err != nil { + return nil, err + } + packages := make(map[string]*wireTypeCatalog) + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan, err := newPlan(generation, transport, input, packages) + if err != nil { + return nil, err + } + plans[index] = plan + } + for _, catalog := range packages { + if err := catalog.Declare(); err != nil { + return nil, err + } + } + for _, plan := range plans { + for _, serviceTypes := range plan.wireTypes { + for endpoint, record := range serviceTypes.streamPayloads { + plan.streams[endpoint] = record.constructor + } + } + } + return plans, nil +} + +// newPlan records one design's HTTP services and submits every function name +// that its generated client and server packages will define. +func newPlan(generation *codegen.Generation, transport transportKind, input PlanInput, packages map[string]*wireTypeCatalog) (*Plan, error) { + plan := &Plan{ + root: input.Root, + servicePlan: input.Service, + generation: generation, + transport: transport, + constructors: make(map[viewedConstructorKey]*codegen.NameDeclaration), + payloads: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + streams: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + errors: make(map[*expr.HTTPErrorExpr]*codegen.NameDeclaration), + wireTypes: make(map[*expr.HTTPServiceExpr]*plannedWireTypes), + symbols: make(map[*expr.HTTPServiceExpr]*httpSymbols), + cliParsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + } + expressions := transportExpressions(input.Root, transport) + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + clientPath := path.Join(generation.GenPkg(), dir, codegen.SnakeCase(transportService.Name()), "client") + clientPackage, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + serverPath := path.Join(generation.GenPkg(), dir, codegen.SnakeCase(transportService.Name()), "server") + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + clientCatalog := packages[clientPath] + if clientCatalog == nil { + clientCatalog = newWireTypeCatalog(clientPackage) + packages[clientPath] = clientCatalog + } + serverCatalog := packages[serverPath] + if serverCatalog == nil { + serverCatalog = newWireTypeCatalog(serverPackage) + packages[serverPath] = serverCatalog + } + planned := &plannedWireTypes{ + server: serverCatalog, + client: clientCatalog, + streamPayloads: make(map[*expr.HTTPEndpointExpr]*wireTypeRecord), + } + collectPlannedWireTypes(transportService, planned, input.Service) + plan.wireTypes[transportService] = planned + symbols, err := collectHTTPSymbols(plan, transportService, clientPackage, serverPackage) + if err != nil { + return nil, err + } + plan.symbols[transportService] = symbols + for _, endpoint := range transportService.HTTPEndpoints { + order := viewedConstructorOrder{ + transport: dir, + service: transportService.Name(), + method: endpoint.Name(), + } + if needInit(endpoint.MethodExpr.Payload.Type) { + declaration, err := declareHTTPConstructor(serverPackage, endpointPayloadConstructorName(endpoint), order.withRole("payload")) + if err != nil { + return nil, err + } + plan.payloads[endpoint] = declaration + } + if endpoint.UsesWebSocket() && endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) && planned.streamPayloads[endpoint] == nil { + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(endpoint.MethodExpr.StreamingPayload.Type.Name(), true) + declaration, err := declareHTTPConstructor(serverPackage, preferred, order.withRole("streaming payload")) + if err != nil { + return nil, err + } + plan.streams[endpoint] = declaration + } + if needInit(endpoint.MethodExpr.Result.Type) { + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) + noTagSeen := false + for _, response := range endpoint.Responses { + if response.Tag[0] == "" { + if noTagSeen { + continue + } + noTagSeen = true + } + views := []string{""} + body := planned.bodies.response(response) + _, explicitBody := body.Meta["origin:attribute"] + if viewed && !explicitBody && clientResponseViewNameExpr(endpoint, resultType) == "" && (endpoint.UsesSSE() || endpoint.IsJSONRPC()) { + views = make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + } + for _, view := range views { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + responseOrder := order.withRole("result") + responseOrder.status = response.StatusCode + responseOrder.tagName = response.Tag[0] + responseOrder.tagValue = response.Tag[1] + responseOrder.view = view + declaration, err := declareHTTPConstructor(clientPackage, viewedResultConstructorName(endpoint, response, view), responseOrder) + if err != nil { + return nil, err + } + plan.constructors[key] = declaration + } + } + } + for _, transportError := range endpoint.HTTPErrors { + if !needInit(transportError.Type) { + continue + } + errorOrder := order.withRole("error") + errorOrder.status = transportError.Response.StatusCode + errorOrder.tagName = transportError.Name + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(transportError.ErrorExpr.Name, true) + declaration, err := declareHTTPConstructor(clientPackage, preferred, errorOrder) + if err != nil { + return nil, err + } + plan.errors[transportError] = declaration + } + } + } + for _, server := range input.Root.API.Servers { + serverPath := path.Join(generation.GenPkg(), dir, "cli", codegen.SnakeCase(codegen.Goify(server.Name, true))) + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + var commands []cli.CommandDeclarationInput + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil || len(transportService.HTTPEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: serviceName} + for _, endpoint := range transportService.HTTPEndpoints { + command.Methods = append(command.Methods, endpoint.MethodExpr.Name) + } + commands = append(commands, command) + } + parser, err := cli.DeclareParser(serverPackage, dir, input.Root.API.Name, server.Name, commands) + if err != nil { + return nil, err + } + plan.cliParsers[server] = parser + } + return plan, nil +} + +// link reads the generated service names, builds data for every selected service once, +// and stores all files that the public methods on Plan return. +func (p *Plan) link() error { + serviceData := p.servicePlan.Services() + if serviceData.Root != p.root { + return fmt.Errorf("HTTP plan root does not match linked service plan root") + } + expressions := transportExpressions(p.root, p.transport) + services := newServicesData(serviceData, expressions) + services.jsonrpc = p.transport == jsonrpcTransport + services.viewedResultConstructors = p.constructors + services.payloadConstructors = p.payloads + services.streamConstructors = p.streams + services.errorConstructors = p.errors + services.plannedWireTypes = p.wireTypes + services.plannedSymbols = p.symbols + services.cliParsers = p.cliParsers + for _, transportService := range services.Expressions.Services { + if services.ServicesData.Get(transportService.Name()) == nil { + return fmt.Errorf("HTTP service %q has no linked service model", transportService.Name()) + } + services.HTTPData[transportService.Name()] = services.analyze(transportService) + } + p.services = services + p.viewed = make(map[viewedMethodKey]*viewedResultPlan) + p.jsonServices = make(map[string]*jsonRPCServicePlan, len(services.HTTPData)) + for serviceName, serviceData := range services.HTTPData { + transportService := expressions.Service(serviceName) + if transportService == nil { + return fmt.Errorf("HTTP service %q has no transport expression", serviceName) + } + if len(transportService.HTTPEndpoints) != len(serviceData.Endpoints) { + return fmt.Errorf("HTTP service %q endpoint analysis does not match its design", serviceName) + } + jsonService := &jsonRPCServicePlan{ + data: serviceData, + services: services, + fileImports: make(map[string][]*codegen.ImportSpec), + clientCodec: clientEncodeDecodeFile(transportService, services), + serverCodec: serverEncodeDecodeFile(transportService, services), + } + if p.transport == jsonrpcTransport { + jsonService.prepareFileImports(transportService, services) + } + p.jsonServices[serviceName] = jsonService + for _, endpoint := range serviceData.Endpoints { + if endpoint.Method.ViewedResult == nil || endpoint.SSE == nil && !endpoint.IsJSONRPC { + continue + } + var representations []viewedRepresentationPlan + for _, response := range endpoint.Result.Responses { + for _, representation := range response.ViewedRepresentations { + representations = append(representations, viewedRepresentationPlan{ + data: representation, + headers: response.Headers, + cookies: response.Cookies, + }) + } + } + variable := endpoint.Method.ViewedResult.ViewName == "" + if len(representations) == 0 { + return fmt.Errorf("HTTP viewed method %q has no response representations", endpoint.Method.Name) + } + p.viewed[viewedMethodKey{service: serviceName, method: endpoint.Method.Name}] = &viewedResultPlan{ + variable: variable, + fixedView: endpoint.Method.ViewedResult.ViewName, + service: endpoint.Method.ViewedResult, + representations: representations, + } + } + } + if p.transport == httpTransport { + p.server = serverFiles(services) + p.client = clientFiles(services) + p.example = exampleServerFiles(services) + } + p.exampleCLI = exampleCLIFiles(services) + p.serverTypes = serverTypeFiles(services) + p.clientTypes = clientTypeFiles(services) + p.paths = pathFiles(services) + p.clientCLI = clientCLIFiles(services) + return nil +} + +// transportExpressions returns the HTTP or JSON-RPC designs requested +// by the caller. +func transportExpressions(root *expr.RootExpr, transport transportKind) *expr.HTTPExpr { + if transport == jsonrpcTransport { + return &root.API.JSONRPC.HTTPExpr + } + return root.API.HTTP +} + +// transportDirectory returns the output directory for HTTP or JSON-RPC files. +func transportDirectory(transport transportKind) string { + if transport == jsonrpcTransport { + return "jsonrpc" + } + return "http" +} + +// transportLabel returns "HTTP" or "JSON-RPC" for error messages. +func transportLabel(transport transportKind) string { + if transport == jsonrpcTransport { + return "JSON-RPC" + } + return "HTTP" +} + +// requireLinked rejects file and service access before Link builds them. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("HTTP render model requested before plan linking") + } +} + +// prepareFileImports computes the service-type imports for every JSON-RPC file +// that this service can generate. JSON-RPC later reads these lists without +// walking the HTTP endpoint types again. +func (p *jsonRPCServicePlan) prepareFileImports(transportService *expr.HTTPServiceExpr, services *ServicesData) { + var all, sse, websocket []*expr.AttributeExpr + for index, endpoint := range transportService.HTTPEndpoints { + references := serviceReferenceAttributes(endpoint) + all = append(all, references...) + switch { + case p.data.Endpoints[index].SSE != nil: + sse = append(sse, references...) + case IsWebSocketEndpoint(p.data.Endpoints[index]): + websocket = append(websocket, references...) + } + } + servicePath := p.data.Service.PathName + clientPackage := path.Join(services.GenPkg(), "jsonrpc", servicePath, "client") + serverPackage := path.Join(services.GenPkg(), "jsonrpc", servicePath, "server") + clientAll := services.AttributeImports(clientPackage, all...) + serverAll := services.AttributeImports(serverPackage, all...) + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "client.go")] = clientAll + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "server.go")] = serverAll + if p.clientCodec != nil { + p.fileImports[p.clientCodec.Path] = clientAll + } + if p.serverCodec != nil { + p.fileImports[p.serverCodec.Path] = serverAll + } + if len(sse) > 0 { + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "stream.go")] = services.AttributeImports(clientPackage, sse...) + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "sse.go")] = services.AttributeImports(serverPackage, sse...) + } + if len(websocket) > 0 { + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "websocket.go")] = services.AttributeImports(clientPackage, websocket...) + if len(sse) == 0 { + p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "websocket.go")] = services.AttributeImports(serverPackage, websocket...) + } + } +} + +// cloneImportSpecs copies an import list and each import value so a caller can +// change both without changing the list stored by the HTTP plan. +func cloneImportSpecs(source []*codegen.ImportSpec) []*codegen.ImportSpec { + result := make([]*codegen.ImportSpec, len(source)) + for index, spec := range source { + copy := *spec + result[index] = © + } + return result +} + +// cloneImportSpec copies one import so callers can change its path or name +// without changing the import stored by the HTTP plan. +func cloneImportSpec(source *codegen.ImportSpec) *codegen.ImportSpec { + if source == nil { + return nil + } + copy := *source + return © +} + +// viewedResultConstructorName returns the preferred constructor spelling for +// one client response body selected by a result view. +func viewedResultConstructorName(endpoint *expr.HTTPEndpointExpr, response *expr.HTTPResponseExpr, view string) string { + return "New" + codegen.Goify(endpoint.Name(), true) + "Result" + codegen.Goify(view, true) + codegen.Goify(http.StatusText(response.StatusCode), true) +} + +// endpointPayloadConstructorName returns the preferred server function name +// that builds one method payload from its HTTP request values. +func endpointPayloadConstructorName(endpoint *expr.HTTPEndpointExpr) string { + return "New" + codegen.Goify(endpoint.Name(), true) + "Payload" +} + +// declareHTTPConstructor submits one constructor name to the generated package +// that will contain both its definition and calls. +func declareHTTPConstructor(pkg *codegen.GeneratedPackage, preferred string, order viewedConstructorOrder) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// withRole returns an ordering value for one kind of endpoint constructor. +func (o viewedConstructorOrder) withRole(role string) viewedConstructorOrder { + o.role = role + return o +} + +// ComparePackageName orders view constructors by design service, method, +// response status, and view so input iteration order cannot change names. +func (o viewedConstructorOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(viewedConstructorOrder) + for _, compared := range []int{ + cmp.Compare(o.transport, right.transport), + cmp.Compare(o.service, right.service), + cmp.Compare(o.method, right.method), + cmp.Compare(o.role, right.role), + cmp.Compare(o.status, right.status), + cmp.Compare(o.tagName, right.tagName), + cmp.Compare(o.tagValue, right.tagValue), + cmp.Compare(o.view, right.view), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 9a7a33aa3e..0a87a19e9a 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -1,14 +1,16 @@ -// This file verifies HTTP import planning participates in the shared -// generation lifecycle before service aliases are frozen. +// This file verifies that HTTP package names are requested before Goa assigns +// them and that files are built only after service names are available. package codegen import ( + "fmt" "path" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" @@ -25,7 +27,8 @@ func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + _, err = NewPlans(generation) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() @@ -38,11 +41,57 @@ func TestPlanRejectsFrozenGeneration(t *testing.T) { require.NoError(t, err) require.NoError(t, generation.Freeze()) - require.Error(t, Plan(generation)) + _, err = NewPlans(generation) + require.Error(t, err) +} + +// TestNewPlansRequiresEveryHTTPRoot proves package names cannot be requested +// from only some of the HTTP designs in one generation. +func TestNewPlansRequiresEveryHTTPRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + services, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + + _, err = NewPlans(generation, PlanInput{Root: first, Service: services[0]}) + require.EqualError(t, err, "HTTP planning requires all 2 transport roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot proves one service plan cannot be paired +// with the same design twice in one call. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/calc") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + _, err = NewPlans(generation, + PlanInput{Root: root, Service: servicePlan}, + PlanInput{Root: root, Service: servicePlan}, + ) + require.EqualError(t, err, fmt.Sprintf("HTTP root %p is planned more than once", root)) } // TestPlanReservesGeneratedHTTPPackages verifies that client, server, and CLI -// packages receive aliases from the generation catalog before it freezes. +// packages receive distinct import names before files are written. func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { root := expr.RunDSL(t, func() { for _, name := range []string{"Foo", "Fooc", "Foosvr"} { @@ -57,7 +106,8 @@ func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() @@ -72,3 +122,490 @@ func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { require.NotEqual(t, services.ServiceImport("Foosvr").Name, server.Name) require.NotEmpty(t, cli.Name) } + +// TestPlanLinkEagerlyRetainsHTTPFiles proves Link analyzes every HTTP service +// once and decides which generated files exist before callers request them. +func TestPlanLinkEagerlyRetainsHTTPFiles(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plan := plans[0] + require.NoError(t, plan.Link()) + _, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + require.NotEmpty(t, plan.ExampleServerFiles()) + require.NotEmpty(t, plan.ExampleCLIFiles()) + serverCount := len(plan.ServerFiles()) + clientCount := len(plan.ClientFiles()) + + root.API.HTTP.Services = append(root.API.HTTP.Services, &expr.HTTPServiceExpr{}) + require.Len(t, plan.ServerFiles(), serverCount) + require.Len(t, plan.ClientFiles(), clientCount) +} + +// TestJSONRPCCodecFilesAreIndependent checks that the JSON-RPC file writer can +// change a returned encoder and decoder file without changing a later copy. +func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "example.com/types") + dsl.Attribute("number", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(value) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + service, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + stored := plans[0].jsonServices["Calc"] + assertIndependentCodecFile(t, stored.clientCodec, service.ClientCodecFile) + assertIndependentCodecFile(t, stored.serverCodec, service.ServerCodecFile) + + clientPath := stored.clientCodec.Path + imports := service.FileImports(clientPath) + require.NotEmpty(t, imports) + original := *imports[0] + imports[0].Path = "changed.example/package" + freshImports := service.FileImports(clientPath) + require.Equal(t, original, *freshImports[0]) + require.PanicsWithValue(t, "JSON-RPC file is not part of this HTTP service plan", func() { + service.FileImports("gen/jsonrpc/calc/client/unknown.go") + }) + + service.Service.Name = "changed" + service.Endpoints[0].ServiceName = "changed" + service.Endpoints[0].Payload.Request.Headers = append( + service.Endpoints[0].Payload.Request.Headers, + JSONRPCHeaderData{CanonicalName: "Changed"}, + ) + fresh, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + require.Equal(t, "Calc", fresh.Service.Name) + require.Equal(t, "Calc", fresh.Endpoints[0].ServiceName) + require.Empty(t, fresh.Endpoints[0].Payload.Request.Headers) +} + +// TestViewedResultSnapshotsPreserveMissingBodies checks that a successful +// response containing only a mapped header keeps both body values absent. It +// also changes the returned header and confirms a later copy is unchanged. +func TestViewedResultSnapshotsPreserveMissingBodies(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.header-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { dsl.Attribute("id") }) + }) + dsl.Service("Headers", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { dsl.Header("id") }) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + viewed, ok := plans[0].ViewedResult("Headers", "Fetch") + require.True(t, ok) + require.Len(t, viewed.Representations, 1) + require.Nil(t, viewed.Representations[0].ServerBody) + require.Nil(t, viewed.Representations[0].ClientBody) + require.Len(t, viewed.Representations[0].Headers, 1) + originalHeader := viewed.Representations[0].Headers[0].CanonicalName + viewed.Representations[0].Headers[0].CanonicalName = "Changed" + fresh, ok := plans[0].ViewedResult("Headers", "Fetch") + require.True(t, ok) + require.Equal(t, originalHeader, fresh.Representations[0].Headers[0].CanonicalName) +} + +// TestViewedResultCopiesBodyFieldSelection checks that JSON-RPC receives the +// Go field selected by Body("value"). An empty field keeps the whole-result +// body constructor responsible for the server conversion. +func TestViewedResultCopiesBodyFieldSelection(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.body-field", func() { + dsl.TypeName("BodyField") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Field", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { dsl.Body("value") }) + }) + }) + dsl.Method("Whole", func() { + dsl.Result(result) + dsl.JSONRPC(func() { dsl.Response(dsl.StatusOK) }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + field, ok := plans[0].ViewedResult("Values", "Field") + require.True(t, ok) + require.NotEmpty(t, field.Representations) + for _, representation := range field.Representations { + require.Equal(t, "Value", representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.Nil(t, representation.ServerBody.Init) + } + + whole, ok := plans[0].ViewedResult("Values", "Whole") + require.True(t, ok) + require.NotEmpty(t, whole.Representations) + for _, representation := range whole.Representations { + require.Empty(t, representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.NotNil(t, representation.ServerBody.Init) + } +} + +// TestEndpointConstructorsUsePackageDeclarations checks that request payload, +// response result, and error result functions all use the names chosen before +// files are written. +func TestEndpointConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Error("BadInput", func() { dsl.Attribute("message", dsl.String) }) + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.Error("BadInput") + dsl.HTTP(func() { + dsl.POST("/add") + dsl.Response("BadInput", dsl.StatusBadRequest) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := plans[0].services.Get("Calc").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit.Declaration) + require.Equal(t, endpoint.Payload.Request.PayloadInit.Declaration.Name(), endpoint.Payload.Request.PayloadInit.Name) + require.NotNil(t, endpoint.Result.Responses[0].ResultInit.Declaration) + require.Equal(t, endpoint.Result.Responses[0].ResultInit.Declaration.Name(), endpoint.Result.Responses[0].ResultInit.Name) + require.NotNil(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration) + require.Equal(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration.Name(), endpoint.Errors[0].Errors[0].Response.ResultInit.Name) +} + +// TestHTTPTypeAndConstructorNamesShareOnePackage checks a body type and a +// payload constructor that request the same spelling. Goa must give them +// different names, and generated definitions and calls must use those names. +func TestHTTPTypeAndConstructorNamesShareOnePackage(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("NewAddPayload", func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(payload) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + request := plans[0].services.Get("Calc").Endpoints[0].Payload.Request + require.NotEqual(t, request.ServerBody.Name, request.PayloadInit.Name) + definitions := renderedFiles(t, plans[0].ServerTypeFiles()) + calls := renderedFiles(t, plans[0].ServerFiles()) + require.Contains(t, definitions, "type "+request.ServerBody.Name+" ") + require.Contains(t, definitions, "func "+request.PayloadInit.Name+"(") + require.Contains(t, calls, request.PayloadInit.Name+"(") +} + +// TestNewPlansAssignsNamesAcrossRoots checks two designs whose service names +// resolve to the same generated directory. NewPlans must submit both sets of +// function names together so definitions and calls remain distinct. +func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { + makeRoot := func(serviceName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + dsl.Service(serviceName, func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + } + first := makeRoot("Foo Bar") + second := makeRoot("Foo-Bar") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + firstService := plans[0].services.Get("Foo Bar") + secondService := plans[1].services.Get("Foo-Bar") + firstInit := firstService.Endpoints[0].Payload.Request.PayloadInit + secondInit := secondService.Endpoints[0].Payload.Request.PayloadInit + require.NotEqual(t, firstInit.Name, secondInit.Name) + require.Equal(t, plans[0].ServerTypeFiles()[0].Path, plans[1].ServerTypeFiles()[0].Path) + for index, init := range []*InitData{firstInit, secondInit} { + definitions := renderedFiles(t, plans[index].ServerTypeFiles()) + calls := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, definitions, "func "+init.Name+"(") + require.Contains(t, calls, init.Name+"(") + } +} + +// TestHTTPHelperDefinitionsUseAssignedNames checks two designs that write the +// same server package. File helpers and mixed-result stream helpers must define +// the same names that their call sites use. +func TestHTTPHelperDefinitionsUseAssignedNames(t *testing.T) { + makeRoot := func(serviceName, typePrefix string) *expr.RootExpr { + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) + } + first := makeRoot("Foo Bar", "First") + second := makeRoot("Foo-Bar", "Second") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + for index, name := range []string{"Foo Bar", "Foo-Bar"} { + data := plans[index].services.Get(name) + endpoint := data.Endpoints[0] + code := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, code, "type "+endpoint.DiscardStreamDeclaration.Name()+" struct{}") + require.Contains(t, code, "type "+data.AppendFSDeclaration.Name()+" struct {") + require.Contains(t, code, "func "+data.AppendPrefixDeclaration.Name()+"(") + require.Contains(t, code, "return "+data.AppendFSDeclaration.Name()+"{") + } +} + +// TestNewPlansRejectsDifferentServiceRoot checks the pairing before HTTP +// planning changes any generated package. The valid retry proves the rejected +// call did not reserve imports or names for the wrong design. +func TestNewPlansRejectsDifferentServiceRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + plans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[1]}, + PlanInput{Root: second, Service: plans[0]}, + ) + require.EqualError(t, err, "HTTP root does not match its service plan root") + + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[0]}, + PlanInput{Root: second, Service: plans[1]}, + ) + require.NoError(t, err) +} + +// TestPlanRequiresLinkedServicePlan proves HTTP generation cannot read service +// names before Goa has assigned every package name. +func TestPlanRequiresLinkedServicePlan(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { dsl.HTTP(func() { dsl.POST("/add") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.PanicsWithValue(t, "service render model requested before plan linking", func() { + _ = plans[0].Link() + }) +} + +// assertIndependentCodecFile changes every file field that the JSON-RPC +// generator edits, then checks that both the saved file and a new copy keep +// their original values. +func assertIndependentCodecFile(t *testing.T, saved *codegen.File, copyFile func() *codegen.File) { + t.Helper() + require.NotNil(t, saved) + require.NotEmpty(t, saved.SectionTemplates) + + originalPath := saved.Path + originalName := saved.SectionTemplates[0].Name + originalSource := saved.SectionTemplates[0].Source + originalImports := append([]*codegen.ImportSpec(nil), saved.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + + changed := copyFile() + changed.Path = "changed.go" + changed.SectionTemplates[0].Name = "changed" + changed.SectionTemplates[0].Source = "changed" + changed.SectionTemplates[0].FuncMap = map[string]any{"changed": true} + codegen.AddImport(changed.SectionTemplates[0], &codegen.ImportSpec{Path: "changed.example/package"}) + var changedEndpoint bool + for _, section := range changed.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + endpoint.Method.Name = "Changed" + changedEndpoint = true + break + } + } + require.True(t, changedEndpoint) + + fresh := copyFile() + require.Equal(t, originalPath, saved.Path) + require.Equal(t, originalPath, fresh.Path) + require.Equal(t, originalName, saved.SectionTemplates[0].Name) + require.Equal(t, originalName, fresh.SectionTemplates[0].Name) + require.Equal(t, originalSource, saved.SectionTemplates[0].Source) + require.Equal(t, originalSource, fresh.SectionTemplates[0].Source) + require.Equal(t, originalImports, saved.SectionTemplates[0].Data.(map[string]any)["Imports"]) + require.Equal(t, originalImports, fresh.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range fresh.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + require.Equal(t, "Add", endpoint.Method.Name) + return + } + } + require.Fail(t, "copied codec file has no endpoint section") +} + +// codecEndpointData returns the copied endpoint value used by one encoder or +// decoder section. +func codecEndpointData(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *JSONRPCEndpointSnapshot: + return actual + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + default: + return nil + } +} diff --git a/http/codegen/plan_test_helpers_test.go b/http/codegen/plan_test_helpers_test.go new file mode 100644 index 0000000000..0f50829f35 --- /dev/null +++ b/http/codegen/plan_test_helpers_test.go @@ -0,0 +1,32 @@ +// This file prepares HTTP plans for tests through the same name assignment and +// file-building steps used by the generator. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// linkedHTTPPlanForRoot builds the HTTP files for root after every generated +// package has received its final names. +func linkedHTTPPlanForRoot(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return plans[0] +} diff --git a/http/codegen/server.go b/http/codegen/server.go index e2dd4fadc2..6c992d2656 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -13,8 +13,18 @@ import ( "goa.design/goa/v3/expr" ) -// ServerFiles returns the generated HTTP server files. -func ServerFiles(data *ServicesData) []*codegen.File { +type ( + // appendFSData gives the server file its chosen file helper names and path + // replacements. + appendFSData struct { + *ServiceData + // Mappings pairs each requested path with the embedded file path opened for it. + Mappings map[string]string + } +) + +// serverFiles builds the HTTP server files read by Plan.Link. +func serverFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { files = append(files, addEndpointImports(serverFile(svc, data), data, svc.HTTPEndpoints...)) @@ -26,7 +36,7 @@ func ServerFiles(data *ServicesData) []*codegen.File { } } for _, svc := range data.Expressions.Services { - if f := ServerEncodeDecodeFile(svc, data); f != nil { + if f := serverEncodeDecodeFile(svc, data); f != nil { files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) } } @@ -108,7 +118,12 @@ func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File } } } - sections = append(sections, &codegen.SectionTemplate{Name: "append-fs", Source: httpTemplates.Read(appendFsT), FuncMap: funcs, Data: mappedFiles}) + sections = append(sections, &codegen.SectionTemplate{ + Name: "append-fs", + Source: httpTemplates.Read(appendFsT), + FuncMap: funcs, + Data: appendFSData{ServiceData: data, Mappings: mappedFiles}, + }) } for _, s := range data.FileServers { sections = append(sections, &codegen.SectionTemplate{Name: "server-files", Source: httpTemplates.Read(fileServerT), FuncMap: funcs, Data: s}) @@ -117,9 +132,9 @@ func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File return &codegen.File{Path: fpath, SectionTemplates: sections} } -// ServerEncodeDecodeFile returns the file defining the HTTP server encoding and +// serverEncodeDecodeFile returns the file defining the HTTP server encoding and // decoding logic. -func ServerEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "server", "encode_decode.go") diff --git a/http/codegen/server_decode_test.go b/http/codegen/server_decode_test.go index 9b18ae7171..e743b9badb 100644 --- a/http/codegen/server_decode_test.go +++ b/http/codegen/server_decode_test.go @@ -226,8 +226,8 @@ func TestDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index b595a7b0c2..aea0fb3383 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -92,8 +92,8 @@ func TestEncode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -117,8 +117,8 @@ func TestEncodeMarshallingAndUnmarshalling(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates totalSectionsExpected := c.SectionsOffset + c.SectionCount diff --git a/http/codegen/server_error_encoder_test.go b/http/codegen/server_error_encoder_test.go index 9bc7dd262f..b7ed8cac57 100644 --- a/http/codegen/server_error_encoder_test.go +++ b/http/codegen/server_error_encoder_test.go @@ -35,8 +35,8 @@ func TestEncodeError(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/server_handler_test.go b/http/codegen/server_handler_test.go index 797a17b494..ccdb426eb7 100644 --- a/http/codegen/server_handler_test.go +++ b/http/codegen/server_handler_test.go @@ -26,8 +26,8 @@ func TestServerHandler(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/server_init_test.go b/http/codegen/server_init_test.go index 787925c483..bc9b0b088a 100644 --- a/http/codegen/server_init_test.go +++ b/http/codegen/server_init_test.go @@ -32,8 +32,8 @@ func TestServerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/server_mount_test.go b/http/codegen/server_mount_test.go index 1e29dbf9a1..620120bd84 100644 --- a/http/codegen/server_mount_test.go +++ b/http/codegen/server_mount_test.go @@ -34,8 +34,8 @@ func TestServerMount(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", c.SectionName) require.Greater(t, len(sections), c.SectionNum) code := codegen.SectionCode(t, sections[c.SectionNum]) diff --git a/http/codegen/server_payload_types_test.go b/http/codegen/server_payload_types_test.go index 86b7d8f980..fec42b0547 100644 --- a/http/codegen/server_payload_types_test.go +++ b/http/codegen/server_payload_types_test.go @@ -123,8 +123,8 @@ func TestPayloadConstructor(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] sections := fs.SectionTemplates var section *codegen.SectionTemplate for _, s := range sections { diff --git a/http/codegen/server_types_test.go b/http/codegen/server_types_test.go index ffa1c607ee..0ca6c9c573 100644 --- a/http/codegen/server_types_test.go +++ b/http/codegen/server_types_test.go @@ -41,8 +41,8 @@ func TestServerTypes(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 3568c48d81..b1d7338ab7 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1,5 +1,5 @@ -// This file analyzes HTTP endpoint designs into the immutable data consumed by -// HTTP client, server, body, validation, and streaming templates. +// This file turns HTTP endpoint designs into the data used to write client, +// server, request, response, validation, and streaming code. package codegen import ( @@ -9,11 +9,11 @@ import ( "path" "slices" "sort" - "strconv" "strings" "text/template" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -39,10 +39,22 @@ type ( *service.ServicesData Expressions *expr.HTTPExpr HTTPData map[string]*ServiceData - // jsonrpc indicates that the data describes the JSON-RPC - // transport: generated files live under gen/jsonrpc and titles - // use the JSON-RPC label. + // jsonrpc is true when files are written under gen/jsonrpc and their + // headings use the JSON-RPC name. jsonrpc bool + // viewedResultConstructors contains every client result function name + // chosen for the generated client package. + viewedResultConstructors map[viewedConstructorKey]*codegen.NameDeclaration + payloadConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streamConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errorConstructors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + // plannedWireTypes contains each copied request and response field with + // the Go name used by both its definition and its references. + plannedWireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + // plannedSymbols contains the Go names used in each client and server package. + plannedSymbols map[*expr.HTTPServiceExpr]*httpSymbols + // cliParsers contains the function names for each command parser file. + cliParsers map[*expr.ServerExpr]*cli.ParserPlan } // ServiceData contains the data used to render the code related to a @@ -50,29 +62,40 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data - // ClientPkgName is the frozen qualifier for the generated transport - // client package. + // ClientPkgName is the Go package name written before client types. ClientPkgName string - // ServerPkgName is the frozen qualifier for the generated transport - // server package. + // ServerPkgName is the Go package name written before server types. ServerPkgName string // Endpoints describes the endpoint data for this service. Endpoints []*EndpointData // FileServers lists the file servers for this service. FileServers []*FileServerData - // ServerStruct is the name of the HTTP server struct. - ServerStruct string - // MountPointStruct is the name of the mount point struct. - MountPointStruct string - // ServerInit is the name of the constructor of the server - // struct. - ServerInit string - // MountServer is the name of the mount function. - MountServer string + // ServerStructDeclaration is the package name used by server definitions and calls. + ServerStructDeclaration *codegen.NameDeclaration + // MountPointStructDeclaration is the package name used by the mount point type. + MountPointStructDeclaration *codegen.NameDeclaration + // ServerInitDeclaration is the package name used by the server constructor. + ServerInitDeclaration *codegen.NameDeclaration + // MountServerDeclaration is the package name used by the route mount function. + MountServerDeclaration *codegen.NameDeclaration // ServerService is the name of service function. ServerService string - // ClientStruct is the name of the HTTP client struct. - ClientStruct string + // ClientStructDeclaration is the package name used by the client type. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the package name used by the client constructor. + ClientInitDeclaration *codegen.NameDeclaration + // ServerConnConfigurerDeclaration names the server WebSocket configuration type. + ServerConnConfigurerDeclaration *codegen.NameDeclaration + // ServerConnConfigurerInitDeclaration names the server WebSocket configuration constructor. + ServerConnConfigurerInitDeclaration *codegen.NameDeclaration + // ClientConnConfigurerDeclaration names the client WebSocket configuration type. + ClientConnConfigurerDeclaration *codegen.NameDeclaration + // ClientConnConfigurerInitDeclaration names the client WebSocket configuration constructor. + ClientConnConfigurerInitDeclaration *codegen.NameDeclaration + // AppendFSDeclaration names the file system type used for mapped file paths. + AppendFSDeclaration *codegen.NameDeclaration + // AppendPrefixDeclaration names the function that adds a mapped file path prefix. + AppendPrefixDeclaration *codegen.NameDeclaration // ServerBodyAttributeTypes is the list of user types used to // define the request, response and error response type // attributes in the server code. @@ -95,10 +118,8 @@ type ( // clientWireTypes owns declarations emitted in the actual client // package. clientWireTypes *wireTypeCatalog - // bodies caches the shaped body attributes derived from the - // design expressions during analysis. Shaped bodies are detached - // copies: the analyze pass must never write them back onto the - // design expression tree. + // bodies stores copied request and response fields after applying the HTTP + // mappings. Building service data must never change the input design. bodies shapedBodies } @@ -145,17 +166,18 @@ type ( // server - // MountHandler is the name of the mount handler function. - MountHandler string - // HandlerInit is the name of the constructor function for the - // http handler function. - HandlerInit string - // RequestDecoder is the name of the request decoder function. - RequestDecoder string - // ResponseEncoder is the name of the response encoder function. - ResponseEncoder string - // ErrorEncoder is the name of the error encoder function. - ErrorEncoder string + // MountHandlerDeclaration is the package name used by this endpoint's mount function. + MountHandlerDeclaration *codegen.NameDeclaration + // HandlerInitDeclaration is the package name used by this endpoint's handler constructor. + HandlerInitDeclaration *codegen.NameDeclaration + // RequestDecoderDeclaration is the package name used by this endpoint's request decoder. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseEncoderDeclaration is the package name used by this endpoint's response encoder. + ResponseEncoderDeclaration *codegen.NameDeclaration + // ErrorEncoderDeclaration is the package name used by this endpoint's error encoder. + ErrorEncoderDeclaration *codegen.NameDeclaration + // DiscardStreamDeclaration names the no-output stream used by a mixed-result request. + DiscardStreamDeclaration *codegen.NameDeclaration // MultipartRequestDecoder indicates the request decoder for // multipart content type. MultipartRequestDecoder *MultipartData @@ -173,32 +195,33 @@ type ( // client - // ClientStruct is the name of the HTTP client struct. - ClientStruct string + // ClientStructDeclaration supplies the client type name used by endpoint methods. + ClientStructDeclaration *codegen.NameDeclaration // EndpointInit is the name of the constructor function for the // client endpoint. EndpointInit string // RequestInit is the request builder function. RequestInit *InitData - // RequestEncoder is the name of the request encoder function. - RequestEncoder string - // ResponseDecoder is the name of the response decoder function. - ResponseDecoder string + // RequestEncoderDeclaration is the package name used by this endpoint's request encoder. + RequestEncoderDeclaration *codegen.NameDeclaration + // ResponseDecoderDeclaration is the package name used by this endpoint's response decoder. + ResponseDecoderDeclaration *codegen.NameDeclaration // MultipartRequestEncoder indicates the request encoder for // multipart content type. MultipartRequestEncoder *MultipartData // ClientWebSocket holds the data to render the client struct which // implements the client stream interface. ClientWebSocket *WebSocketData - // BuildStreamPayload is the name of the function used to create the - // payload for endpoints that use SkipRequestBodyEncodeDecode. - BuildStreamPayload string + // BuildStreamPayloadDeclaration is the package name used by the streamed request helper. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // CLIPayloadDeclaration is the package name used by the command-line payload helper. + CLIPayloadDeclaration *codegen.NameDeclaration } // FileServerData lists the data needed to generate file servers. FileServerData struct { - // MountHandler is the name of the mount handler function. - MountHandler string + // MountHandlerDeclaration is the package name used by this file server's mount function. + MountHandlerDeclaration *codegen.NameDeclaration // RequestPaths is the set of HTTP paths to the server. RequestPaths []string // Root is the root server file path. @@ -393,10 +416,35 @@ type ( // ViewedResult indicates whether the response body type is a // result type. ViewedResult *service.ViewedResultTypeData + // ViewedRepresentations lists the body type and constructor used for each + // legal result view. A response that supports several views includes its + // view name so the client can choose the matching entry. + ViewedRepresentations []*ViewedRepresentationData + } + + // ViewedRepresentationData describes the HTTP body used for one legal result + // view. The server constructor converts the service result into ServerBody. + // The client decodes ClientBody and ResultInit rebuilds the service result. + ViewedRepresentationData struct { + // View is the exact design view name carried on variable-view messages. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the response body uses the complete projected result. + ResultAttr string + // ServerBody is the body type encoded by the server for View. + ServerBody *TypeData + // ClientBody is the body type decoded by the client for View. + ClientBody *TypeData + // ResultInit rebuilds the projected result from ClientBody. + ResultInit *InitData } // InitData contains the data required to render a constructor. InitData struct { + // Declaration is the generated package function name used by this constructor. + Declaration *codegen.NameDeclaration + // ClientDeclaration is the client package name for a path function also emitted on the server. + ClientDeclaration *codegen.NameDeclaration // Name is the constructor function name. Name string // Description is the function description. @@ -455,6 +503,8 @@ type ( TypeName string // TypeRef is the generated attribute type reference. TypeRef string + // ElemTypeRef is the generated element type reference for an array. + ElemTypeRef string // Description is the attribute description as defined in the design. Description string // FieldName is the name of the data structure field that should @@ -472,9 +522,9 @@ type ( Validate string // Example is an example attribute value Example any - // IsAliased is true if the field type is a user-defined type (alias). + // IsAliased is true when the field uses a user-defined type. IsAliased bool - // ServiceTypeRef is the service-aware type reference for cross-service resolution. + // ServiceTypeRef is the Go type used when the field comes from another service. ServiceTypeRef string // IsTextUnmarshaler is true if the attribute has a struct:field:type meta // whose underlying DSL type is string and the custom type is expected to @@ -531,7 +581,7 @@ type ( // HeaderData describes a HTTP request or response header. HeaderData struct { *Element - // CanonicalName is the canonical header key. + // CanonicalName is the standard HTTP header spelling. CanonicalName string } @@ -571,6 +621,8 @@ type ( ValidateDef string // ValidateRef contains the call to the validation code. ValidateRef string + // ValidatorName is the package-level function that runs ValidateDef. + ValidatorName string // Example is an example value for the type. Example any // View is the view used to render the (result) type if any. @@ -583,10 +635,10 @@ type ( // MultipartData contains the data needed to render multipart // encoder/decoder. MultipartData struct { - // FuncName is the name used to generate function type. - FuncName string - // InitName is the name of the constructor. - InitName string + // FuncDeclaration is the package name used by the multipart function type or root helper. + FuncDeclaration *codegen.NameDeclaration + // InitDeclaration is the package name used by the multipart constructor. + InitDeclaration *codegen.NameDeclaration // VarName is the name of the variable referring to the function. VarName string // ServiceName is the name of the service. @@ -629,8 +681,9 @@ const ( cookieElement httpElementKind = "cookie" ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { +// newServicesData creates the HTTP service map that Plan.Link fills before it +// builds any generated file. +func newServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { return &ServicesData{ ServicesData: services, Expressions: expressions, @@ -638,27 +691,10 @@ func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) } } -// NewJSONRPCServicesData creates a new ServicesData instance for the JSON-RPC -// transport: file constructors write under gen/jsonrpc and use the JSON-RPC -// label in generated file headers. -func NewJSONRPCServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { - data := NewServicesData(services, expressions) - data.jsonrpc = true - return data -} - -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// Get returns the generated HTTP information for the service with the given +// name. A missing entry means the design does not expose that service over the +// protocol handled by this plan. func (sds *ServicesData) Get(name string) *ServiceData { - if data, ok := sds.HTTPData[name]; ok { - return data - } - svc := sds.Expressions.Service(name) - if svc == nil { - return nil - } - sds.HTTPData[name] = sds.analyze(svc) return sds.HTTPData[name] } @@ -705,21 +741,38 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { scope.Unique("websocket") // Reserve the service package alias to avoid collision with parameter names in generated code. scope.Unique(svc.PkgName) + planned := sds.plannedWireTypes[httpSvc] + if planned == nil { + panic(fmt.Sprintf("HTTP service %q has no planned generated types", httpSvc.Name())) + } + planned.server.Link() + planned.client.Link() + symbols := sds.plannedSymbols[httpSvc] + if symbols == nil { + panic(fmt.Sprintf("HTTP service %q has no package names", httpSvc.Name())) + } sd := &ServiceData{ - Service: svc, - ClientPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client")).Name, - ServerPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name, - ServerStruct: "Server", - MountPointStruct: "MountPoint", - ServerInit: "New", - MountServer: "Mount", - ServerService: "Service", - ClientStruct: "Client", - Scope: scope, - serverWireTypes: newWireTypeCatalog("c", "v", "websocket", svc.PkgName), - clientWireTypes: newWireTypeCatalog("c", "v", "websocket", svc.PkgName), - } - sds.collectWireTypes(httpSvc, sd) + Service: svc, + ClientPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client")).Name, + ServerPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name, + ServerStructDeclaration: symbols.serverStruct, + MountPointStructDeclaration: symbols.mountPoint, + ServerInitDeclaration: symbols.serverInit, + MountServerDeclaration: symbols.mountServer, + ServerService: "Service", + ClientStructDeclaration: symbols.clientStruct, + ClientInitDeclaration: symbols.clientInit, + ServerConnConfigurerDeclaration: symbols.serverConfigurer, + ServerConnConfigurerInitDeclaration: symbols.serverConfigurerInit, + ClientConnConfigurerDeclaration: symbols.clientConfigurer, + ClientConnConfigurerInitDeclaration: symbols.clientConfigurerInit, + AppendFSDeclaration: symbols.appendFS, + AppendPrefixDeclaration: symbols.appendPrefix, + Scope: scope, + serverWireTypes: planned.server, + clientWireTypes: planned.client, + bodies: planned.bodies, + } for _, s := range httpSvc.FileServers { paths := make([]string, len(s.RequestPaths)) @@ -746,14 +799,14 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } data := &FileServerData{ - MountHandler: scope.Unique(fmt.Sprintf("Mount%s", codegen.Goify(s.FilePath, true))), - RequestPaths: paths, - FilePath: s.FilePath, - IsDir: s.IsDir(), - PathParam: pp, - Redirect: redirect, - VarName: scope.Unique(codegen.Goify(s.FilePath, true)), - ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), + MountHandlerDeclaration: symbols.fileServers[s], + RequestPaths: paths, + FilePath: s.FilePath, + IsDir: s.IsDir(), + PathParam: pp, + Redirect: redirect, + VarName: scope.Unique(codegen.Goify(s.FilePath, true)), + ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), } sd.FileServers = append(sd.FileServers, data) } @@ -766,6 +819,10 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { routesCap += len(r.FullPaths()) } routes := make([]*RouteData, 0, routesCap) + endpointSymbols := symbols.endpoints[httpEndpoint] + if endpointSymbols == nil { + panic(fmt.Sprintf("HTTP endpoint %q has no package names", httpEndpoint.Name())) + } pathCount := 0 for _, r := range httpEndpoint.Routes { for _, rpath := range r.FullPaths() { @@ -776,12 +833,8 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { { initArgs := make([]*InitArgData, len(params)) pathParamsObj := expr.AsObject(httpEndpoint.PathParams().Type) - suffix := "" - if pathCount > 0 { - suffix = strconv.Itoa(pathCount + 1) - } - pathCount++ - name := fmt.Sprintf("%s%sPath%s", method.VarName, svc.StructName, suffix) + declaration := endpointSymbols.serverPaths[pathCount] + name := declaration.Name() for j, arg := range params { patt := pathParamsObj.Attribute(arg) att := makeHTTPType(patt) @@ -838,14 +891,16 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } init = &InitData{ - Name: name, - Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), - ServerArgs: initArgs, - ClientArgs: clientArgs, - ReturnTypeName: "string", - ReturnTypeRef: "string", - ServerCode: buffer.String(), - ClientCode: buffer.String(), + Declaration: declaration, + ClientDeclaration: endpointSymbols.clientPaths[pathCount], + Name: name, + Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), + ServerArgs: initArgs, + ClientArgs: clientArgs, + ReturnTypeName: "string", + ReturnTypeRef: "string", + ServerCode: buffer.String(), + ClientCode: buffer.String(), } } @@ -854,6 +909,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Path: rpath, PathInit: init, }) + pathCount++ } } @@ -888,13 +944,6 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { reqs = append(reqs, &service.RequirementData{Schemes: rs, Scopes: req.Scopes}) } - var requestEncoder string - if httpEndpoint.IsJSONRPC() || payload.Request.ClientBody != nil || len(payload.Request.Headers) > 0 || len(payload.Request.QueryParams) > 0 || len(payload.Request.Cookies) > 0 || basch != nil { - // JSON-RPC endpoints always need a request encoder to build - // the JSON-RPC envelope, even when the payload is empty. - requestEncoder = fmt.Sprintf("Encode%sRequest", method.VarName) - } - var requestInit *InitData var ( name string @@ -951,59 +1000,75 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } ed := &EndpointData{ - Method: method, - IsJSONRPC: httpEndpoint.IsJSONRPC(), - ServiceName: svc.Name, - ServiceVarName: svc.VarName, - ServicePkgName: svc.PkgName, - Payload: payload, - Result: sds.buildResultData(httpEndpoint, sd), - Errors: sds.buildErrorsData(httpEndpoint, sd), - HeaderSchemes: hsch, - BodySchemes: bosch, - QuerySchemes: qsch, - BasicScheme: basch, - Routes: routes, - MountHandler: fmt.Sprintf("Mount%sHandler", method.VarName), - HandlerInit: fmt.Sprintf("New%sHandler", method.VarName), - RequestDecoder: fmt.Sprintf("Decode%sRequest", method.VarName), - ResponseEncoder: fmt.Sprintf("Encode%sResponse", method.VarName), - ErrorEncoder: fmt.Sprintf("Encode%sError", method.VarName), - ClientStruct: "Client", - EndpointInit: method.VarName, - RequestInit: requestInit, - HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), - RequestEncoder: requestEncoder, - ResponseDecoder: fmt.Sprintf("Decode%sResponse", method.VarName), - Requirements: reqs, + Method: method, + IsJSONRPC: httpEndpoint.IsJSONRPC(), + ServiceName: svc.Name, + ServiceVarName: svc.VarName, + ServicePkgName: svc.PkgName, + Payload: payload, + Result: sds.buildResultData(httpEndpoint, sd), + Errors: sds.buildErrorsData(httpEndpoint, sd), + HeaderSchemes: hsch, + BodySchemes: bosch, + QuerySchemes: qsch, + BasicScheme: basch, + Routes: routes, + MountHandlerDeclaration: endpointSymbols.mountHandler, + HandlerInitDeclaration: endpointSymbols.handlerInit, + RequestDecoderDeclaration: endpointSymbols.requestDecoder, + ResponseEncoderDeclaration: endpointSymbols.responseEncoder, + ErrorEncoderDeclaration: endpointSymbols.errorEncoder, + DiscardStreamDeclaration: endpointSymbols.discardStream, + ClientStructDeclaration: symbols.clientStruct, + EndpointInit: method.VarName, + RequestInit: requestInit, + HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), + RequestEncoderDeclaration: endpointSymbols.requestEncoder, + ResponseDecoderDeclaration: endpointSymbols.responseDecoder, + Requirements: reqs, } if httpEndpoint.MethodExpr.IsStreaming() { sds.initWebSocketData(ed, httpEndpoint, sd) sds.initSSEData(ed, httpEndpoint, sd) + if ed.ServerWebSocket != nil { + ed.ServerWebSocket.VarDeclaration = endpointSymbols.serverStream + ed.ServerWebSocket.VarName = endpointSymbols.serverStream.Name() + } + if ed.ClientWebSocket != nil { + ed.ClientWebSocket.VarDeclaration = endpointSymbols.clientStream + ed.ClientWebSocket.VarName = endpointSymbols.clientStream.Name() + } + if ed.SSE != nil { + ed.SSE.StructDeclaration = endpointSymbols.serverStream + ed.SSE.ClientInterfaceDeclaration = endpointSymbols.sseClientInterface + ed.SSE.ClientStructDeclaration = endpointSymbols.sseClientStruct + ed.SSE.ClientInitDeclaration = endpointSymbols.sseClientInit + } } if httpEndpoint.MultipartRequest { ed.MultipartRequestDecoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sDecoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sDecoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncDeclaration: endpointSymbols.serverMultipart.functionType, + InitDeclaration: endpointSymbols.serverMultipart.constructor, + VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } ed.MultipartRequestEncoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sEncoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sEncoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncDeclaration: endpointSymbols.clientMultipart.functionType, + InitDeclaration: endpointSymbols.clientMultipart.constructor, + VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } } if httpEndpoint.SkipRequestBodyEncodeDecode { - ed.BuildStreamPayload = scope.Unique("Build" + codegen.Goify(method.Name, true) + "StreamPayload") + ed.BuildStreamPayloadDeclaration = endpointSymbols.buildStreamPayload } + ed.CLIPayloadDeclaration = endpointSymbols.cliPayload if httpEndpoint.Redirect != nil { ed.Redirect = &RedirectData{ @@ -1058,33 +1123,43 @@ func (sds *ServicesData) buildRequestAttributeTypes(body *expr.AttributeExpr, da } } -// collectWireTypes records every declaration that the service's actual client -// and server packages may emit, then freezes both catalogs before endpoint -// analysis builds TypeData or asks for a reference. -func (sds *ServicesData) collectWireTypes(httpService *expr.HTTPServiceExpr, data *ServiceData) { +// collectPlannedWireTypes records every request and response type written by +// the generated client and server packages. NewPlans calls it before Goa +// assigns package names, and Link later uses these same copied values. +func collectPlannedWireTypes(httpService *expr.HTTPServiceExpr, planned *plannedWireTypes, servicePlan *service.Plan) { + bodies, server, client := &planned.bodies, planned.server, planned.client for _, endpoint := range httpService.HTTPEndpoints { - request := expr.DupAtt(data.bodies.request(endpoint)) + request := expr.DupAtt(bodies.request(endpoint)) addMarshalTags(request) - data.serverWireTypes.collect(request, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}, "") - data.clientWireTypes.collect(request, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}, "") - data.serverWireTypes.collect(request, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}, "") - data.clientWireTypes.collect(request, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + server.collect(request, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}, "") + clientRequest := client.collect(request, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + if clientRequest != nil && needInit(request.Type) { + clientRequest.needsConstructor = true + } + server.collectChildren(request, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}) + client.collectChildren(request, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}) if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { - streaming := expr.DupAtt(data.bodies.streaming(endpoint)) + streaming := expr.DupAtt(bodies.streaming(endpoint)) addMarshalTags(streaming) - data.serverWireTypes.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, pointer: true, validate: true}, "") - data.clientWireTypes.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, useDefault: true, validate: true}, "") - data.serverWireTypes.collect(streaming, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}, "") - data.clientWireTypes.collect(streaming, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + serverStream := server.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, pointer: true, validate: true}, "") + if endpoint.UsesWebSocket() && needInit(endpoint.MethodExpr.StreamingPayload.Type) && serverStream != nil { + serverStream.needsConstructor = true + planned.streamPayloads[endpoint] = serverStream + } + clientStream := client.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + if clientStream != nil && needInit(streaming.Type) { + clientStream.needsConstructor = true + } + server.collectChildren(streaming, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}) + client.collectChildren(streaming, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}) } - method := data.Service.Method(endpoint.Name()) - viewed := method.ViewedResult != nil + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) for _, response := range endpoint.Responses { - body := data.bodies.response(response) + body := bodies.response(response) if !viewed { - sds.collectResponseWireType(body, endpoint, data, true, nil) - sds.collectResponseWireType(body, endpoint, data, false, nil) + collectResponseWireType(body, endpoint, server, true, nil) + collectResponseWireType(body, endpoint, client, false, nil) continue } origin := "" @@ -1094,40 +1169,200 @@ func (sds *ServicesData) collectWireTypes(httpService *expr.HTTPServiceExpr, dat emptyView := "" switch { case origin != "": - sds.collectResponseWireType(body, endpoint, data, true, &emptyView) + collectResponseWireType(body, endpoint, server, true, &emptyView) case endpoint.MethodExpr.Result.Meta != nil: if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { - sds.collectResponseWireType(body, endpoint, data, true, &view) + collectResponseWireType(body, endpoint, server, true, &view) } else { - for _, view := range method.ViewedResult.Views { - sds.collectResponseWireType(body, endpoint, data, true, &view.Name) + for _, view := range resultType.Views { + collectResponseWireType(body, endpoint, server, true, &view.Name) } } default: - for _, view := range method.ViewedResult.Views { - sds.collectResponseWireType(body, endpoint, data, true, &view.Name) + for _, view := range resultType.Views { + collectResponseWireType(body, endpoint, server, true, &view.Name) } } - clientView := clientResponseViewName(endpoint, method) - clientBody := body + clientView := clientResponseViewNameExpr(endpoint, resultType) + if origin != "" { + emptyView := "" + collectResponseWireType(body, endpoint, client, false, &emptyView) + continue + } + if clientView == "" && !endpoint.UsesSSE() && !endpoint.IsJSONRPC() { + emptyView := "" + collectResponseWireType(body, endpoint, client, false, &emptyView) + continue + } if clientView != "" { - clientBody = effectiveClientResponseBody(body, endpoint, method) + clientBody := effectiveClientResponseBodyForView(body, clientView) + collectResponseWireType(clientBody, endpoint, client, false, &clientView) + continue + } + for _, view := range resultType.Views { + clientBody := effectiveClientResponseBodyForView(body, view.Name) + collectResponseWireType(clientBody, endpoint, client, false, &view.Name) } - sds.collectResponseWireType(clientBody, endpoint, data, false, &clientView) } for _, transportError := range endpoint.HTTPErrors { - body := data.bodies.errorResponse(transportError) - sds.collectResponseWireType(body, endpoint, data, true, nil) - sds.collectResponseWireType(body, endpoint, data, false, nil) + body := bodies.errorResponse(transportError) + collectResponseWireType(body, endpoint, server, true, nil) + collectResponseWireType(body, endpoint, client, false, nil) } + collectPlannedTransforms(endpoint, bodies, servicePlan, server, client) + } +} + +// collectPlannedTransforms records each HTTP body conversion in the same order +// that Link writes it. This lets the generated package name every extra +// conversion function before Plan.Link. +func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBodies, servicePlan *service.Plan, server, client *wireTypeCatalog) { + methodName := endpoint.MethodExpr.Name + request := expr.DupAtt(bodies.request(endpoint)) + addMarshalTags(request) + payload := endpoint.MethodExpr.Payload + if needInit(payload.Type) { + if request.Type != expr.Empty { + target := payload + if origin, ok := request.Meta["origin:attribute"]; ok { + target = expr.AsObject(payload.Type).Attribute(origin[0]) + } + client.collectTransform(target, request, "marshal", methodName+" request body") + server.collectTransform(request, target, "unmarshal", methodName+" server payload") + client.collectTransform(request, target, "marshal", methodName+" command payload") + } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { + if params := expr.AsObject(endpoint.Params.Type); len(*params) > 0 { + server.collectTransform((*params)[0].Attribute, payload, "unmarshal", methodName+" server parameters") + client.collectTransform((*params)[0].Attribute, payload, "marshal", methodName+" command parameters") + } + } + } + + result := endpoint.MethodExpr.Result + resultType, viewed := result.Type.(*expr.ResultTypeExpr) + if viewed { + var err error + result, err = servicePlan.ProjectedResult(endpoint.MethodExpr) + if err != nil { + panic(err) + } + } + for _, response := range endpoint.Responses { + body := bodies.response(response) + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + resultAttribute := result + if origin != "" { + resultAttribute = expr.AsObject(result.Type).Attribute(origin) + } + var serverViews []*string + switch { + case !viewed: + serverViews = []*string{nil} + case origin != "": + empty := "" + serverViews = []*string{&empty} + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + serverViews = []*string{&view} + } else { + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) + } + } + default: + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) + } + } + for _, view := range serverViews { + prepared, _ := prepareResponseWireBody(body, view) + if prepared.Type != expr.Empty && resultAttribute.Type != expr.Empty && needInit(prepared.Type) { + server.collectTransform(resultAttribute, prepared, "marshal", transformResponseOwner(methodName, response, view, "server")) + } + } + + if !needInit(result.Type) { + continue + } + var clientViews []*string + if !viewed { + clientViews = []*string{nil} + } else { + selected := clientResponseViewNameExpr(endpoint, resultType) + switch { + case origin != "": + empty := "" + clientViews = []*string{&empty} + case selected != "": + clientViews = []*string{&selected} + case !endpoint.UsesSSE() && !endpoint.IsJSONRPC(): + empty := "" + clientViews = []*string{&empty} + default: + for index := range resultType.Views { + clientViews = append(clientViews, &resultType.Views[index].Name) + } + } + } + for _, view := range clientViews { + clientBody := body + if view != nil && *view != "" { + clientBody = effectiveClientResponseBodyForView(body, *view) + } + prepared, _ := prepareResponseWireBody(clientBody, view) + if prepared.Type != expr.Empty { + client.collectTransform(prepared, resultAttribute, "unmarshal", transformResponseOwner(methodName, response, view, "client")) + } + } + if body.Type == expr.Empty && (expr.IsArray(result.Type) || expr.IsMap(result.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + client.collectTransform((*params)[0].Attribute, result, "unmarshal", transformResponseOwner(methodName, response, nil, "client parameters")) + } + } + } + + for _, transportError := range endpoint.HTTPErrors { + body, _ := prepareResponseWireBody(bodies.errorResponse(transportError), nil) + target := endpoint.MethodExpr.Error(transportError.Name).AttributeExpr + if origin, ok := body.Meta["origin:attribute"]; ok { + target = expr.AsObject(target.Type).Attribute(origin[0]) + } + if body.Type != expr.Empty && needInit(transportError.Type) { + server.collectTransform(target, body, "marshal", methodName+" server error "+transportError.Name) + client.collectTransform(body, target, "unmarshal", methodName+" client error "+transportError.Name) + } else if body.Type == expr.Empty && (expr.IsArray(transportError.Type) || expr.IsMap(transportError.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + client.collectTransform((*params)[0].Attribute, endpoint.MethodExpr.Error(transportError.Name).AttributeExpr, "unmarshal", methodName+" client error parameters "+transportError.Name) + } + } + } + + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && endpoint.UsesWebSocket() { + body := expr.DupAtt(bodies.streaming(endpoint)) + addMarshalTags(body) + if body.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) { + server.collectTransform(body, endpoint.MethodExpr.StreamingPayload, "marshal", methodName+" server stream payload") + client.collectTransform(endpoint.MethodExpr.StreamingPayload, body, "marshal", methodName+" client stream body") + } + } +} + +// transformResponseOwner returns the design values that distinguish helper +// functions for two responses with the same generated Go types. +func transformResponseOwner(method string, response *expr.HTTPResponseExpr, view *string, side string) string { + viewName := "" + if view != nil { + viewName = *view } - data.serverWireTypes.Freeze() - data.clientWireTypes.Freeze() + return fmt.Sprintf("%s %s response %d %s %s %s", method, side, response.StatusCode, response.Tag[0], response.Tag[1], viewName) } // collectResponseWireType applies the selected view and records response body // declarations using the same policy later consumed by buildResponseBodyType. -func (sds *ServicesData) collectResponseWireType(body *expr.AttributeExpr, endpoint *expr.HTTPEndpointExpr, data *ServiceData, server bool, view *string) { +func collectResponseWireType(body *expr.AttributeExpr, endpoint *expr.HTTPEndpointExpr, catalog *wireTypeCatalog, server bool, view *string) { body, viewName := prepareResponseWireBody(body, view) policy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server && view == nil, view: viewName} preferred := "" @@ -1136,9 +1371,12 @@ func (sds *ServicesData) collectResponseWireType(body *expr.AttributeExpr, endpo preferred = codegen.Goify(endpoint.Name(), true) + "ResponseBody" } } - data.wireTypes(server).collect(body, wireResponseBody, policy, preferred) + record := catalog.collect(body, wireResponseBody, policy, preferred) + if server && record != nil && needInit(body.Type) { + record.needsConstructor = true + } attributePolicy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server} - data.wireTypes(server).collect(body, wireAttribute, attributePolicy, "") + catalog.collectChildren(body, wireAttribute, attributePolicy) } // prepareResponseWireBody returns the detached, projected, and tagged shape @@ -1299,8 +1537,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD svc = sd.Service body = httpBody.Type ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = httpContext(sd.serverWireTypes.scope, true, true) - httpclictx = httpContext(sd.clientWireTypes.scope, true, false) + httpsvrctx = wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) payloadOwner = expr.MethodPayloadExampleIdentity(e.MethodExpr) @@ -1426,20 +1664,11 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD serverArgs []*InitArgData ) argsCap := len(request.PathParams) + len(request.QueryParams) + len(request.Headers) + len(request.Cookies) - n := codegen.Goify(ep.Name, true) - p := codegen.Goify(ep.Payload, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - if ep.PayloadDeclaration != nil { - p = ep.PayloadDeclaration.Name() - } else { - p = svc.Scope.HashedUnique(payload.Type, p) - } - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.payloadConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.Name()) isObject = expr.IsObject(payload.Type) @@ -1447,9 +1676,27 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD clientArgs = make([]*InitArgData, 0, argsCap+1) if body != expr.Empty { var ( - svcode string - cvcode string + svcode string + cvcode string + serverTypeName string + serverTypeRef string + clientTypeName string + clientTypeRef string ) + if record := sd.serverWireTypes.lookupUser(serverHTTPBody, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}); record != nil { + serverTypeName = record.name + serverTypeRef = record.ref + } else { + serverTypeName = httpsvrctx.Scope.Name(serverHTTPBody, "", httpsvrctx.Pointer, httpsvrctx.UseDefault) + serverTypeRef = httpsvrctx.Scope.Ref(serverHTTPBody, "") + } + if record := sd.clientWireTypes.lookupUser(clientHTTPBody, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}); record != nil { + clientTypeName = record.name + clientTypeRef = record.ref + } else { + clientTypeName = httpclictx.Scope.Name(clientHTTPBody, "", httpclictx.Pointer, httpclictx.UseDefault) + clientTypeRef = httpclictx.Scope.Ref(clientHTTPBody, "") + } if ut, ok := serverHTTPBody.Type.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { svcode = codegen.ValidationCode(ut.Attribute(), ut, httpsvrctx, true, expr.IsAlias(ut), false, "body") @@ -1465,8 +1712,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.serverWireTypes.scope.GoTypeName(serverHTTPBody), - TypeRef: sd.serverWireTypes.scope.GoTypeRef(serverHTTPBody), + TypeName: serverTypeName, + TypeRef: serverTypeRef, Type: serverHTTPBody.Type, Required: true, Example: sds.Example(httpBody, bodyOwner), @@ -1478,8 +1725,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.clientWireTypes.scope.GoTypeNameWithDefaults(clientHTTPBody), - TypeRef: sd.clientWireTypes.scope.GoTypeRefWithDefaults(clientHTTPBody), + TypeName: clientTypeName, + TypeRef: clientTypeRef, Type: clientHTTPBody.Type, Required: true, Example: sds.Example(httpBody, bodyOwner), @@ -1601,8 +1848,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD var ( helpers []*codegen.TransformFunctionData ) - transformctx := httpContext(sd.serverWireTypes.scope.Fork(), true, true) - serverCode, helpers, err = unmarshal(serverHTTPBody, pAtt, "body", transformctx, svcsvrctx) + transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(serverHTTPBody, pAtt, "body", "v", "unmarshal", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -1610,21 +1857,21 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // body is used by the CLI tool to build the payload given to the // client endpoint. It differs because the body type there does not // use pointers for all fields (no need to validate). - transformctx = httpContext(sd.clientWireTypes.scope.Fork(), true, false) - clientCode, helpers, err = marshal(clientHTTPBody, pAtt, "body", "v", transformctx, svcclictx) + transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(clientHTTPBody, pAtt, "body", "v", "marshal", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - transformctx := httpContext(sd.serverWireTypes.scope.Fork(), true, true) - serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), transformctx, svcsvrctx) + transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } - transformctx = httpContext(sd.clientWireTypes.scope.Fork(), true, false) - clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcclictx) + transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", "marshal", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -1634,6 +1881,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD panic(err) // bug } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, @@ -1766,11 +2014,10 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A var ( responses []*ResponseData - svc = sd.Service - md = svc.Method(e.Name()) - httpclictx = httpContext(sd.clientWireTypes.scope, false, false) - scope = svc.Scope - svcctx = sds.serviceTypeContext(sd, "client").Enter(result) + svc = sd.Service + md = svc.Method(e.Name()) + scope = svc.Scope + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) { if viewed { @@ -1845,12 +2092,14 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A } } if clientView != "" { - clientRespBody = effectiveClientResponseBody(respBody, e, md) + clientRespBody = effectiveClientResponseBodyForView(respBody, clientView) clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd, resultOwner, bodyOwner) clientBodyView = &clientView - } else { + } else if origin != "" || !e.UsesSSE() && !e.IsJSONRPC() { clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, resultOwner, bodyOwner) clientBodyView = &vname + } else { + clientRespBody = &expr.AttributeExpr{Type: expr.Empty} } } else { if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, resultOwner, bodyOwner); sbd != nil { @@ -1858,7 +2107,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A } clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, resultOwner, bodyOwner) } - if clientRespBody.Type != expr.Empty { + if clientBodyData != nil && clientRespBody.Type != expr.Empty { var viewName string clientRespBody, viewName = prepareResponseWireBody(clientRespBody, clientBodyView) policy := wireTypePolicy{pointer: true, validate: clientBodyView == nil, view: viewName} @@ -1876,107 +2125,75 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A break } } - if needInit(result.Type) { - // generate constructor function to transform response body, - // headers and cookies into the method result type - var ( - name string - desc string - code string - tname string - tref string - err error - pointer bool - clientArgs []*InitArgData - helpers []*codegen.TransformFunctionData + variableWire := viewed && origin == "" && clientResponseViewName(e, md) == "" && (e.UsesSSE() || e.IsJSONRPC()) + if needInit(result.Type) && !variableWire { + init = sds.buildResponseResultInit( + e, resp, result, resAttr, clientRespBody, origin, + headersData, cookiesData, sd, "", clientBodyData, ) - { - tname = svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) - tref = svcctx.Scope.Ref(result, svcctx.Pkg(result)) - status := codegen.Goify(http.StatusText(resp.StatusCode), true) - n := codegen.Goify(md.Name, true) - r := codegen.Goify(md.Result, true) - // Raw result object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(r, n) { - if md.ResultDeclaration != nil { - r = md.ResultDeclaration.Name() - } else { - r = scope.HashedUnique(result.Type, r) + } + + var representations []*ViewedRepresentationData + if viewed && (e.UsesSSE() || e.IsJSONRPC()) { + clientView := clientResponseViewName(e, md) + if origin != "" { + views := md.ViewedResult.Views + if clientView != "" { + views = []*service.ViewData{{Name: clientView}} + } + for _, view := range views { + representation := &ViewedRepresentationData{ + View: view.Name, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ResultInit: init, } - name = fmt.Sprintf("New%s%s", r, status) - } else { - name = fmt.Sprintf("New%s%s%s", n, r, status) + if len(serverBodyData) > 0 { + representation.ServerBody = serverBodyData[0] + } + representations = append(representations, representation) } - desc = fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) - if clientRespBody.Type != expr.Empty { - if origin != "" { - pointer = result.IsPrimitivePointer(origin, true) + } else { + if clientView != "" { + representation := &ViewedRepresentationData{ + View: clientView, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ResultInit: init, } - ref := "body" - if expr.IsObject(clientRespBody.Type) { - ref = "&body" - pointer = false + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, clientView) } - var vcode string - if ut, ok := clientRespBody.Type.(expr.UserType); ok { - if val := ut.Attribute().Validation; val != nil { - vcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") - } + representations = append(representations, representation) + } + for _, view := range md.ViewedResult.Views { + if clientView != "" { + break } - clientArgs = []*InitArgData{{ - Ref: ref, - AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeRef: sd.clientWireTypes.scope.GoTypeRef(clientRespBody), - Validate: vcode, - }, - }} - // If the method result is a - // * result type - we unmarshal the client response body to the - // corresponding type in the views package so that view-specific - // validation logic can be applied. - // * user type - we unmarshal the client response body to the - // corresponding type in the service package after validating the - // response body. Here, the transformation code must - // rely on the fact that the required attributes are - // set in the response body (otherwise validation - // would fail). - transformctx := httpContext(sd.clientWireTypes.scope.Fork(), false, false) - code, helpers, err = unmarshal(clientRespBody, resAttr, "body", transformctx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + viewName := view.Name + body := effectiveClientResponseBodyForView(respBody, viewName) + clientBody := sds.buildResponseBodyType( + respBody, result, e, false, &viewName, sd, resultOwner, bodyOwner, + ) + if body.Type != expr.Empty { + policy := wireTypePolicy{pointer: true, view: viewName} + sd.clientWireTypes.applyNames(body, wireResponseBody, policy) } - } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { - if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { - code, helpers, err = unmarshal((*params)[0].Attribute, result, codegen.Goify((*params)[0].Name, false), httpclictx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) - } + resultInit := sds.buildResponseResultInit( + e, resp, result, resAttr, body, origin, + headersData, cookiesData, sd, viewName, clientBody, + ) + representation := &ViewedRepresentationData{ + View: viewName, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBody, + ResultInit: resultInit, } + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, viewName) + } + representations = append(representations, representation) } - if err != nil { - panic(err) // bug - } - for _, h := range headersData { - clientArgs = append(clientArgs, resultInitArg(h.Element)) - } - for _, c := range cookiesData { - clientArgs = append(clientArgs, resultInitArg(c.Element)) - } - } - init = &InitData{ - Name: name, - Description: desc, - ClientArgs: clientArgs, - ReturnTypeName: tname, - ReturnTypeRef: tref, - ReturnIsStruct: expr.IsObject(result.Type), - ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: svcctx.Pkg(result), - ReturnIsPrimitivePointer: pointer, - ClientCode: code, } } @@ -1991,20 +2208,21 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A tagPtr = viewed || result.IsPrimitivePointer(resp.Tag[0], true) } responses = append(responses, &ResponseData{ - StatusCode: statusCodeToHTTPConst(resp.StatusCode), - Description: resp.Description, - Headers: headersData, - Cookies: cookiesData, - ContentType: resp.ContentType, - ServerBody: serverBodyData, - ClientBody: clientBodyData, - ResultInit: init, - TagName: tagName, - TagValue: tagVal, - TagPointer: tagPtr, - MustValidate: mustValidate, - ResultAttr: codegen.Goify(origin, true), - ViewedResult: md.ViewedResult, + StatusCode: statusCodeToHTTPConst(resp.StatusCode), + Description: resp.Description, + Headers: headersData, + Cookies: cookiesData, + ContentType: resp.ContentType, + ServerBody: serverBodyData, + ClientBody: clientBodyData, + ResultInit: init, + TagName: tagName, + TagValue: tagVal, + TagPointer: tagPtr, + MustValidate: mustValidate, + ResultAttr: codegen.Goify(origin, true), + ViewedResult: md.ViewedResult, + ViewedRepresentations: representations, }) } } @@ -2017,14 +2235,109 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A return responses } +// buildResponseResultInit builds the data used to write one client result +// function. It uses the name chosen by NewPlans and converts the decoded HTTP +// body, headers, and cookies into the method result. +func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, resAttr, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { + var ( + svc = sd.Service + md = svc.Method(e.Name()) + httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) + ) + if md.ViewedResult != nil { + svcctx = sds.viewTypeContext(sd, "client").Enter(result) + } + tname := svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + tref := svcctx.Scope.Ref(result, svcctx.Pkg(result)) + status := codegen.Goify(http.StatusText(resp.StatusCode), true) + declaration := sds.viewedResultConstructors[viewedConstructorKey{endpoint: e, response: resp, view: view}] + if declaration == nil { + panic(fmt.Sprintf("result constructor for %s.%s view %q was not submitted", svc.Name, e.Name(), view)) + } + name := declaration.Name() + desc := fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) + + var ( + code string + pointer bool + clientArgs []*InitArgData + ) + if clientBody.Type != expr.Empty { + if origin != "" { + pointer = svcctx.IsPrimitivePointer(origin, result) + } + ref := "body" + if expr.IsObject(clientBody.Type) { + ref = "&body" + pointer = false + } + var validate string + if ut, ok := clientBody.Type.(expr.UserType); ok && ut.Attribute().Validation != nil { + validate = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") + } + bodyTypeRef := bodyType.Ref + if bodyTypeRef == "" { + bodyTypeRef = bodyType.VarName + } + clientArgs = []*InitArgData{{ + Ref: ref, + AttributeData: &AttributeData{ + Name: "body", + VarName: "body", + TypeRef: bodyTypeRef, + Validate: validate, + }, + }} + transformctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transformctx.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, wireTypePolicy{ + pointer: transformctx.Pointer, + view: bodyType.View, + }) + converted, helpers, err := sd.clientWireTypes.renderTransform(clientBody, resAttr, "body", "v", "unmarshal", transformctx, svcctx) + if err != nil { + panic(err) // bug + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { + if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { + converted, helpers, err := sd.clientWireTypes.renderTransform((*params)[0].Attribute, result, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", httpclictx, svcctx) + if err != nil { + panic(err) // bug + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + } + for _, header := range headers { + clientArgs = append(clientArgs, resultInitArg(header.Element)) + } + for _, cookie := range cookies { + clientArgs = append(clientArgs, resultInitArg(cookie.Element)) + } + return &InitData{ + Declaration: declaration, + Name: name, + Description: desc, + ClientArgs: clientArgs, + ReturnTypeName: tname, + ReturnTypeRef: tref, + ReturnIsStruct: expr.IsObject(result.Type), + ReturnTypeAttribute: codegen.Goify(origin, true), + ReturnTypePkg: svcctx.Pkg(result), + ReturnIsPrimitivePointer: pointer, + ClientCode: code, + } +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response headers, cookies and body for each response // are inferred from the method's error expression if not specified explicitly. func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceData) []*ErrorGroupData { var ( svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - httpclictx = httpContext(sd.clientWireTypes.scope, false, false) + httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) ) data := make(map[string][]*ErrorData) @@ -2048,7 +2361,11 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa isObject bool args []*InitArgData ) - name = fmt.Sprintf("New%s%s", codegen.Goify(ep.Name, true), codegen.Goify(v.ErrorExpr.Name, true)) + declaration := sds.errorConstructors[v] + if declaration == nil { + panic(fmt.Sprintf("error constructor for %s.%s error %q was not submitted", svc.Name, e.Name(), v.Name)) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint %s error.", name, svc.Name, e.Name(), v.ErrorExpr.Name) headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) @@ -2071,7 +2388,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa if bodyRecord != nil { bodyTypeRef = bodyRecord.ref } else { - bodyTypeRef = sd.clientWireTypes.scope.GoTypeRef(respBody) + bodyTypeRef = httpclictx.Scope.Ref(respBody, "") } args = append(args, &InitArgData{ Ref: ref, @@ -2100,15 +2417,15 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } var helpers []*codegen.TransformFunctionData - transformctx := httpContext(sd.clientWireTypes.scope.Fork(), false, false) - code, helpers, err = unmarshal(respBody, eAtt, "body", transformctx, errctx) + transformctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + code, helpers, err = sd.clientWireTypes.renderTransform(respBody, eAtt, "body", "v", "unmarshal", transformctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(v.Type) || expr.IsMap(v.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal((*params)[0].Attribute, errorAttribute, codegen.Goify((*params)[0].Name, false), httpclictx, errctx) + code, helpers, err = sd.clientWireTypes.renderTransform((*params)[0].Attribute, errorAttribute, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", httpclictx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2119,6 +2436,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ClientArgs: args, @@ -2253,7 +2571,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * svc = sd.Service catalog = sd.wireTypes(svr) policy = wireTypePolicy{request: true, pointer: svr, useDefault: !svr, validate: true} - httpctx = httpContext(catalog.scope, true, svr) + httpctx = wireHTTPContext(catalog, catalog.scope, true, svr) side = "client" ) if svr { @@ -2265,14 +2583,15 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * catalog.applyNames(body, wireRequestBody, policy) name = body.Type.Name() if record != nil { + name = record.name ref = record.ref } else { - ref = catalog.scope.GoTypeRef(body) + ref = httpctx.Scope.Ref(body, "") } if ut, ok := body.Type.(expr.UserType); ok { varname = record.name - def = goTypeDef(catalog.scope.Fork(), ut.Attribute(), svr, !svr) + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP request body.", varname, svc.Name, e.Name()) if svr { @@ -2284,7 +2603,9 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * } } else { // Generate validation code first because inline struct validation is removed. - ctx := codegen.NewAttributeContext(!expr.IsPrimitive(body.Type), false, !svr, "", catalog.scope) + ctx := wireHTTPContext(catalog, catalog.scope, true, svr) + ctx.Pointer = !expr.IsPrimitive(body.Type) + ctx.UseDefault = !svr validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") if svr && expr.IsObject(body.Type) { // Body is an explicit object described in the design and in @@ -2293,7 +2614,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // generating the server body type pre-validation. body.Validation = nil } - varname = catalog.scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description } var init *InitData @@ -2310,7 +2631,11 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * svc = sd.Service ) { - name = fmt.Sprintf("New%s", codegen.Goify(catalog.scope.GoTypeName(body), true)) + if record != nil { + name = fmt.Sprintf("New%s", record.name) + } else { + name = fmt.Sprintf("New%s", codegen.Goify(httpctx.Scope.Name(body, "", httpctx.Pointer, httpctx.UseDefault), true)) + } desc = fmt.Sprintf("%s builds the HTTP request body from the payload of the %q endpoint of the %q service.", name, e.Name(), svc.Name) src := sourceVar @@ -2323,8 +2648,8 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - transformctx := httpContext(catalog.scope.Fork(), true, svr) - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, transformctx) + transformctx := wireHTTPContext(catalog, catalog.scope, true, svr) + code, helpers, err = catalog.renderTransform(srcAtt, body, src, "body", "marshal", svcctx, transformctx) if err != nil { panic(err) // bug } @@ -2402,9 +2727,8 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e svcctx := sds.serviceTypeContext(sd, side).Enter(att) catalog := sd.wireTypes(svr) policy := wireTypePolicy{pointer: !svr, useDefault: svr, validate: !svr && view == nil, view: viewName} - // Build nested declarations before package names are applied to body. Each - // nested lookup consumes the collected authored shape, then applies the - // frozen names to its own detached occurrence. + // Add each nested named field before body receives its chosen Go names. This + // keeps each copied request or response field tied to its own definition. topLevel, _ := body.Type.(expr.UserType) collectUserTypes(body.Type, func(ut expr.UserType) { if topLevel != nil && ut == topLevel { @@ -2420,19 +2744,20 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e }) record := catalog.lookupUser(body, wireResponseBody, policy) catalog.applyNames(body, wireResponseBody, policy) - httpctx := httpContext(catalog.scope, false, svr) + httpctx := wireHTTPContext(catalog, catalog.scope, false, svr) name = body.Type.Name() if record != nil { + name = record.name ref = record.ref } else { - ref = catalog.scope.GoTypeRef(body) + ref = httpctx.Scope.Ref(body, "") } mustInit = att.Type != expr.Empty && needInit(body.Type) if ut, ok := body.Type.(expr.UserType); ok { // response body is a user type. varname = record.name - def = goTypeDef(catalog.scope.Fork(), ut.Attribute(), !svr, svr) + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) if !svr && view == nil { @@ -2465,19 +2790,23 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e name = record.name desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) - def = goTypeDef(catalog.scope.Fork(), body, !svr, svr) + def = goTypeDefForContext(body, httpctx) } else { - varname = catalog.scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description def = "" } - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } } else { // response body is a primitive type. They are used as non-pointers when // encoding/decoding responses. - httpctx = httpContext(catalog.scope, false, true) - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") - varname = catalog.scope.GoTypeRef(body) + httpctx = wireHTTPContext(catalog, catalog.scope, false, true) + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } + varname = httpctx.Scope.Ref(body, "") desc = body.Description } var init *InitData @@ -2500,7 +2829,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e rtname = codegen.Goify(e.Name(), true) + "ResponseBody" rtref = rtname } else { - rtname = codegen.Goify(catalog.scope.GoTypeName(body), true) + rtname = record.name rtref = ref } name = fmt.Sprintf("New%s", rtname) @@ -2519,8 +2848,9 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - transformctx := httpContext(catalog.scope.Fork(), false, svr) - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, transformctx) + transformctx := wireHTTPContext(catalog, catalog.scope, false, svr) + transformctx.Scope = catalog.resolver(catalog.scope, policy) + code, helpers, err = catalog.renderTransform(srcAtt, body, src, "body", "marshal", svcctx, transformctx) if err != nil { panic(err) // bug } @@ -2695,14 +3025,19 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt } att := makeHTTPType(attr) var ( - varn = scope.Name(codegen.Goify(name, false)) - typeRef = scope.GoTypeRef(att) - ft = svcAtt.Type + varn = scope.Name(codegen.Goify(name, false)) + typeRef = scope.GoTypeRef(att) + elemTypeRef string + ft = svcAtt.Type slice bool pointer bool fptr bool ) + if arr := expr.AsArray(att.Type); arr != nil { + elemCtx := svcCtx.Enter(arr.ElemType) + elemTypeRef = elemCtx.Scope.Ref(arr.ElemType, elemCtx.Pkg(arr.ElemType)) + } if kind != cookieElement { slice = expr.AsArray(att.Type) != nil } @@ -2751,6 +3086,7 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt Type: att.Type, TypeName: scope.GoTypeName(att), TypeRef: typeRef, + ElemTypeRef: elemTypeRef, Pointer: pointer, Validate: validate, IsTextUnmarshaler: isText, @@ -2831,15 +3167,10 @@ func collectUserTypesRecursive(dt expr.DataType, cb func(expr.UserType), seen ma } } -// effectiveClientResponseBody returns the response body shape used by client -// code generation. When the design fixes the response to a single view, the -// returned attribute uses that projected ResultType so type collection, union -// collection, and client decode/init all agree on one transport body. -func effectiveClientResponseBody(body *expr.AttributeExpr, e *expr.HTTPEndpointExpr, md *service.MethodData) *expr.AttributeExpr { - view := clientResponseViewName(e, md) - if view == "" { - return body - } +// effectiveClientResponseBodyForView returns a copied response body containing +// the fields visible in one selected view. Type naming and client decoding both +// use this copy so they cannot disagree about its fields. +func effectiveClientResponseBodyForView(body *expr.AttributeExpr, view string) *expr.AttributeExpr { body = expr.DupAtt(body) rt, ok := body.Type.(*expr.ResultTypeExpr) if !ok { @@ -2870,11 +3201,23 @@ func clientResponseViewName(e *expr.HTTPEndpointExpr, md *service.MethodData) st return "" } -func buildHTTPUnionTypeData(u *expr.Union, scope *codegen.NameScope, record *wireUnionRecord) *service.UnionTypeData { +// clientResponseViewNameExpr returns the one view selected by the HTTP design. +// An empty result means each streamed response may name any allowed view. +func clientResponseViewNameExpr(e *expr.HTTPEndpointExpr, result *expr.ResultTypeExpr) string { + if view, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + return view + } + if len(result.Views) == 1 { + return result.Views[0].Name + } + return "" +} + +func buildHTTPUnionTypeData(u *expr.Union, scope codegen.Attributor, record *wireUnionRecord) *service.UnionTypeData { fields := make([]*service.UnionFieldData, len(u.Values)) for i, nat := range u.Values { fieldName := codegen.Goify(nat.Name, true) - fieldType := scope.GoTypeRef(nat.Attribute) + fieldType := scope.Ref(nat.Attribute, scope.Package(nat.Attribute)) fields[i] = &service.UnionFieldData{ Name: nat.Name, KindConst: record.kindConsts[i], @@ -2919,7 +3262,7 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve ut = att.Type.(expr.UserType) record := catalog.lookupUser(att, wireAttribute, policy) catalog.applyNames(att, wireAttribute, policy) - hctx := httpContext(catalog.scope, req, server) + hctx := wireHTTPContext(catalog, catalog.scope, req, server) name = record.name ctx := "request" if !req { @@ -2939,7 +3282,7 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve Name: ut.Name(), VarName: name, Description: desc, - Def: goTypeDef(catalog.scope.Fork(), ut.Attribute(), ptr, hctx.UseDefault), + Def: goTypeDefForContext(ut.Attribute(), hctx), Ref: record.ref, ValidateDef: validate, ValidateRef: validateRef, @@ -2947,7 +3290,7 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve }) } -// wireTypes returns the catalog for the generated server or client package. +// wireTypes returns the request and response types for the server or client package. func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { if server { return sd.serverWireTypes @@ -2955,8 +3298,8 @@ func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { return sd.clientWireTypes } -// hctxUseDefault mirrors httpContext's wire default policy without creating a -// second scope during declaration collection. +// hctxUseDefault reports whether missing HTTP values receive their design +// defaults for the selected request or response side. func hctxUseDefault(request, server bool) bool { return !request && server || request && !server } @@ -2979,8 +3322,21 @@ func httpContext(scope *codegen.NameScope, request, svr bool) *codegen.Attribute return ctx } -// serviceTypeContext returns a context that resolves service declarations from -// the generated transport package for side. +// wireHTTPContext returns the pointer and default-value rules for one generated +// HTTP package. It maps each copied field to the Go type name chosen for +// that particular request or response. +func wireHTTPContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := httpContext(scope, request, server) + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + }) + return context +} + +// serviceTypeContext returns the service type names as referenced from the +// generated client or server package named by side. func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) return &codegen.AttributeContext{ @@ -2989,8 +3345,8 @@ func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codeg } } -// viewTypeContext returns a context that resolves projected and viewed result -// declarations from the generated transport package for side. +// viewTypeContext returns the result-view type names as referenced from the +// generated client or server package named by side. func (sds *ServicesData) viewTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) return &codegen.AttributeContext{ diff --git a/http/codegen/service_data_purity_test.go b/http/codegen/service_data_purity_test.go index cceb97d271..0ac13dfaf6 100644 --- a/http/codegen/service_data_purity_test.go +++ b/http/codegen/service_data_purity_test.go @@ -82,9 +82,9 @@ func TestAnalyzeLeavesDesignExpressionsUnchanged(t *testing.T) { } } - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } for _, svc := range root.API.HTTP.Services { diff --git a/http/codegen/service_data_union_nilability_test.go b/http/codegen/service_data_union_nilability_test.go index 56f7b20eb8..1f6dc21769 100644 --- a/http/codegen/service_data_union_nilability_test.go +++ b/http/codegen/service_data_union_nilability_test.go @@ -19,7 +19,7 @@ func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { kindConsts: []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"}, constructors: []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"}, } - data := buildHTTPUnionTypeData(union, codegen.NewNameScope(), record) + data := buildHTTPUnionTypeData(union, codegen.NewAttributeScope(codegen.NewNameScope()), record) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index b1a504a389..49bb269511 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -54,8 +54,8 @@ func TestCollectHTTPUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { }, } - forwardNames := collectHTTPUnionTypeNames(forward) - reverseNames := collectHTTPUnionTypeNames(reverse) + forwardNames := collectHTTPUnionTypeNames(t, forward) + reverseNames := collectHTTPUnionTypeNames(t, reverse) require.Len(t, forwardNames, 2) require.Equal(t, forwardNames, reverseNames) @@ -77,9 +77,9 @@ func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testi }, } - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) catalog.collect(bodies, wireAttribute, wireTypePolicy{}, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(bodies, wireAttribute, wireTypePolicy{}) emitted := make([]string, 0, len(catalog.unions)) @@ -87,8 +87,8 @@ func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testi emitted = append(emitted, union.Name) } references := []string{ - catalog.scope.GoTypeName(&expr.AttributeExpr{Type: first}), - catalog.scope.GoTypeName(&expr.AttributeExpr{Type: second}), + catalog.resolver(catalog.scope, wireTypePolicy{}).Name(&expr.AttributeExpr{Type: first}, "", false, false), + catalog.resolver(catalog.scope, wireTypePolicy{}).Name(&expr.AttributeExpr{Type: second}, "", false, false), } require.Equal(t, []string{"Value"}, emitted) require.Equal(t, []string{"Value", "Value"}, references) @@ -116,7 +116,7 @@ func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { }) }) - data := CreateHTTPServices(root).Get("values") + data := linkedHTTPPlanForRoot(t, root).services.Get("values") require.NotNil(t, data) for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { unions := catalog.unionTypes() @@ -124,10 +124,10 @@ func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { for i, union := range unions { emitted[i] = union.Name } - require.Equal(t, []string{"Value", "Value2"}, emitted) + require.Equal(t, []string{"Value"}, emitted) } require.Contains(t, data.Endpoint("first").Payload.Request.ServerBody.Def, "Value *Value ") - require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value2 ") + require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value ") } func TestMakeHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { @@ -197,10 +197,11 @@ func sameShapedValueUnionDSL() { dsl.Attribute("number", dsl.Float64) } -func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { - catalog := newWireTypeCatalog() +func collectHTTPUnionTypeNames(t *testing.T, att *expr.AttributeExpr) map[string]string { + t.Helper() + catalog, generation := testWireTypeCatalog(t) catalog.collect(att, wireAttribute, wireTypePolicy{}, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) names := make(map[string]string, len(catalog.unions)) for _, record := range catalog.unions { diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go index 307a2c8453..bc46927d3d 100644 --- a/http/codegen/service_imports.go +++ b/http/codegen/service_imports.go @@ -18,14 +18,14 @@ func addEndpointImports(file *codegen.File, services *ServicesData, endpoints .. } outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, ServiceReferenceAttributes(endpoints...)...)...) + codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, serviceReferenceAttributes(endpoints...)...)...) return file } -// ServiceReferenceAttributes returns the named service attributes referenced +// serviceReferenceAttributes returns the named service attributes referenced // by generated HTTP or JSON-RPC endpoint sections, including the nested result // field selected as SSE event data. -func ServiceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { +func serviceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { var attributes []*expr.AttributeExpr for _, endpoint := range endpoints { method := endpoint.MethodExpr diff --git a/http/codegen/sse.go b/http/codegen/sse.go index 2068abe331..8a8524e129 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -1,6 +1,6 @@ -// This file builds HTTP server-sent event render data. Service event values -// use frozen service declarations while encoded response bodies remain owned -// by the HTTP transport package. +// This file builds the values used to write HTTP server-sent event code. +// Service event types keep the names chosen earlier, while the HTTP package +// defines the request and response body types. package codegen import ( @@ -17,9 +17,14 @@ type ( // SSEData contains the data needed to render struct type that // implements the server and client stream interface for SSE. SSEData struct { - // StructName is the name of the generated struct which encapsulates the - // server implementation. - StructName string + // StructDeclaration is the package name used by the server stream type. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration is the package name used by the client stream interface. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration is the package name used by the client stream implementation. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the package name used by the client stream constructor. + ClientInitDeclaration *codegen.NameDeclaration // Interface is the fully qualified name of the interface that // the struct implements. Interface string @@ -33,7 +38,7 @@ type ( SendWithContextDesc string // EventTypeRef is the fully qualified type ref for the event type. EventTypeRef string - // EventTypeName is the name of the event type without package qualifier. + // EventTypeName is the event type name without its Go package name. EventTypeName string // EventIsStruct indicates whether the SSE method return type is a struct. EventIsStruct bool @@ -58,6 +63,14 @@ type ( RequestIDPointer bool // HasResponseBody indicates whether an HTTP response body converter exists for this endpoint. HasResponseBody bool + // Response is the successful HTTP response whose body types encode and + // decode stream events. + Response *ResponseData + // VariableView reports whether SetView selects the result body used by all + // events sent for one HTTP request. + VariableView bool + // DefaultView is used when SetView receives an empty string. + DefaultView string } ) @@ -118,7 +131,6 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, } ed.SSE = &SSEData{ - StructName: md.ServerStream.VarName, Interface: fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface), SendName: md.ServerStream.SendName, SendDesc: sendDesc, @@ -134,6 +146,21 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, RetryField: retryFieldVar, RequestIDField: e.SSE.RequestIDField, RequestIDPointer: ridPtr, + VariableView: md.ViewedResult != nil && md.ViewedResult.ViewName == "", + } + if ed.SSE.VariableView { + for _, view := range md.ViewedResult.Views { + if view.Name == expr.DefaultView { + ed.SSE.DefaultView = view.Name + break + } + } + if ed.SSE.DefaultView == "" { + panic(fmt.Sprintf("viewed SSE method %q has no default view", md.Name)) + } + } + if len(ed.Result.Responses) > 0 { + ed.SSE.Response = ed.Result.Responses[0] } // Mixed results SSE uses the streaming result type for events, not the unary @@ -173,6 +200,9 @@ func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F {Path: "fmt"}, services.ServiceImport(svc.Name()), } + if serviceHasVariableViewedResult(data, IsSSEEndpoint) { + imports = append(imports, codegen.GoaImport("")) + } sections = append(sections, codegen.Header( "sse", @@ -231,3 +261,17 @@ func IsSSEEndpoint(ed *EndpointData) bool { func HasSSE(data *ServiceData) bool { return slices.ContainsFunc(data.Endpoints, IsSSEEndpoint) } + +// serviceHasVariableViewedResult reports whether a selected endpoint carries +// one of multiple legal views at runtime. +func serviceHasVariableViewedResult(service *ServiceData, selected func(*EndpointData) bool) bool { + for _, endpoint := range service.Endpoints { + if selected != nil && !selected(endpoint) { + continue + } + if endpoint.Method.ViewedResult != nil && endpoint.Method.ViewedResult.ViewName == "" { + return true + } + } + return false +} diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index c243e59bf6..f30fadc859 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -32,6 +32,12 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F services.ServiceImport(svc.Name()), {Path: "goa.design/goa/v3/http", Name: "goahttp"}, } + if serviceHasViewedResult(data, IsSSEEndpoint) { + imports = append(imports, services.ViewImport(svc.Name())) + } + if serviceHasVariableViewedResult(data, IsSSEEndpoint) || serviceHasSSEResponseElements(data) { + imports = append(imports, codegen.GoaImport("")) + } sections = append(sections, codegen.Header( "sse-client", @@ -52,10 +58,13 @@ func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate { } sections = append(sections, &codegen.SectionTemplate{ Name: "client-sse", - Source: httpTemplates.Read(clientSseT, sseParseP), + Source: httpTemplates.Read(clientSseT, sseParseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), Data: ed, FuncMap: map[string]any{ "dict": dict, + "goTypeRef": func(dataType expr.DataType) string { + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dataType}) + }, "deref": func(ref string) string { return strings.TrimPrefix(ref, "*") }, @@ -64,3 +73,15 @@ func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate { } return sections } + +// serviceHasSSEResponseElements reports whether a stream constructor reads +// values from HTTP response headers or cookies in addition to event data. +func serviceHasSSEResponseElements(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.SSE != nil && endpoint.SSE.Response != nil && + (len(endpoint.SSE.Response.Headers) > 0 || len(endpoint.SSE.Response.Cookies) > 0) { + return true + } + } + return false +} diff --git a/http/codegen/sse_client_test.go b/http/codegen/sse_client_test.go index 66c640e84e..590813a1c6 100644 --- a/http/codegen/sse_client_test.go +++ b/http/codegen/sse_client_test.go @@ -30,8 +30,8 @@ func TestSSEClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/sse_mixed_results_test.go b/http/codegen/sse_mixed_results_test.go index 7dbd0c8364..80169372ec 100644 --- a/http/codegen/sse_mixed_results_test.go +++ b/http/codegen/sse_mixed_results_test.go @@ -14,10 +14,10 @@ import ( func TestSSE_MixedResults(t *testing.T) { root := expr.RunDSL(t, testdata.MixedResultsDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) t.Run("server", func(t *testing.T) { - files := ServerFiles(services) + files := plan.ServerFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("server", "sse.go")) { @@ -36,7 +36,7 @@ func TestSSE_MixedResults(t *testing.T) { }) t.Run("client", func(t *testing.T) { - files := ClientFiles(services) + files := plan.ClientFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("client", "sse.go")) { diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go index 8f6ec0278c..2382119cbc 100644 --- a/http/codegen/sse_server_test.go +++ b/http/codegen/sse_server_test.go @@ -30,8 +30,8 @@ func TestSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -44,8 +44,8 @@ func TestSSE(t *testing.T) { func TestSSETransportDefaultsToStatusOK(t *testing.T) { root := expr.RunDSL(t, testdata.SSEStringDSL) - services := CreateHTTPServices(root) - fs := ServerFiles(services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates diff --git a/http/codegen/streaming_test.go b/http/codegen/streaming_test.go index cc4539e069..7a73958e05 100644 --- a/http/codegen/streaming_test.go +++ b/http/codegen/streaming_test.go @@ -205,8 +205,7 @@ func TestServerStreaming(t *testing.T) { } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ServerFiles(services) + return linkedHTTPPlanForRoot(t, root).ServerFiles() } runTests(t, cases, filesFn) } @@ -388,8 +387,7 @@ func TestClientStreaming(t *testing.T) { }}, } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ClientFiles(services) + return linkedHTTPPlanForRoot(t, root).ClientFiles() } runTests(t, cases, filesFn) } diff --git a/http/codegen/symbols.go b/http/codegen/symbols.go new file mode 100644 index 0000000000..f3f07b2786 --- /dev/null +++ b/http/codegen/symbols.go @@ -0,0 +1,396 @@ +// This file requests the Go names written by generated HTTP client and server +// files. NewPlans calls it before names are assigned, and Link later gives the +// same records to every definition and use. +package codegen + +import ( + "cmp" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // httpSymbols contains every package-level name emitted for one HTTP service. + httpSymbols struct { + serverStruct *codegen.NameDeclaration + mountPoint *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + mountServer *codegen.NameDeclaration + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverConfigurer *codegen.NameDeclaration + serverConfigurerInit *codegen.NameDeclaration + clientConfigurer *codegen.NameDeclaration + clientConfigurerInit *codegen.NameDeclaration + appendFS *codegen.NameDeclaration + appendPrefix *codegen.NameDeclaration + endpoints map[*expr.HTTPEndpointExpr]*httpEndpointSymbols + fileServers map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration + } + + // httpEndpointSymbols contains the package-level names emitted for one endpoint. + httpEndpointSymbols struct { + mountHandler *codegen.NameDeclaration + handlerInit *codegen.NameDeclaration + requestDecoder *codegen.NameDeclaration + responseEncoder *codegen.NameDeclaration + errorEncoder *codegen.NameDeclaration + discardStream *codegen.NameDeclaration + requestEncoder *codegen.NameDeclaration + responseDecoder *codegen.NameDeclaration + buildStreamPayload *codegen.NameDeclaration + cliPayload *codegen.NameDeclaration + serverMultipart *httpMultipartSymbols + clientMultipart *httpMultipartSymbols + serverStream *codegen.NameDeclaration + clientStream *codegen.NameDeclaration + sseClientInterface *codegen.NameDeclaration + sseClientStruct *codegen.NameDeclaration + sseClientInit *codegen.NameDeclaration + serverPaths []*codegen.NameDeclaration + clientPaths []*codegen.NameDeclaration + } + + // httpMultipartSymbols contains the type and constructor names for one side + // of a multipart endpoint. + httpMultipartSymbols struct { + functionType *codegen.NameDeclaration + constructor *codegen.NameDeclaration + } + + // httpSymbolID identifies one emitted declaration without encoding fields in + // a string. The output package is supplied separately by the caller. + httpSymbolID struct { + transport transportKind + role httpSymbolRole + service string + method string + subject string + index int + } + + // httpSymbolOrder gives colliding declarations the same result regardless of + // the order in which design roots are passed to NewPlans. + httpSymbolOrder httpSymbolID + + // httpSymbolRole lists each package declaration emitted outside HTTP body + // type and constructor files. + httpSymbolRole uint8 +) + +const ( + httpServerStructRole httpSymbolRole = iota + 1 + httpMountPointRole + httpServerInitRole + httpMountServerRole + httpClientStructRole + httpClientInitRole + httpConnConfigurerRole + httpConnConfigurerInitRole + httpAppendFSRole + httpAppendPrefixRole + httpMountHandlerRole + httpHandlerInitRole + httpRequestDecoderRole + httpResponseEncoderRole + httpErrorEncoderRole + httpDiscardStreamRole + httpRequestEncoderRole + httpResponseDecoderRole + httpBuildStreamPayloadRole + httpCLIPayloadRole + httpMultipartTypeRole + httpMultipartInitRole + httpServerStreamRole + httpClientStreamRole + httpSSEClientInterfaceRole + httpSSEClientStructRole + httpSSEClientInitRole + httpPathRole + httpFileMountRole +) + +// collectHTTPSymbols requests each client and server name needed by service. It +// returns records that Link gives to definitions and calls after Goa assigns +// the names. +func collectHTTPSymbols(plan *Plan, service *expr.HTTPServiceExpr, clientPackage, serverPackage *codegen.GeneratedPackage) (*httpSymbols, error) { + symbols := &httpSymbols{ + endpoints: make(map[*expr.HTTPEndpointExpr]*httpEndpointSymbols), + fileServers: make(map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, exported codegen.PackageNameVisibility, id httpSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, exported, httpSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := httpSymbolID{transport: plan.transport, service: service.Name()} + var err error + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.withRole(httpServerStructRole)); err != nil { + return nil, err + } + if symbols.mountPoint, err = declare(serverPackage, codegen.NameType, "MountPoint", codegen.ExportedName, serviceID.withRole(httpMountPointRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.withRole(httpServerInitRole)); err != nil { + return nil, err + } + if symbols.mountServer, err = declare(serverPackage, codegen.NameFunction, "Mount", codegen.ExportedName, serviceID.withRole(httpMountServerRole)); err != nil { + return nil, err + } + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.withRole(httpClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.withRole(httpClientInitRole)); err != nil { + return nil, err + } + + hasWebSocket := false + for _, endpoint := range service.HTTPEndpoints { + if endpoint.UsesWebSocket() { + hasWebSocket = true + break + } + } + if hasWebSocket { + if symbols.serverConfigurer, err = declare(serverPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.serverConfigurerInit, err = declare(serverPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.clientConfigurer, err = declare(clientPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("client")); err != nil { + return nil, err + } + if symbols.clientConfigurerInit, err = declare(clientPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("client")); err != nil { + return nil, err + } + } + if len(service.FileServers) > 0 { + if symbols.appendFS, err = declare(serverPackage, codegen.NameType, "appendFS", codegen.UnexportedName, serviceID.withRole(httpAppendFSRole)); err != nil { + return nil, err + } + if symbols.appendPrefix, err = declare(serverPackage, codegen.NameFunction, "appendPrefix", codegen.UnexportedName, serviceID.withRole(httpAppendPrefixRole)); err != nil { + return nil, err + } + } + for index, fileServer := range service.FileServers { + id := serviceID.withRole(httpFileMountRole).withSubject(fileServer.FilePath).withIndex(index) + declaration, err := declare(serverPackage, codegen.NameFunction, "Mount"+codegen.Goify(fileServer.FilePath, true), codegen.ExportedName, id) + if err != nil { + return nil, err + } + symbols.fileServers[fileServer] = declaration + } + for _, endpoint := range service.HTTPEndpoints { + names, err := plan.servicePlan.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.MethodExpr.Name) + endpointSymbols := &httpEndpointSymbols{} + endpointSymbols.mountHandler, err = declare(serverPackage, codegen.NameFunction, "Mount"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpMountHandlerRole)) + if err != nil { + return nil, err + } + endpointSymbols.handlerInit, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpHandlerInitRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.requestDecoder, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestDecoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.Redirect == nil && !endpoint.UsesWebSocket() && !endpoint.IsJSONRPC() { + endpointSymbols.responseEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseEncoderRole)) + if err != nil { + return nil, err + } + } + if len(endpoint.HTTPErrors) > 0 && !endpoint.IsJSONRPC() { + endpointSymbols.errorEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Error", codegen.ExportedName, id.withRole(httpErrorEncoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.HasMixedResults() { + endpointSymbols.discardStream, err = declare(serverPackage, codegen.NameType, "discard"+names.Method+"ServerStream", codegen.UnexportedName, id.withRole(httpDiscardStreamRole)) + if err != nil { + return nil, err + } + } + if clientRequestEncoderSelected(endpoint) { + endpointSymbols.requestEncoder, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestEncoderRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.responseDecoder, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseDecoderRole)) + if err != nil { + return nil, err + } + if endpoint.SkipRequestBodyEncodeDecode { + endpointSymbols.buildStreamPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"StreamPayload", codegen.ExportedName, id.withRole(httpBuildStreamPayloadRole)) + if err != nil { + return nil, err + } + } + if needInit(endpoint.MethodExpr.Payload.Type) { + endpointSymbols.cliPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Payload", codegen.ExportedName, id.withRole(httpCLIPayloadRole)) + if err != nil { + return nil, err + } + } + if endpoint.MultipartRequest { + serviceName := codegen.Goify(service.Name(), true) + endpointSymbols.serverMultipart, err = declareHTTPMultipart(declare, serverPackage, serviceName+names.Method+"DecoderFunc", "New"+serviceName+names.Method+"Decoder", id.withSubject("server")) + if err != nil { + return nil, err + } + endpointSymbols.clientMultipart, err = declareHTTPMultipart(declare, clientPackage, serviceName+names.Method+"EncoderFunc", "New"+serviceName+names.Method+"Encoder", id.withSubject("client")) + if err != nil { + return nil, err + } + } + if endpoint.UsesWebSocket() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.withRole(httpClientStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.UsesSSE() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInterface, err = declare(clientPackage, codegen.NameType, names.Method+"ClientStream", codegen.ExportedName, id.withRole(httpSSEClientInterfaceRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientStruct, err = declare(clientPackage, codegen.NameType, names.Method+"StreamImpl", codegen.ExportedName, id.withRole(httpSSEClientStructRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInit, err = declare(clientPackage, codegen.NameFunction, "New"+names.Method+"Stream", codegen.ExportedName, id.withRole(httpSSEClientInitRole)) + if err != nil { + return nil, err + } + } + pathCount := 0 + for _, route := range endpoint.Routes { + for range route.FullPaths() { + suffix := "" + if pathCount > 0 { + suffix = strconv.Itoa(pathCount + 1) + } + preferred := names.Method + codegen.Goify(service.Name(), true) + "Path" + suffix + pathID := id.withRole(httpPathRole).withIndex(pathCount) + serverPath, err := declare(serverPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("server")) + if err != nil { + return nil, err + } + clientPath, err := declare(clientPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("client")) + if err != nil { + return nil, err + } + endpointSymbols.serverPaths = append(endpointSymbols.serverPaths, serverPath) + endpointSymbols.clientPaths = append(endpointSymbols.clientPaths, clientPath) + pathCount++ + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// declareHTTPMultipart requests the type and constructor emitted for one +// multipart endpoint side. +func declareHTTPMultipart(declare func(*codegen.GeneratedPackage, codegen.PackageNameKind, string, codegen.PackageNameVisibility, httpSymbolID) (*codegen.NameDeclaration, error), pkg *codegen.GeneratedPackage, typeName, initName string, id httpSymbolID) (*httpMultipartSymbols, error) { + functionType, err := declare(pkg, codegen.NameType, typeName, codegen.ExportedName, id.withRole(httpMultipartTypeRole)) + if err != nil { + return nil, err + } + constructor, err := declare(pkg, codegen.NameFunction, initName, codegen.ExportedName, id.withRole(httpMultipartInitRole)) + if err != nil { + return nil, err + } + return &httpMultipartSymbols{functionType: functionType, constructor: constructor}, nil +} + +// clientRequestEncoderSelected reports whether the client codec file writes a +// request encoder for endpoint. +func clientRequestEncoderSelected(endpoint *expr.HTTPEndpointExpr) bool { + if endpoint.IsJSONRPC() { + return true + } + if endpoint.SkipRequestBodyEncodeDecode { + return false + } + if endpoint.Body.Type != expr.Empty || endpoint.MapQueryParams != nil || + len(*expr.AsObject(endpoint.QueryParams().Type)) > 0 || + len(*expr.AsObject(endpoint.Headers.Type)) > 0 || + len(*expr.AsObject(endpoint.Cookies.Type)) > 0 { + return true + } + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind == expr.BasicAuthKind { + return true + } + } + } + return false +} + +// withRole returns id with the declaration role used by one template. +func (id httpSymbolID) withRole(role httpSymbolRole) httpSymbolID { + id.role = role + return id +} + +// withMethod returns id with the design method that emits the declaration. +func (id httpSymbolID) withMethod(method string) httpSymbolID { + id.method = method + return id +} + +// withSubject returns id with the route side or file path that distinguishes +// otherwise identical declarations. +func (id httpSymbolID) withSubject(subject string) httpSymbolID { + id.subject = subject + return id +} + +// withIndex returns id with the route or file position in its design list. +func (id httpSymbolID) withIndex(index int) httpSymbolID { + id.index = index + return id +} + +// ComparePackageName orders HTTP declarations by stable design values. +func (order httpSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + left := httpSymbolID(order) + right := httpSymbolID(other.(httpSymbolOrder)) + for _, compared := range []int{ + cmp.Compare(left.transport, right.transport), + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.role, right.role), + cmp.Compare(left.subject, right.subject), + cmp.Compare(left.index, right.index), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/templates/append_fs.go.tpl b/http/codegen/templates/append_fs.go.tpl index 0ae3643c9a..ce1b0caf7d 100644 --- a/http/codegen/templates/append_fs.go.tpl +++ b/http/codegen/templates/append_fs.go.tpl @@ -1,15 +1,14 @@ -// appendFS is a custom implementation of fs.FS that appends a specified prefix -// to the file paths before delegating the Open call to the underlying fs.FS. -type appendFS struct { +{{ printf "%s adds a fixed directory to file paths before opening them." .AppendFSDeclaration.Name | comment }} +type {{ .AppendFSDeclaration.Name }} struct { prefix string fs http.FileSystem } // Open opens the named file, appending the prefix to the file path before -// passing it to the underlying fs.FS. -func (s appendFS) Open(name string) (http.File, error) { +// passing it to the underlying file system. +func (s {{ .AppendFSDeclaration.Name }}) Open(name string) (http.File, error) { switch name { - {{- range $requested, $embedded := . }} + {{- range $requested, $embedded := .Mappings }} case {{ printf "%q" $requested }}: name = {{ printf "%q" $embedded }} {{- end }} @@ -17,8 +16,7 @@ func (s appendFS) Open(name string) (http.File, error) { return s.fs.Open(path.Join(s.prefix, name)) } -// appendPrefix returns a new fs.FS that appends the specified prefix to file paths -// before delegating to the provided embed.FS. -func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { - return appendFS{prefix: prefix, fs: fsys} +{{ printf "%s returns a file system that adds prefix before opening each path." .AppendPrefixDeclaration.Name | comment }} +func {{ .AppendPrefixDeclaration.Name }}(fsys http.FileSystem, prefix string) http.FileSystem { + return {{ .AppendFSDeclaration.Name }}{prefix: prefix, fs: fsys} } diff --git a/http/codegen/templates/build_stream_request.go.tpl b/http/codegen/templates/build_stream_request.go.tpl index 8d63f3c646..2101f434c3 100644 --- a/http/codegen/templates/build_stream_request.go.tpl +++ b/http/codegen/templates/build_stream_request.go.tpl @@ -1,5 +1,5 @@ -// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayload | comment }} -func {{ .BuildStreamPayload }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { +// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayloadDeclaration.Name | comment }} +func {{ .BuildStreamPayloadDeclaration.Name }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { f, err := os.Open(fpath) if err != nil { return nil, err diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl index 2cfed6fd8e..afb2c063e2 100644 --- a/http/codegen/templates/cli_end.go.tpl +++ b/http/codegen/templates/cli_end.go.tpl @@ -1,4 +1,4 @@ -endpoint, payload, err := {{ .CLIPkg }}.ParseEndpoint( +endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( scheme, host, doer, @@ -16,7 +16,7 @@ endpoint, payload, err := {{ .CLIPkg }}.ParseEndpoint( {{- range .Services }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncName }}, + {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- end }} diff --git a/http/codegen/templates/cli_usage.go.tpl b/http/codegen/templates/cli_usage.go.tpl index 7de9c399a7..9160a2b554 100644 --- a/http/codegen/templates/cli_usage.go.tpl +++ b/http/codegen/templates/cli_usage.go.tpl @@ -1,8 +1,8 @@ func {{ .VarPrefix }}UsageCommands() []string { - return {{ .CLIPkg }}.UsageCommands() + return {{ .CLIPkg }}.{{ .Parser.UsageCommands.Name }}() } func {{ .VarPrefix }}UsageExamples() string { - return {{ .CLIPkg }}.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } diff --git a/http/codegen/templates/client_endpoint_init.go.tpl b/http/codegen/templates/client_endpoint_init.go.tpl index 09ac4de957..2f53106c78 100644 --- a/http/codegen/templates/client_endpoint_init.go.tpl +++ b/http/codegen/templates/client_endpoint_init.go.tpl @@ -1,11 +1,11 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not .MultipartRequestEncoder) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes HTTP requests to the %s service %s server." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncName }}{{ end }}) goa.Endpoint { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncDeclaration.Name }}{{ end }}) goa.Endpoint { var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitName }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitDeclaration.Name }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) {{- end }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) ) {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { @@ -16,7 +16,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} err = encodeRequest(req, v) if err != nil { return nil, err @@ -51,7 +51,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder conn.Close() }() {{- end }} - stream := &{{ .ClientWebSocket.VarName }}{conn: conn} + stream := &{{ .ClientWebSocket.VarDeclaration.Name }}{conn: conn} {{- if .Method.ViewedResult }} {{- if not .Method.ViewedResult.ViewName }} view := resp.Header.Get("goa-view") @@ -81,7 +81,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) } - return New{{ .Method.VarName }}Stream(resp, c.decoder), nil + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil {{- else }} resp, err := c.{{ .Method.VarName }}Doer.Do(req) if err != nil { diff --git a/http/codegen/templates/client_init.go.tpl b/http/codegen/templates/client_init.go.tpl index 9d76771638..104d2877ea 100644 --- a/http/codegen/templates/client_init.go.tpl +++ b/http/codegen/templates/client_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s instantiates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, @@ -10,13 +10,13 @@ func New{{ .ClientStruct }}( dialer goahttp.Dialer, cfn *ConnConfigurer, {{- end }} -) *{{ .ClientStruct }} { +) *{{ .ClientStructDeclaration.Name }} { {{- if hasWebSocket . }} if cfn == nil { cfn = &ConnConfigurer{} } {{- end }} - return &{{ .ClientStruct }}{ + return &{{ .ClientStructDeclaration.Name }}{ {{- range .Endpoints }} {{ .Method.VarName }}Doer: doer, {{- end }} diff --git a/http/codegen/templates/client_sse.go.tpl b/http/codegen/templates/client_sse.go.tpl index ecba21651f..0d8c26b272 100644 --- a/http/codegen/templates/client_sse.go.tpl +++ b/http/codegen/templates/client_sse.go.tpl @@ -1,5 +1,5 @@ -// {{ .Method.VarName }}ClientStream is the interface for reading Server-Sent Events. -type {{ .Method.VarName }}ClientStream interface { +// {{ .SSE.ClientInterfaceDeclaration.Name }} is the interface for reading Server-Sent Events. +type {{ .SSE.ClientInterfaceDeclaration.Name }} interface { // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream with context. @@ -9,39 +9,45 @@ type {{ .Method.VarName }}ClientStream interface { } type ( - // {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. - {{ .Method.VarName }}StreamImpl struct { + // {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. + {{ .SSE.ClientStructDeclaration.Name }} struct { resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data lock sync.Mutex closed bool + {{- if .SSE.VariableView }} + view string + {{- end }} } ) -// {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. -var _ {{ .Method.VarName }}ClientStream = (*{{ .Method.VarName }}StreamImpl)(nil) +// {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. +var _ {{ .SSE.ClientInterfaceDeclaration.Name }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// {{ .Method.VarName }}StreamImpl implements the service client stream +// {{ .SSE.ClientStructDeclaration.Name }} implements the service client stream // interface so the generated endpoint client can return it directly. -var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .Method.VarName }}StreamImpl)(nil) +var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// New{{ .Method.VarName }}Stream creates a new {{ .Method.VarName }}ClientStream. -func New{{ .Method.VarName }}Stream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .Method.VarName }}ClientStream { - return &{{ .Method.VarName }}StreamImpl{ +// {{ .SSE.ClientInitDeclaration.Name }} creates a new {{ .SSE.ClientInterfaceDeclaration.Name }}. +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ resp: resp, decoder: decoder, buffer: make([]byte, 0, 4096), // Pre-allocate buffer + {{- if .SSE.VariableView }} + view: resp.Header.Get("goa-view"), + {{- end }} } } // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) } // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream, respecting context cancellation. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { var byts []byte byts, err = s.readEvent(ctx) if err != nil { @@ -61,7 +67,7 @@ func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvWithContex // the HTTP response body until it either finds an event boundary, reaches EOF, // or encounters an error. Any data after the event boundary is saved in the // buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size // Check for event in existing buffer @@ -139,7 +145,7 @@ func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte // contents if no complete event is found), and a boolean indicating whether a // complete event was found. If a complete event is found, any remaining data // after the event is kept in the buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -179,7 +185,7 @@ func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { } // Close closes the SSE stream and releases any associated resources. -func (s *{{ .Method.VarName }}StreamImpl) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() if s.closed { @@ -190,7 +196,7 @@ func (s *{{ .Method.VarName }}StreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { {{- if .SSE.EventIsStruct }} event = new({{ deref .SSE.EventTypeRef }}) {{- end }} @@ -224,13 +230,31 @@ func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event } {{- end }} } + {{- if .Method.ViewedResult }} + {{- template "viewed_sse_response_elements" . }} + {{- if .SSE.VariableView }} + view := s.view + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + default: + return event, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .Method.Name }}", goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} })) + } + {{- else }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} + {{- else }} if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") {{- if .SSE.DataField }} {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "TypeRef" .SSE.DataFieldTypeRef }} - {{- else }} - {{- if .SSE.EventIsStruct }} - // Decode JSON into the struct pointer directly + {{- else if .SSE.EventIsStruct }} + // Decode the event data into the result value returned by Recv. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), @@ -242,13 +266,151 @@ func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event {{- else }} {{ template "partial_sse_parse" dict "Target" "event" "TypeRef" .SSE.EventTypeRef }} {{- end }} - {{- end }} } + {{- end }} return } +{{- define "viewed_sse_client_result" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + {{- if .ClientBody }} + var body {{ .ClientBody.VarName }} + {{- if $endpoint.SSE.IDField }} + body.{{ $endpoint.SSE.IDField }} = event.{{ $endpoint.SSE.IDField }} + {{- end }} + {{- if $endpoint.SSE.EventField }} + body.{{ $endpoint.SSE.EventField }} = event.{{ $endpoint.SSE.EventField }} + {{- end }} + if len(dataLines) > 0 { + dataContent := strings.Join(dataLines, "\n") + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + {{- if $endpoint.SSE.DataField }} + if err = s.decoder(respBody).Decode(&body.{{ $endpoint.SSE.DataField }}); err != nil { + {{- else }} + if err = s.decoder(respBody).Decode(&body); err != nil { + {{- end }} + return event, goahttp.ErrDecodingError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + } + {{- end }} + projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }}, {{ end }}) + viewed := {{ if not $endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.VarName }}{Projected: projected, View: view} + if err = {{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.Validate.Declaration.Name }}(viewed); err != nil { + return event, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + result := {{ $endpoint.ServicePkgName }}.{{ $endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(viewed) + {{- if $endpoint.SSE.IDField }} + result.{{ $endpoint.SSE.IDField }} = event.{{ $endpoint.SSE.IDField }} + {{- end }} + {{- if $endpoint.SSE.EventField }} + result.{{ $endpoint.SSE.EventField }} = event.{{ $endpoint.SSE.EventField }} + {{- end }} + return result, nil + {{- end }} +{{- end }} + +{{- define "viewed_sse_response_elements" }} + {{- with .SSE.Response }} + {{- if .Headers }} + var ( + {{- range .Headers }} + {{ .VarName }} {{ .TypeRef }} + {{- end }} + ) + {{- range .Headers }} + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + if {{ .VarName }}Raw != "" { + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + } + {{- end }} + {{- else if .StringSlice }} + {{ .VarName }} = s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }} == nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + {{- else if .Slice }} + {{ .VarName }}Raw := s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }}Raw == nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != nil { + {{- template "partial_element_slice_conversion" . }} + } + {{- else }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- template "partial_query_type_conversion" . }} + } + {{- end }} + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- if .Cookies }} + var ( + {{- range .Cookies }} + {{ .VarName }} {{ .TypeRef }} + {{ .VarName }}Raw string + {{- end }} + ) + for _, cookie := range s.resp.Cookies() { + switch cookie.Name { + {{- range .Cookies }} + case {{ printf "%q" .HTTPName }}: + {{ .VarName }}Raw = cookie.Value + {{- end }} + } + } + {{- range .Cookies }} + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "cookie")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + {{- template "partial_query_type_conversion" . }} + {{- end }} + } + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + // trimHeader removes the header prefix and optional leading space -func (s *{{ .Method.VarName }}StreamImpl) trimHeader(size int, data []byte) string { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) trimHeader(size int, data []byte) string { if len(data) < size { return string(data) } diff --git a/http/codegen/templates/client_struct.go.tpl b/http/codegen/templates/client_struct.go.tpl index f7b286636e..01c41eab00 100644 --- a/http/codegen/templates/client_struct.go.tpl +++ b/http/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ printf "%s Doer is the HTTP client used to make requests to the %s endpoint." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }}Doer goahttp.Doer diff --git a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl index 132d6ba52c..233be556c4 100644 --- a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart decoder for service %q endpoint %q. The decoder must populate the argument p after encoding." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mr *multipart.Reader, p *{{ .Payload.Ref }}) error { +{{ printf "%s implements the multipart decoder for service %q endpoint %q. The decoder must populate the argument p after encoding." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mr *multipart.Reader, p *{{ .Payload.Ref }}) error { // Add multipart request decoder logic here return nil } diff --git a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl index ec588acd12..c27201f641 100644 --- a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { +{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { // Add multipart request encoder logic here return nil } diff --git a/http/codegen/templates/error_encoder.go.tpl b/http/codegen/templates/error_encoder.go.tpl index 31f48dbe6b..067e6fc0f6 100644 --- a/http/codegen/templates/error_encoder.go.tpl +++ b/http/codegen/templates/error_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoder .Method.Name .ServiceName | comment }} -func {{ .ErrorEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { +{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoderDeclaration.Name .Method.Name .ServiceName | comment }} +func {{ .ErrorEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { encodeError := goahttp.ErrorEncoder(encoder, formatter) return func(ctx context.Context, w http.ResponseWriter, v error) error { var en goa.GoaErrorNamer diff --git a/http/codegen/templates/file_server.go.tpl b/http/codegen/templates/file_server.go.tpl index f3b2d1bf06..8ad01c70c2 100644 --- a/http/codegen/templates/file_server.go.tpl +++ b/http/codegen/templates/file_server.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s configures the mux to serve GET request made to %q." .MountHandler (join .RequestPaths ", ") | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve GET request made to %q." .MountHandlerDeclaration.Name (join .RequestPaths ", ") | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { {{- if .IsDir }} {{- range .RequestPaths }} mux.Handle("GET", "{{ . }}{{if ne . "/"}}/{{end}}", h.ServeHTTP) diff --git a/http/codegen/templates/mount_point_struct.go.tpl b/http/codegen/templates/mount_point_struct.go.tpl index b4739ba2a7..d928733168 100644 --- a/http/codegen/templates/mount_point_struct.go.tpl +++ b/http/codegen/templates/mount_point_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s holds information about the mounted endpoints." .MountPointStruct | comment }} -type {{ .MountPointStruct }} struct { +{{ printf "%s holds information about the mounted endpoints." .MountPointStructDeclaration.Name | comment }} +type {{ .MountPointStructDeclaration.Name }} struct { {{ printf "Method is the name of the service method served by the mounted HTTP handler." | comment }} Method string {{ printf "Verb is the HTTP method used to match requests to the mounted handler." | comment }} diff --git a/http/codegen/templates/multipart_request_decoder.go.tpl b/http/codegen/templates/multipart_request_decoder.go.tpl index dab9d3ef8f..9c73bd1acb 100644 --- a/http/codegen/templates/multipart_request_decoder.go.tpl +++ b/http/codegen/templates/multipart_request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(mux goahttp.Muxer, {{ .VarName }} {{ .FuncName }}) func(r *http.Request) goahttp.Decoder { +{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(mux goahttp.Muxer, {{ .VarName }} {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() diff --git a/http/codegen/templates/multipart_request_decoder_type.go.tpl b/http/codegen/templates/multipart_request_decoder_type.go.tpl index 7dda99adff..28be9c5ac5 100644 --- a/http/codegen/templates/multipart_request_decoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_decoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Reader, *{{ .Payload.Ref }}) error +{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Reader, *{{ .Payload.Ref }}) error diff --git a/http/codegen/templates/multipart_request_encoder.go.tpl b/http/codegen/templates/multipart_request_encoder.go.tpl index 9fd9625b98..d75fea859a 100644 --- a/http/codegen/templates/multipart_request_encoder.go.tpl +++ b/http/codegen/templates/multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(encoderFn {{ .FuncName }}) func(r *http.Request) goahttp.Encoder { +{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(encoderFn {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Encoder { return func(r *http.Request) goahttp.Encoder { body := &bytes.Buffer{} mw := multipart.NewWriter(body) diff --git a/http/codegen/templates/multipart_request_encoder_type.go.tpl b/http/codegen/templates/multipart_request_encoder_type.go.tpl index c6633349fa..1e0c11ec34 100644 --- a/http/codegen/templates/multipart_request_encoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_encoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Writer, {{ .Payload.Ref }}) error +{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Writer, {{ .Payload.Ref }}) error diff --git a/http/codegen/templates/parse_endpoint.go.tpl b/http/codegen/templates/parse_endpoint.go.tpl index 64f098eead..01676b1371 100644 --- a/http/codegen/templates/parse_endpoint.go.tpl +++ b/http/codegen/templates/parse_endpoint.go.tpl @@ -1,6 +1,6 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( +func {{ .Declaration.Name }}( scheme, host string, doer goahttp.Doer, enc func(*http.Request) goahttp.Encoder, @@ -10,14 +10,14 @@ func ParseEndpoint( dialer goahttp.Dialer, {{- range .Commands }} {{- if .NeedDialer }} - {{ if .JSONRPC }}{{ .VarName }}ConfigFn goahttp.ConnConfigureFunc,{{ else }}{{ .VarName }}Configurer *{{ .PkgName }}.ConnConfigurer,{{ end }} + {{ if .JSONRPC }}{{ .VarName }}ConfigFn goahttp.ConnConfigureFunc,{{ else }}{{ .VarName }}Configurer *{{ .PkgName }}.{{ .Configurer.Name }},{{ end }} {{- end }} {{- end }} {{- end }} {{- range $i, $c := .Commands }} {{- range .Subcommands }} {{- if .MultipartVarName }} - {{ .MultipartVarName }} {{ $c.PkgName }}.{{ .MultipartFuncName }}, + {{ .MultipartVarName }} {{ $c.PkgName }}.{{ .MultipartFuncDeclaration.Name }}, {{- end }} {{- end }} {{- if .Interceptors }} @@ -35,7 +35,7 @@ func ParseEndpoint( switch svcn { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ if .JSONRPC }}{{ .VarName }}ConfigFn{{ else }}{{ .VarName }}Configurer{{ end }}{{ end }}) + c := {{ .PkgName }}.{{ .ClientInit.Name }}(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ if .JSONRPC }}{{ .VarName }}ConfigFn{{ else }}{{ .VarName }}Configurer{{ end }}{{ end }}) switch epn { {{- $pkgName := .PkgName }} {{- range .Subcommands }} @@ -45,7 +45,7 @@ func ParseEndpoint( endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName }}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + data, err = {{ $pkgName }}.{{ .BuildFunction.Declaration.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} @@ -53,7 +53,7 @@ func ParseEndpoint( {{- if .BuildFunction }} if err == nil { {{- end }} - data, err = {{ $pkgName }}.{{ .BuildStreamPayload }}({{ if or .BuildFunction .Conversion }}data, {{ end }}*{{ .StreamFlag.FullName }}Flag) + data, err = {{ $pkgName }}.{{ .BuildStreamPayload.Name }}({{ if or .BuildFunction .Conversion }}data, {{ end }}*{{ .StreamFlag.FullName }}Flag) {{- if .BuildFunction }} } {{- end }} diff --git a/http/codegen/templates/path.go.tpl b/http/codegen/templates/path.go.tpl index 30d0663e67..b555c2a341 100644 --- a/http/codegen/templates/path.go.tpl +++ b/http/codegen/templates/path.go.tpl @@ -1,5 +1,5 @@ {{ range .Routes }}// {{ .PathInit.Description }} -func {{ .PathInit.Name }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { +func {{ if $.Client }}{{ .PathInit.ClientDeclaration.Name }}{{ else }}{{ .PathInit.Declaration.Name }}{{ end }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { {{- .PathInit.ServerCode }} } {{ end }} diff --git a/http/codegen/templates/request_builder.go.tpl b/http/codegen/templates/request_builder.go.tpl index 5fb72f304a..2e5d14c768 100644 --- a/http/codegen/templates/request_builder.go.tpl +++ b/http/codegen/templates/request_builder.go.tpl @@ -1,4 +1,4 @@ {{ comment .RequestInit.Description }} -func (c *{{ .ClientStruct }}) {{ .RequestInit.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .RequestInit.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { {{- .RequestInit.ClientCode }} } diff --git a/http/codegen/templates/request_decoder.go.tpl b/http/codegen/templates/request_decoder.go.tpl index 0b4570c7f1..f5b26cf3f8 100644 --- a/http/codegen/templates/request_decoder.go.tpl +++ b/http/codegen/templates/request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoder .ServiceName .Method.Name | comment }} -func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { +{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { return func(r *http.Request{{ if .IsJSONRPC }}, req *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { {{- if .IsJSONRPC }} r.Body = io.NopCloser(bytes.NewReader(req.Params)) diff --git a/http/codegen/templates/request_encoder.go.tpl b/http/codegen/templates/request_encoder.go.tpl index 4fcbe804e0..38c8e18005 100644 --- a/http/codegen/templates/request_encoder.go.tpl +++ b/http/codegen/templates/request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoder .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoder .ServiceName .Method.Name | comment }}{{ end }} -func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { +{{ if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ end }} +func {{ .RequestEncoderDeclaration.Name }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { return func(req *http.Request, v any) error { {{- if and .IsJSONRPC (not .Payload.Ref) }} {{- template "partial_jsonrpc_request_envelope" . }} diff --git a/http/codegen/templates/request_init.go.tpl b/http/codegen/templates/request_init.go.tpl index a3031e072f..c7a191c5ef 100644 --- a/http/codegen/templates/request_init.go.tpl +++ b/http/codegen/templates/request_init.go.tpl @@ -53,7 +53,7 @@ scheme = "wss" } {{- end }} - u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} + u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.ClientDeclaration.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} req, err := http.NewRequest("{{ .Verb }}", u.String(), {{ if .RequestStruct }}body{{ else }}nil{{ end }}) if err != nil { return nil, goahttp.ErrInvalidURL("{{ .ServiceName }}", "{{ .EndpointName }}", u.String(), err) diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index be18ef20c4..eb38281b67 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} +{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} {{- if .Errors }} -{{ printf "%s may return the following errors:" .ResponseDecoder | comment }} +{{ printf "%s may return the following errors:" .ResponseDecoderDeclaration.Name | comment }} {{- range $gerr := .Errors }} {{- range $errors := .Errors }} // - {{ printf "%q" .Name }} (type {{ .Ref }}): {{ .Response.StatusCode }}{{ if .Response.Description }}, {{ .Response.Description }}{{ end }} @@ -8,7 +8,7 @@ {{- end }} // - error: internal error {{- end }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { return func(resp *http.Response) (any, error) { if restoreBody { b, err := io.ReadAll(resp.Body) @@ -42,7 +42,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { + if err = {{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.Validate.Declaration.Name }}(vres); err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } {{- end }} diff --git a/http/codegen/templates/response_encoder.go.tpl b/http/codegen/templates/response_encoder.go.tpl index 5b5ab0b5a9..3399652511 100644 --- a/http/codegen/templates/response_encoder.go.tpl +++ b/http/codegen/templates/response_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoder .ServiceName .Method.Name | comment }} -func {{ .ResponseEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { +{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { {{- if .Result.MustInit }} {{- if .Method.ViewedResult }} diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl index 706f184af1..2870e32115 100644 --- a/http/codegen/templates/server_configure.go.tpl +++ b/http/codegen/templates/server_configure.go.tpl @@ -5,10 +5,10 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{ .ServerPkgName }}.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} {{- range .JSONRPCServices }} - {{ .Service.VarName }}JSONRPCServer *{{ .ServerPkgName }}.Server + {{ .Service.VarName }}JSONRPCServer *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { @@ -18,23 +18,23 @@ {{- end }} {{- range $svc := .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncName }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncDeclaration.Name }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) {{- end }} {{- end }} {{- range $svcData := .JSONRPCServices }} {{- if .Endpoints }} {{- $svc := . }} - {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.New({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) + {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) {{- end }} {{- end }} } // Configure the mux. {{- range .Services }} - {{ .ServerPkgName }}.Mount(mux, {{ .Service.VarName }}Server) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}Server) {{- end }} {{- range .JSONRPCServices }} - {{ .ServerPkgName }}.Mount(mux, {{ .Service.VarName }}JSONRPCServer) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}JSONRPCServer) {{- end }} diff --git a/http/codegen/templates/server_handler.go.tpl b/http/codegen/templates/server_handler.go.tpl index 6428a945c9..d2746c609e 100644 --- a/http/codegen/templates/server_handler.go.tpl +++ b/http/codegen/templates/server_handler.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandler .ServiceName .Method.Name | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { f, ok := h.(http.HandlerFunc) if !ok { f = func(w http.ResponseWriter, r *http.Request) { diff --git a/http/codegen/templates/server_handler_init.go.tpl b/http/codegen/templates/server_handler_init.go.tpl index 08ff40ffe0..45eb428e58 100644 --- a/http/codegen/templates/server_handler_init.go.tpl +++ b/http/codegen/templates/server_handler_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s creates a HTTP handler which loads the HTTP request and calls the %q service %q endpoint." .HandlerInit .ServiceName .Method.Name | comment }} -func {{ .HandlerInit }}( +{{ printf "%s creates a HTTP handler which loads the HTTP request and calls the %q service %q endpoint." .HandlerInitDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .HandlerInitDeclaration.Name }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, @@ -15,13 +15,13 @@ func {{ .HandlerInit }}( var ( {{- end }} {{- if mustDecodeRequest . }} - decodeRequest = {{ .RequestDecoder }}(mux, decoder) + decodeRequest = {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} {{- if not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults))) }} - encodeResponse = {{ .ResponseEncoder }}(encoder) + encodeResponse = {{ .ResponseEncoderDeclaration.Name }}(encoder) {{- end }} {{- if (or (mustDecodeRequest .) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} - encodeError = {{ if .Errors }}{{ .ErrorEncoder }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) + encodeError = {{ if .Errors }}{{ .ErrorEncoderDeclaration.Name }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) {{- end }} {{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults)))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} ) @@ -63,7 +63,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -73,6 +73,13 @@ func {{ .HandlerInit }}( } _, err = endpoint(ctx, v) if err != nil { + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -98,7 +105,7 @@ func {{ .HandlerInit }}( // In the standard (non-SSE) mode, Stream discards events and the service // must return the synchronous result. v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &discard{{ .Method.VarName }}ServerStream{}, + Stream: &{{ .DiscardStreamDeclaration.Name }}{}, {{- if .Payload.Ref }} Payload: payload, {{- end }} @@ -205,7 +212,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -239,6 +246,15 @@ func {{ .HandlerInit }}( return } {{- end }} + {{- if isSSEEndpoint . }} + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + {{- end }} if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -301,24 +317,24 @@ func {{ .HandlerInit }}( {{- if .HasMixedResults }} -// discard{{ .Method.VarName }}ServerStream implements the {{ .SSE.Interface }} +// {{ .DiscardStreamDeclaration.Name }} implements the {{ .SSE.Interface }} // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. -type discard{{ .Method.VarName }}ServerStream struct{} +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. +type {{ .DiscardStreamDeclaration.Name }} struct{} // {{ .SSE.SendName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { return nil } // {{ .SSE.SendWithContextName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { return nil } // Close is a no-op. -func (s *discard{{ .Method.VarName }}ServerStream) Close() error { +func (s *{{ .DiscardStreamDeclaration.Name }}) Close() error { return nil } {{- end }} diff --git a/http/codegen/templates/server_init.go.tpl b/http/codegen/templates/server_init.go.tpl index 0562b3bc5e..1f21868771 100644 --- a/http/codegen/templates/server_init.go.tpl +++ b/http/codegen/templates/server_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( +{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( e *{{ .Service.PkgName }}.Endpoints, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, @@ -8,20 +8,20 @@ func {{ .ServerInit }}( formatter func(ctx context.Context, err error) goahttp.Statuser, {{- if hasWebSocket . }} upgrader goahttp.Upgrader, - configurer *ConnConfigurer, + configurer *{{ .ServerConnConfigurerDeclaration.Name }}, {{- end }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncName }}, + {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- range .FileServers }} {{ .ArgName }} http.FileSystem, {{- end }} -) *{{ .ServerStruct }} { +) *{{ .ServerStructDeclaration.Name }} { {{- if hasWebSocket . }} if configurer == nil { - configurer = &ConnConfigurer{} + configurer = &{{ .ServerConnConfigurerDeclaration.Name }}{} } {{- end }} {{- range .FileServers }} @@ -32,10 +32,10 @@ func {{ .ServerInit }}( {{- if not .IsDir }} {{- $prefix = dir $prefix }} {{- end }} - {{ .ArgName }} = appendPrefix({{ .ArgName }}, "{{ $prefix }}") + {{ .ArgName }} = {{ $.AppendPrefixDeclaration.Name }}({{ .ArgName }}, "{{ $prefix }}") {{- end }} - return &{{ .ServerStruct }}{ - Mounts: []*{{ .MountPointStruct }}{ + return &{{ .ServerStructDeclaration.Name }}{ + Mounts: []*{{ .MountPointStructDeclaration.Name }}{ {{- range $e := .Endpoints }} {{- range $e.Routes }} {"{{ $e.Method.VarName }}", "{{ .Verb }}", "{{ .Path }}"}, @@ -49,7 +49,7 @@ func {{ .ServerInit }}( {{- end }} }, {{- range .Endpoints }} - {{ .Method.VarName }}: {{ .HandlerInit }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), + {{ .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitDeclaration.Name }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), {{- end }} {{- range .FileServers }} {{ .VarName }}: http.FileServer({{ .ArgName }}), diff --git a/http/codegen/templates/server_method_names.go.tpl b/http/codegen/templates/server_method_names.go.tpl index aec727ee7d..c652d7fcd8 100644 --- a/http/codegen/templates/server_method_names.go.tpl +++ b/http/codegen/templates/server_method_names.go.tpl @@ -1,2 +1,2 @@ {{ printf "MethodNames returns the methods served." | comment }} -func (s *{{ .ServerStruct }}) MethodNames() []string { return {{ .Service.PkgName }}.MethodNames[:] } +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.MethodNames[:] } diff --git a/http/codegen/templates/server_mount.go.tpl b/http/codegen/templates/server_mount.go.tpl index 01b4fc294a..033530e4a6 100644 --- a/http/codegen/templates/server_mount.go.tpl +++ b/http/codegen/templates/server_mount.go.tpl @@ -1,15 +1,15 @@ -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- range .Endpoints }} - {{ .MountHandler }}(mux, h.{{ .Method.VarName }}) + {{ .MountHandlerDeclaration.Name }}(mux, h.{{ .Method.VarName }}) {{- end }} {{- range .FileServers }} {{- if .Redirect }} - {{ .MountHandler }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + {{ .MountHandlerDeclaration.Name }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "{{ .Redirect.URL }}", {{ .Redirect.StatusCode }}) })) {{- else }} - {{- $mountHandler := .MountHandler }} + {{- $mountHandler := .MountHandlerDeclaration.Name }} {{- $varName := .VarName }} {{- $isDir := .IsDir }} {{- range .RequestPaths }} @@ -27,7 +27,7 @@ func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { {{- end }} } -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/http/codegen/templates/server_service.go.tpl b/http/codegen/templates/server_service.go.tpl index 744fae2dea..c8337b8caa 100644 --- a/http/codegen/templates/server_service.go.tpl +++ b/http/codegen/templates/server_service.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s returns the name of the service served." .ServerService | comment }} -func (s *{{ .ServerStruct }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl index 423768dc63..61e4a695a0 100644 --- a/http/codegen/templates/server_sse.go.tpl +++ b/http/codegen/templates/server_sse.go.tpl @@ -1,76 +1,96 @@ -{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructName .SSE.Interface | comment }} -type {{ .SSE.StructName }} struct { +{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructDeclaration.Name .SSE.Interface | comment }} +type {{ .SSE.StructDeclaration.Name }} struct { {{ comment "once ensures the headers are written once." }} once sync.Once {{ comment "w is the HTTP response writer used to send the SSE events." }} w http.ResponseWriter {{ comment "r is the HTTP request." }} r *http.Request + {{ comment "attempted is true after this stream writes the HTTP success status." }} + attempted bool + {{- if .SSE.VariableView }} + {{ comment "view is the result view selected for events in this HTTP response." }} + view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string + {{- end }} +} + +{{- if .SSE.VariableView }} +{{ comment "SetView selects the result view used by subsequent sends on this stream." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.view = view } +{{- end }} {{ printf "%s %s" .SSE.SendName .SSE.SendDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { return s.{{ .SSE.SendWithContextName }}(context.Background(), v) } {{ printf "%s %s" .SSE.SendWithContextName .SSE.SendWithContextDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) - - {{- if .Method.ViewedResult }} - {{- if .Method.ViewedResult.ViewName }} - res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }}).Projected - {{- else }} - res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, "default").Projected +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { + {{- if .SSE.VariableView }} + view := s.view + if view == "" { + view = {{ printf "%q" .SSE.DefaultView }} + } + switch view { + {{- range .Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: {{- end }} - {{- else }} - res := v - {{- end }} - - {{ if .SSE.IDField }} - if id := res.{{ .SSE.IDField }}; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } - {{- end }} - - {{- if .SSE.EventField }} - if event := res.{{ .SSE.EventField }}; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) } + {{- else if .Method.ViewedResult }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} {{- end }} - {{- if .SSE.RetryField }} - if retry := res.{{ .SSE.RetryField }}; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) - } + {{- if .Method.ViewedResult }} + res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, view) + {{- if or .SSE.IDField .SSE.EventField .SSE.RetryField (not .SSE.HasResponseBody) }} + projected := res.Projected + {{- end }} + {{- else }} + res := v {{- end }} var data string var payload any {{- if .SSE.HasResponseBody }} - body := New{{ goify .Method.Name true }}ResponseBody(res) - {{- if .SSE.DataField }} - payload = body.{{ .SSE.DataField }} + {{- if .Method.ViewedResult }} + {{- if .SSE.VariableView }} + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + } + {{- else }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} {{- else }} + {{- if (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Name }}({{ range (index .SSE.Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- else }} + body := res + {{- end }} + {{- if .SSE.DataField }} + payload = body.{{ .SSE.DataField }} + {{- else }} payload = body + {{- end }} {{- end }} {{- else }} {{- if .SSE.DataField }} - payload = res.{{ .SSE.DataField }} + payload = {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.DataField }} {{- else }} - payload = res + payload = {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }} {{- end }} {{- end }} switch v := payload.(type) { @@ -117,13 +137,73 @@ func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Conte } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + {{- if .SSE.VariableView }} + s.sentView = view + {{- end }} + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + {{- if .SSE.VariableView }} + header.Set("goa-view", view) + {{- end }} + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + {{ if .SSE.IDField }} + if id := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.IDField }}; id != "" { + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } + } + {{- end }} + + {{- if .SSE.EventField }} + if event := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.EventField }}; event != "" { + if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil { + return err + } + } + {{- end }} + + {{- if .SSE.RetryField }} + if retry := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.RetryField }}; retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", retry); err != nil { + return err + } + } + {{- end }} + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -{{ comment "Close is a no-op for SSE. We keep the method for compatibility with other stream types." }} -func (s *{{ .SSE.StructName }}) Close() error { +{{- define "viewed_sse_server_body" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + body := {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- if $endpoint.SSE.DataField }} + payload = body.{{ $endpoint.SSE.DataField }} + {{- else }} + payload = body + {{- end }} + {{- end }} +{{- end }} + +{{ comment "Close does nothing because an SSE stream closes with its HTTP response. The common stream interface still requires this method." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { return nil } diff --git a/http/codegen/templates/server_struct.go.tpl b/http/codegen/templates/server_struct.go.tpl index 3e56fa99d9..fd33726250 100644 --- a/http/codegen/templates/server_struct.go.tpl +++ b/http/codegen/templates/server_struct.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { - Mounts []*{{ .MountPointStruct }} +{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { + Mounts []*{{ .MountPointStructDeclaration.Name }} {{- range .Endpoints }} {{ .Method.VarName }} http.Handler {{- end }} diff --git a/http/codegen/templates/server_use.go.tpl b/http/codegen/templates/server_use.go.tpl index 1f2d4df41b..a1146574c5 100644 --- a/http/codegen/templates/server_use.go.tpl +++ b/http/codegen/templates/server_use.go.tpl @@ -1,5 +1,5 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { {{- range .Endpoints }} s.{{ .Method.VarName }} = m(s.{{ .Method.VarName }}) {{- end }} diff --git a/http/codegen/templates/transform_helper.go.tpl b/http/codegen/templates/transform_helper.go.tpl index f240c92412..24aa5e8a64 100644 --- a/http/codegen/templates/transform_helper.go.tpl +++ b/http/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,9 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}} +{{- $name = .Declaration.Name -}} +{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ $name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/http/codegen/templates/validate.go.tpl b/http/codegen/templates/validate.go.tpl index 115410fef4..0b635435ed 100644 --- a/http/codegen/templates/validate.go.tpl +++ b/http/codegen/templates/validate.go.tpl @@ -1,5 +1,5 @@ -{{ printf "Validate%s runs the validations defined on %s" .VarName .Name | comment }} -func Validate{{ .VarName }}(body {{ .Ref }}) (err error) { +{{ printf "%s runs the validations defined on %s" .ValidatorName .Name | comment }} +func {{ .ValidatorName }}(body {{ .Ref }}) (err error) { {{ .ValidateDef }} return } diff --git a/http/codegen/templates/websocket_close.go.tpl b/http/codegen/templates/websocket_close.go.tpl index c7e832ac80..87e986130e 100644 --- a/http/codegen/templates/websocket_close.go.tpl +++ b/http/codegen/templates/websocket_close.go.tpl @@ -1,5 +1,5 @@ {{ printf "Close closes the %q endpoint websocket connection." .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) Close() error { +func (s *{{ .VarDeclaration.Name }}) Close() error { var err error {{- if eq .Type "server" }} if s.conn == nil { diff --git a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl index 2a4947699d..588001b35f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "ConnConfigurer holds the websocket connection configurer functions for the streaming endpoints in %q service." .Service.Name | comment }} -type ConnConfigurer struct { +{{ printf "%s holds the websocket connection configurer functions for the streaming endpoints in %q service." .Declaration.Name .Service.Name | comment }} +type {{ .Declaration.Name }} struct { {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName }}Fn goahttp.ConnConfigureFunc diff --git a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl index 9a7eb29382..69207e866f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "NewConnConfigurer initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .Service.Name | comment }} -func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { - return &ConnConfigurer{ +{{ printf "%s initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .InitDeclaration.Name .Service.Name | comment }} +func {{ .InitDeclaration.Name }}(fn goahttp.ConnConfigureFunc) *{{ .Declaration.Name }} { + return &{{ .Declaration.Name }}{ {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName}}Fn: fn, diff --git a/http/codegen/templates/websocket_recv.go.tpl b/http/codegen/templates/websocket_recv.go.tpl index b405152670..381a40647d 100644 --- a/http/codegen/templates/websocket_recv.go.tpl +++ b/http/codegen/templates/websocket_recv.go.tpl @@ -1,5 +1,5 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { var ( rv {{ .RecvTypeRef }} {{- if eq .Type "server" }} @@ -71,7 +71,7 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { res := {{ .Response.ResultInit.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .Endpoint.Method.ViewedResult }}{{ with .Endpoint.Method.ViewedResult }} vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: {{ if .ViewName }}{{ printf "%q" .ViewName }}{{ else }}s.view{{ end }} } - if err := {{ .ViewsPkg }}.Validate{{ $.Endpoint.Method.Result }}(vres); err != nil { + if err := {{ .ViewsPkg }}.{{ .Validate.Declaration.Name }}(vres); err != nil { return rv, goahttp.ErrValidationError("{{ $.Endpoint.ServiceName }}", "{{ $.Endpoint.Method.Name }}", err) } return {{ $.PkgName }}.{{ .ResultInit.Declaration.Name }}(vres){{ end }}, nil @@ -85,6 +85,6 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { return s.{{ .RecvName }}() } diff --git a/http/codegen/templates/websocket_send.go.tpl b/http/codegen/templates/websocket_send.go.tpl index 46f7ede462..c35af6ad7e 100644 --- a/http/codegen/templates/websocket_send.go.tpl +++ b/http/codegen/templates/websocket_send.go.tpl @@ -1,5 +1,5 @@ {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if eq .Type "server" }} {{- if eq .SendName "Send" }} var err error @@ -54,6 +54,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { return s.{{ .SendName }}(v) } diff --git a/http/codegen/templates/websocket_set_view.go.tpl b/http/codegen/templates/websocket_set_view.go.tpl index b8e44d61ff..e9ed779835 100644 --- a/http/codegen/templates/websocket_set_view.go.tpl +++ b/http/codegen/templates/websocket_set_view.go.tpl @@ -1,4 +1,4 @@ {{ printf "SetView sets the view to render the %s type before sending to the %q endpoint websocket connection." .SendTypeName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) SetView(view string) { +func (s *{{ .VarDeclaration.Name }}) SetView(view string) { s.view = view } diff --git a/http/codegen/templates/websocket_struct_type.go.tpl b/http/codegen/templates/websocket_struct_type.go.tpl index a3ed5d915d..e3c56bea37 100644 --- a/http/codegen/templates/websocket_struct_type.go.tpl +++ b/http/codegen/templates/websocket_struct_type.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the %s interface." .VarName .Interface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .VarDeclaration.Name .Interface | comment }} +type {{ .VarDeclaration.Name }} struct { {{- if eq .Type "server" }} once sync.Once {{ comment "upgradeErr is the error returned by the websocket upgrade attempt." }} diff --git a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden index e447a641a3..814976049f 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden @@ -8,7 +8,7 @@ func NewPayloadType(p []*servicebodyprimitivearrayuservalidate.PayloadType) []*P body[i] = nil continue } - body[i] = marshalServicebodyprimitivearrayuservalidatePayloadTypeToPayloadType(val) + body[i] = marshalPayloadTypeToPayloadType2(val) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden index 5da3d0848f..d93aa3588b 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerRequestBody(p *servicebodyuserinner.PayloadType) *MethodBodyUserInnerRequestBody { body := &MethodBodyUserInnerRequestBody{} if p.Inner != nil { - body.Inner = marshalServicebodyuserinnerInnerTypeToInnerType(p.Inner) + body.Inner = marshalInnerTypeToInnerType2(p.Inner) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden index 2f19bff0c4..366208ffc0 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyInlineObjectResultTypeOK builds a "ServiceBodyInlineObject" -// service "MethodBodyInlineObject" endpoint result from a HTTP "OK" response. -func NewMethodBodyInlineObjectResultTypeOK(body *MethodBodyInlineObjectResponseBody) *servicebodyinlineobject.ResultType { +// NewMethodBodyInlineObjectResultOK builds a "ServiceBodyInlineObject" service +// "MethodBodyInlineObject" endpoint result from a HTTP "OK" response. +func NewMethodBodyInlineObjectResultOK(body *MethodBodyInlineObjectResponseBody) *servicebodyinlineobject.ResultType { v := &servicebodyinlineobject.ResultType{} if body.Parent != nil { v.Parent = &struct { diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden index e3ad137928..803240fb6e 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden @@ -1,11 +1,11 @@ -// NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK -// builds a "ServiceExplicitBodyUserResultObjectMultipleView" service +// NewMethodExplicitBodyUserResultObjectMultipleViewResultOK builds a +// "ServiceExplicitBodyUserResultObjectMultipleView" service // "MethodExplicitBodyUserResultObjectMultipleView" endpoint result from a HTTP // "OK" response. -func NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyUserResultObjectMultipleViewResultOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView{} if body.A != nil { - v.A = unmarshalUserTypeToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeView(body.A) + v.A = unmarshalUserTypeToUserTypeView(body.A) } v.C = c diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden index f70eba9e43..2bce5ff48d 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden @@ -1,11 +1,11 @@ -// NewMethodExplicitBodyUserResultObjectResulttypeOK builds a +// NewMethodExplicitBodyUserResultObjectResultOK builds a // "ServiceExplicitBodyUserResultObject" service // "MethodExplicitBodyUserResultObject" endpoint result from a HTTP "OK" // response. -func NewMethodExplicitBodyUserResultObjectResulttypeOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { +func NewMethodExplicitBodyUserResultObjectResultOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { v := &serviceexplicitbodyuserresultobjectviews.ResulttypeView{} if body.A != nil { - v.A = unmarshalUserTypeToServiceexplicitbodyuserresultobjectviewsUserTypeView(body.A) + v.A = unmarshalUserTypeToUserTypeView(body.A) } v.C = c v.B = b diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden index 8f3200dd01..516e7dfaf9 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden @@ -1,8 +1,8 @@ -// NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK -// builds a "ServiceExplicitBodyPrimitiveResultMultipleView" service +// NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK builds a +// "ServiceExplicitBodyPrimitiveResultMultipleView" service // "MethodExplicitBodyPrimitiveResultMultipleView" endpoint result from a HTTP // "OK" response. -func NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body string, c *string) *serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK(body string, c *string) *serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView { v := body res := &serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView{ A: &v, diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden index e363d520b2..9640e1c234 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden @@ -1,8 +1,8 @@ -// NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK builds -// a "ServiceExplicitBodyUserResultMultipleView" service +// NewMethodExplicitBodyUserResultMultipleViewResultOK builds a +// "ServiceExplicitBodyUserResultMultipleView" service // "MethodExplicitBodyUserResultMultipleView" endpoint result from a HTTP "OK" // response. -func NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyUserResultMultipleViewResultOK(body *MethodExplicitBodyUserResultMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultmultipleviewviews.UserTypeView{ X: body.X, Y: body.Y, diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index 563029a6e9..e3a4e99065 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -28,7 +28,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s } v := &servicemulti.MethodMultiPayloadPayload{} if body.C != nil { - v.C = marshalUserTypeToServicemultiUserType(body.C) + v.C = marshalUserTypeToUserType2(body.C) } v.B = b v.A = a diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index 8468cea998..f5f9ab4a99 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -15,7 +15,7 @@ func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyI v[i] = nil continue } - v[i] = marshalElemTypeToServicebodyinlinearrayuserElemType(val) + v[i] = marshalElemTypeToElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index e2f72cdcd6..94985f1d5f 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -11,12 +11,12 @@ func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlin } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeToServicebodyinlinemapuserKeyType(key) + tk := marshalKeyTypeToKeyType(key) if val == nil { v[tk] = nil continue } - v[tk] = marshalElemTypeToServicebodyinlinemapuserElemType(val) + v[tk] = marshalElemTypeToElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden index 3dbbbb5f65..62aef60e85 100644 --- a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden @@ -33,7 +33,7 @@ func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.D if cRaw != "" { c = &cRaw } - p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) + p := NewMethodBodyMultipleViewResultOK(&body, c) view := resp.Header.Get("goa-view") vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { diff --git a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden index 4d1836051f..93973f6f9b 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden @@ -25,7 +25,7 @@ func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response if cRaw != "" { c = &cRaw } - p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) + p := NewMethodEmptyBodyResultMultipleViewResultOK(c) view := resp.Header.Get("goa-view") vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden index 45a3efe2ab..b0167005cb 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden @@ -40,7 +40,7 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h if cRaw != "" { c = &cRaw } - p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) + p := NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK(body, c) view := resp.Header.Get("goa-view") vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden index 9b7d4c88ec..1c54168ba2 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden @@ -33,7 +33,7 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R if cRaw != "" { c = &cRaw } - p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) + p := NewMethodExplicitBodyUserResultMultipleViewResultOK(&body, c) view := resp.Header.Get("goa-view") vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { diff --git a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden index 4420a82de0..8f9004cb2a 100644 --- a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden @@ -33,7 +33,7 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D if cRaw != "" { c = &cRaw } - p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) + p := NewMethodTagMultipleViewsResultAccepted(&body, c) tmp := "value" p.B = &tmp view := resp.Header.Get("goa-view") @@ -52,7 +52,7 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D if err != nil { return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } - p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) + p := NewMethodTagMultipleViewsResultOK(&body) view := resp.Header.Get("goa-view") vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { diff --git a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden index 7042e4a30b..6bcd622ff0 100644 --- a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden +++ b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden @@ -38,7 +38,7 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore if err != nil { return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) } - p := NewMethodAAResultOK(required) + p := NewMethodAResultOK(required) view := "default" vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} res := validateerrorresponsetype.NewAResult(vres) diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden index 6e55a27963..5458c34908 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden @@ -59,7 +59,7 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore if err != nil { return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } - p := NewMethodAAResultOK(required, optional, optionalButRequired) + p := NewMethodAResultOK(required, optional, optionalButRequired) view := resp.Header.Get("goa-view") vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} res := servicewithheadersblockviewedresult.NewAResult(vres) diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden index 688c9fe785..6929073169 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden @@ -52,7 +52,7 @@ func NewListResultOK(body *ListResponseBody) *servicea.ListResult { // NewListSomethingWentWrong builds a ServiceA service list endpoint // something_went_wrong error. func NewListSomethingWentWrong(body *ListSomethingWentWrongResponseBody) *goa.ServiceError { - v := &goa.ServiceError{ + v := &servicea.Error{ Name: *body.Name, ID: *body.ID, Message: *body.Message, @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden index 1c5b90165a..0b6a9257d5 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden @@ -52,7 +52,7 @@ func NewListResultOK(body *ListResponseBody) *serviceb.ListResult { // NewListSomethingWentWrong builds a ServiceB service list endpoint // something_went_wrong error. func NewListSomethingWentWrong(body *ListSomethingWentWrongResponseBody) *goa.ServiceError { - v := &goa.ServiceError{ + v := &serviceb.Error{ Name: *body.Name, ID: *body.ID, Message: *body.Message, @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden index d1ee980ec9..d931fcb331 100644 --- a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden @@ -37,10 +37,10 @@ func NewMethodARequestBody(p *servicemixedpayloadinbody.APayload) *MethodAReques } } if p.Object != nil { - body.Object = marshalServicemixedpayloadinbodyBPayloadToBPayload(p.Object) + body.Object = marshalBPayloadToBPayload(p.Object) } if p.DupObj != nil { - body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayload(p.DupObj) + body.DupObj = marshalBPayloadToBPayload2(p.DupObj) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden index d228e1682b..bcd85d7029 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden @@ -18,7 +18,7 @@ func NewPayloadA(p []*servicemultiplemethods.PayloadA) []*PayloadA { body[i] = nil continue } - body[i] = marshalServicemultiplemethodsPayloadAToPayloadA(val) + body[i] = marshalPayloadAToPayloadA2(val) } return body } @@ -32,7 +32,7 @@ func NewPayloadB(p []*servicemultiplemethods.PayloadB) []*PayloadB { body[i] = nil continue } - body[i] = marshalServicemultiplemethodsPayloadBToPayloadB(val) + body[i] = marshalPayloadBToPayloadB2(val) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden index b15e5cd4c8..7c72e0c962 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden @@ -34,7 +34,7 @@ func NewMethodBRequestBody(p *servicemultiplemethods.PayloadType) *MethodBReques B: p.B, } if p.C != nil { - body.C = marshalServicemultiplemethodsAPayloadToAPayload(p.C) + body.C = marshalAPayloadToAPayload2(p.C) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden b/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden index 8f9a8c97a6..33a875e306 100644 --- a/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden +++ b/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden @@ -5,9 +5,9 @@ type MethodResultTypeValidateResponseBody struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// NewMethodResultTypeValidateResultTypeOK builds a "ServiceResultTypeValidate" +// NewMethodResultTypeValidateResultOK builds a "ServiceResultTypeValidate" // service "MethodResultTypeValidate" endpoint result from a HTTP "OK" response. -func NewMethodResultTypeValidateResultTypeOK(body *MethodResultTypeValidateResponseBody) *serviceresulttypevalidate.ResultType { +func NewMethodResultTypeValidateResultOK(body *MethodResultTypeValidateResponseBody) *serviceresulttypevalidate.ResultType { v := &serviceresulttypevalidate.ResultType{ A: body.A, } diff --git a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden index 17fae0fe8b..aa568c458c 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden @@ -24,7 +24,7 @@ type Rt struct { func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCollectionResponseBody) *serviceresultwithresultcollection.MethodResultWithResultCollectionResult { v := &serviceresultwithresultcollection.MethodResultWithResultCollectionResult{} if body.A != nil { - v.A = unmarshalResulttypeToServiceresultwithresultcollectionResulttype(body.A) + v.A = unmarshalResulttypeToResulttype(body.A) } return v diff --git a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden index c89d793d0c..fe1906ea5b 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden @@ -11,15 +11,15 @@ type Rt struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// NewMethodResultWithResultViewResulttypeOK builds a -// "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint -// result from a HTTP "OK" response. -func NewMethodResultWithResultViewResulttypeOK(body *MethodResultWithResultViewResponseBodyFull) *serviceresultwithresultviewviews.ResulttypeView { +// NewMethodResultWithResultViewResultOK builds a "ServiceResultWithResultView" +// service "MethodResultWithResultView" endpoint result from a HTTP "OK" +// response. +func NewMethodResultWithResultViewResultOK(body *MethodResultWithResultViewResponseBodyFull) *serviceresultwithresultviewviews.ResulttypeView { v := &serviceresultwithresultviewviews.ResulttypeView{ Name: body.Name, } if body.Rt != nil { - v.Rt = unmarshalRtToServiceresultwithresultviewviewsRtView(body.Rt) + v.Rt = unmarshalRtToRtView(body.Rt) } return v diff --git a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden index 30c88c2011..4b5cce23f8 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserPayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden index 509e45cd42..b500c1fe8d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserPayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden index b975e9496c..660b975de6 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden @@ -33,7 +33,7 @@ func DecodeMethodUserBodyPathValidateRequest(mux goahttp.Muxer, decoder func(*ht if err != nil { return payload, err } - payload = NewMethodUserBodyPathValidatePayloadType(&body, b) + payload = NewMethodUserBodyPathValidatePayload(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden index 353fe7683b..af5a8aa1be 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden @@ -25,7 +25,7 @@ func DecodeMethodBodyPathUserRequest(mux goahttp.Muxer, decoder func(*http.Reque params = mux.Vars(r) ) b = params["b"] - payload = NewMethodBodyPathUserPayloadType(&body, b) + payload = NewMethodBodyPathUserPayload(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden index c3f3ccf82c..485963fea8 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden @@ -29,7 +29,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserRequiredPayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserRequiredPayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden index 095de5149e..7c17fc79b5 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden @@ -32,7 +32,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserValidatePayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserValidatePayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden index 21b3bf22ae..7b188533db 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden @@ -28,7 +28,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserValidatePayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserValidatePayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden index 30c88c2011..4b5cce23f8 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) + payload = NewMethodBodyPrimitiveArrayUserPayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden index 3766c2c375..66efbaaf27 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden @@ -40,7 +40,7 @@ func DecodeMethodBodyQueryPathUserValidateRequest(mux goahttp.Muxer, decoder fun if err != nil { return payload, err } - payload = NewMethodBodyQueryPathUserValidatePayloadType(&body, c2, b) + payload = NewMethodBodyQueryPathUserValidatePayload(&body, c2, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden index 8625ce1373..2fd66e8974 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden @@ -30,7 +30,7 @@ func DecodeMethodBodyQueryPathUserRequest(mux goahttp.Muxer, decoder func(*http. if bRaw != "" { b = &bRaw } - payload = NewMethodBodyQueryPathUserPayloadType(&body, c2, b) + payload = NewMethodBodyQueryPathUserPayload(&body, c2, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden index 2d8357c4f6..60d6ba91ce 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden @@ -34,7 +34,7 @@ func DecodeMethodBodyQueryUserValidateRequest(mux goahttp.Muxer, decoder func(*h if err != nil { return payload, err } - payload = NewMethodBodyQueryUserValidatePayloadType(&body, b) + payload = NewMethodBodyQueryUserValidatePayload(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden index c090216754..ebd48574d0 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden @@ -26,7 +26,7 @@ func DecodeMethodBodyQueryUserRequest(mux goahttp.Muxer, decoder func(*http.Requ if bRaw != "" { b = &bRaw } - payload = NewMethodBodyQueryUserPayloadType(&body, b) + payload = NewMethodBodyQueryUserPayload(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden index c7ec4b091c..27d2ae4426 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUnionUserRequest(mux goahttp.Muxer, decoder func(*http.Requ } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUnionUserUnionUser(&body) + payload = NewMethodBodyUnionUserPayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden index d59ea889ec..2b1036137e 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUnionRequest(mux goahttp.Muxer, decoder func(*http.Request) } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUnionUnion(&body) + payload = NewMethodBodyUnionPayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden index 7f797ad350..7aa5c5d198 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden @@ -23,7 +23,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) if err != nil { return payload, err } - payload = NewMethodBodyUserPayloadType(&body) + payload = NewMethodBodyUserPayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden index 5f61f2b6be..825055b836 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden @@ -22,7 +22,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) if err != nil { return payload, err } - payload = NewMethodBodyUserPayloadType(&body) + payload = NewMethodBodyUserPayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden index 064adc369b..05b8c50938 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden @@ -23,7 +23,7 @@ func DecodeMethodBodyUserValidateRequest(mux goahttp.Muxer, decoder func(*http.R if err != nil { return payload, err } - payload = NewMethodBodyUserValidatePayloadType(body) + payload = NewMethodBodyUserValidatePayload(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden index 2697436ea5..8f27eeaa7a 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUserPayloadType(&body) + payload = NewMethodBodyUserPayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden index 6a1141e2c1..94aea4d1f8 100644 --- a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden @@ -1,13 +1,13 @@ -// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextender -// builds a value of type *Immediatechildextender from a value of type +// marshalImmediatechildextenderViewToImmediatechildextender builds a value of +// type *Immediatechildextender from a value of type // *servicedeepuserviews.ImmediatechildextenderView. -func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextender(v *servicedeepuserviews.ImmediatechildextenderView) *Immediatechildextender { +func marshalImmediatechildextenderViewToImmediatechildextender(v *servicedeepuserviews.ImmediatechildextenderView) *Immediatechildextender { if v == nil { return nil } res := &Immediatechildextender{} if v.DeepChild != nil { - res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchild(v.DeepChild) + res.DeepChild = marshalDeepchildViewToDeepchild(v.DeepChild) } return res diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden index 9e0135057d..5c31f80653 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden @@ -52,7 +52,7 @@ func DecodeMethodMapQueryObjectRequest(mux goahttp.Muxer, decoder func(*http.Req if err != nil { return payload, err } - payload = NewMethodMapQueryObjectPayloadType(&body, a, c) + payload = NewMethodMapQueryObjectPayload(&body, a, c) return payload, nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden index 89902850d2..a99f378cae 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalResultTypeToFooserviceResultType builds a value of type -// *fooservice.ResultType from a value of type *ResultType. -func unmarshalResultTypeToFooserviceResultType(v *ResultType) *fooservice.ResultType { +// unmarshalResultTypeToResultType builds a value of type +// *fooservice.ResultType from a value of type *ResultType2. +func unmarshalResultTypeToResultType(v *ResultType2) *fooservice.ResultType { res := &fooservice.ResultType{} if v.Foo != nil { foo := fooservice.Foo(*v.Foo) diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden index b5eab461bc..5627ed1675 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden @@ -1,7 +1,7 @@ -// marshalFooserviceResultTypeToResultType2 builds a value of type *ResultType2 -// from a value of type *fooservice.ResultType. -func marshalFooserviceResultTypeToResultType2(v *fooservice.ResultType) *ResultType2 { - res := &ResultType2{} +// marshalResultTypeToResultType builds a value of type *ResultType from a +// value of type *fooservice.ResultType. +func marshalResultTypeToResultType(v *fooservice.ResultType) *ResultType { + res := &ResultType{} if v.Foo != nil { foo := string(*v.Foo) res.Foo = &foo diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden index 29b33bdc91..286aede630 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden @@ -1,6 +1,6 @@ // unmarshalFooToFooFoo builds a value of type *foo.Foo from a value of type -// *Foo. -func unmarshalFooToFooFoo(v *Foo) *foo.Foo { +// *Foo2. +func unmarshalFooToFooFoo(v *Foo2) *foo.Foo { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden index 91cb0b7493..967a03a255 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden @@ -1,10 +1,9 @@ -// marshalFooFooToFoo2 builds a value of type *Foo2 from a value of type -// *foo.Foo. -func marshalFooFooToFoo2(v *foo.Foo) *Foo2 { +// marshalFooFooToFoo builds a value of type *Foo from a value of type *foo.Foo. +func marshalFooFooToFoo(v *foo.Foo) *Foo { if v == nil { return nil } - res := &Foo2{ + res := &Foo{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden index 30cce7eb2e..8058dd504b 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden @@ -1,12 +1,12 @@ -// unmarshalExtensionToFooserviceExtension builds a value of type -// *fooservice.Extension from a value of type *Extension. -func unmarshalExtensionToFooserviceExtension(v *Extension) *fooservice.Extension { +// unmarshalExtensionToExtension builds a value of type *fooservice.Extension +// from a value of type *Extension2. +func unmarshalExtensionToExtension(v *Extension2) *fooservice.Extension { if v == nil { return nil } res := &fooservice.Extension{} if v.Bar != nil { - res.Bar = unmarshalBarToFooserviceBar(v.Bar) + res.Bar = unmarshalBarToBar(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden index 56f57181d3..0044584907 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden @@ -1,6 +1,6 @@ -// unmarshalBarToFooserviceBar builds a value of type *fooservice.Bar from a -// value of type *Bar. -func unmarshalBarToFooserviceBar(v *Bar) *fooservice.Bar { +// unmarshalBarToBar builds a value of type *fooservice.Bar from a value of +// type *Bar2. +func unmarshalBarToBar(v *Bar2) *fooservice.Bar { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden index 7c7d09668e..adbfeb7efa 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden @@ -1,9 +1,9 @@ -// marshalFooserviceResultTypeToResultType2 builds a value of type *ResultType2 -// from a value of type *fooservice.ResultType. -func marshalFooserviceResultTypeToResultType2(v *fooservice.ResultType) *ResultType2 { - res := &ResultType2{} +// marshalResultTypeToResultType builds a value of type *ResultType from a +// value of type *fooservice.ResultType. +func marshalResultTypeToResultType(v *fooservice.ResultType) *ResultType { + res := &ResultType{} if v.Extension != nil { - res.Extension = marshalFooserviceExtensionToExtension2(v.Extension) + res.Extension = marshalExtensionToExtension(v.Extension) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden index 99d42dfe62..3c25cec99e 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden @@ -1,12 +1,12 @@ -// marshalFooserviceExtensionToExtension2 builds a value of type *Extension2 -// from a value of type *fooservice.Extension. -func marshalFooserviceExtensionToExtension2(v *fooservice.Extension) *Extension2 { +// marshalExtensionToExtension builds a value of type *Extension from a value +// of type *fooservice.Extension. +func marshalExtensionToExtension(v *fooservice.Extension) *Extension { if v == nil { return nil } - res := &Extension2{} + res := &Extension{} if v.Bar != nil { - res.Bar = marshalFooserviceBarToBar2(v.Bar) + res.Bar = marshalBarToBar(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden index a14f3cbd47..4cf6cdd7ae 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden @@ -1,10 +1,10 @@ -// marshalFooserviceBarToBar2 builds a value of type *Bar2 from a value of type +// marshalBarToBar builds a value of type *Bar from a value of type // *fooservice.Bar. -func marshalFooserviceBarToBar2(v *fooservice.Bar) *Bar2 { +func marshalBarToBar(v *fooservice.Bar) *Bar { if v == nil { return nil } - res := &Bar2{ + res := &Bar{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden index e6de120b47..d2c02057c4 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden @@ -1,13 +1,13 @@ -// NewMethodBodyInlineArrayUserElemType builds a ServiceBodyInlineArrayUser +// NewMethodBodyInlineArrayUserPayload builds a ServiceBodyInlineArrayUser // service MethodBodyInlineArrayUser endpoint payload. -func NewMethodBodyInlineArrayUserElemType(body []*ElemType) []*servicebodyinlinearrayuser.ElemType { +func NewMethodBodyInlineArrayUserPayload(body []*ElemType) []*servicebodyinlinearrayuser.ElemType { v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) for i, val := range body { if val == nil { v[i] = nil continue } - v[i] = unmarshalElemTypeToServicebodyinlinearrayuserElemType(val) + v[i] = unmarshalElemTypeToElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index d6eea4dea5..6c570f008c 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -1,14 +1,14 @@ -// NewMethodBodyInlineMapUserMapKeyTypeElemType builds a -// ServiceBodyInlineMapUser service MethodBodyInlineMapUser endpoint payload. -func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyType]*ElemType) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { +// NewMethodBodyInlineMapUserPayload builds a ServiceBodyInlineMapUser service +// MethodBodyInlineMapUser endpoint payload. +func NewMethodBodyInlineMapUserPayload(body map[*KeyType]*ElemType) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeToServicebodyinlinemapuserKeyType(key) + tk := unmarshalKeyTypeToKeyType(key) if val == nil { v[tk] = nil continue } - v[tk] = unmarshalElemTypeToServicebodyinlinemapuserElemType(val) + v[tk] = unmarshalElemTypeToElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden index 350b94cfd0..275086cd2b 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyInlineRecursiveUserPayloadType builds a +// NewMethodBodyInlineRecursiveUserPayload builds a // ServiceBodyInlineRecursiveUser service MethodBodyInlineRecursiveUser // endpoint payload. -func NewMethodBodyInlineRecursiveUserPayloadType(body *MethodBodyInlineRecursiveUserRequestBody, a string, b *string) *servicebodyinlinerecursiveuser.PayloadType { +func NewMethodBodyInlineRecursiveUserPayload(body *MethodBodyInlineRecursiveUserRequestBody, a string, b *string) *servicebodyinlinerecursiveuser.PayloadType { v := &servicebodyinlinerecursiveuser.PayloadType{} - v.C = unmarshalPayloadTypeToServicebodyinlinerecursiveuserPayloadType(body.C) + v.C = unmarshalPayloadTypeToPayloadType(body.C) v.A = a v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden index 6c448d700a..43ac8cff0f 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden @@ -1,7 +1,6 @@ -// NewMethodUserBodyPathValidatePayloadType builds a -// ServiceBodyPathUserValidate service MethodUserBodyPathValidate endpoint -// payload. -func NewMethodUserBodyPathValidatePayloadType(body *MethodUserBodyPathValidateRequestBody, b string) *servicebodypathuservalidate.PayloadType { +// NewMethodUserBodyPathValidatePayload builds a ServiceBodyPathUserValidate +// service MethodUserBodyPathValidate endpoint payload. +func NewMethodUserBodyPathValidatePayload(body *MethodUserBodyPathValidateRequestBody, b string) *servicebodypathuservalidate.PayloadType { v := &servicebodypathuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden index ad367e6d6b..c3066cb509 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyPathUserPayloadType builds a ServiceBodyPathUser service +// NewMethodBodyPathUserPayload builds a ServiceBodyPathUser service // MethodBodyPathUser endpoint payload. -func NewMethodBodyPathUserPayloadType(body *MethodBodyPathUserRequestBody, b string) *servicebodypathuser.PayloadType { +func NewMethodBodyPathUserPayload(body *MethodBodyPathUserRequestBody, b string) *servicebodypathuser.PayloadType { v := &servicebodypathuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden index dc825b939e..1faa5b1eea 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden @@ -1,7 +1,7 @@ -// NewMethodBodyQueryPathUserValidatePayloadType builds a +// NewMethodBodyQueryPathUserValidatePayload builds a // ServiceBodyQueryPathUserValidate service MethodBodyQueryPathUserValidate // endpoint payload. -func NewMethodBodyQueryPathUserValidatePayloadType(body *MethodBodyQueryPathUserValidateRequestBody, c2 string, b string) *servicebodyquerypathuservalidate.PayloadType { +func NewMethodBodyQueryPathUserValidatePayload(body *MethodBodyQueryPathUserValidateRequestBody, c2 string, b string) *servicebodyquerypathuservalidate.PayloadType { v := &servicebodyquerypathuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden index 534e69537c..4f642a9a58 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyQueryPathUserPayloadType builds a ServiceBodyQueryPathUser -// service MethodBodyQueryPathUser endpoint payload. -func NewMethodBodyQueryPathUserPayloadType(body *MethodBodyQueryPathUserRequestBody, c2 string, b *string) *servicebodyquerypathuser.PayloadType { +// NewMethodBodyQueryPathUserPayload builds a ServiceBodyQueryPathUser service +// MethodBodyQueryPathUser endpoint payload. +func NewMethodBodyQueryPathUserPayload(body *MethodBodyQueryPathUserRequestBody, c2 string, b *string) *servicebodyquerypathuser.PayloadType { v := &servicebodyquerypathuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden index eeedde3a5d..747436a53f 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyQueryUserUnionValidatePayloadType builds a +// NewMethodBodyQueryUserUnionValidatePayload builds a // ServiceBodyQueryUserUnionValidate service MethodBodyQueryUserUnionValidate // endpoint payload. -func NewMethodBodyQueryUserUnionValidatePayloadType(body *MethodBodyQueryUserUnionValidateRequestBody, b string) *servicebodyqueryuserunionvalidate.PayloadType { +func NewMethodBodyQueryUserUnionValidatePayload(body *MethodBodyQueryUserUnionValidateRequestBody, b string) *servicebodyqueryuserunionvalidate.PayloadType { v := &servicebodyqueryuserunionvalidate.PayloadType{} - v.A = unmarshalUnionToServicebodyqueryuserunionvalidateUnion(body.A) + v.A = unmarshalUnionToUnion(body.A) v.B = b return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden index add18b987b..127df06279 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyQueryUserUnionPayloadType builds a ServiceBodyQueryUserUnion +// NewMethodBodyQueryUserUnionPayload builds a ServiceBodyQueryUserUnion // service MethodBodyQueryUserUnion endpoint payload. -func NewMethodBodyQueryUserUnionPayloadType(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { +func NewMethodBodyQueryUserUnionPayload(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { v := &servicebodyqueryuserunion.PayloadType{} if body.A != nil { - v.A = unmarshalUnionToServicebodyqueryuserunionUnion(body.A) + v.A = unmarshalUnionToUnion(body.A) } v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden index c94ed6d859..30a5fa0c93 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden @@ -1,7 +1,6 @@ -// NewMethodBodyQueryUserValidatePayloadType builds a -// ServiceBodyQueryUserValidate service MethodBodyQueryUserValidate endpoint -// payload. -func NewMethodBodyQueryUserValidatePayloadType(body *MethodBodyQueryUserValidateRequestBody, b string) *servicebodyqueryuservalidate.PayloadType { +// NewMethodBodyQueryUserValidatePayload builds a ServiceBodyQueryUserValidate +// service MethodBodyQueryUserValidate endpoint payload. +func NewMethodBodyQueryUserValidatePayload(body *MethodBodyQueryUserValidateRequestBody, b string) *servicebodyqueryuservalidate.PayloadType { v := &servicebodyqueryuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden index e80362dac6..6f0cc9392b 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyQueryUserPayloadType builds a ServiceBodyQueryUser service +// NewMethodBodyQueryUserPayload builds a ServiceBodyQueryUser service // MethodBodyQueryUser endpoint payload. -func NewMethodBodyQueryUserPayloadType(body *MethodBodyQueryUserRequestBody, b *string) *servicebodyqueryuser.PayloadType { +func NewMethodBodyQueryUserPayload(body *MethodBodyQueryUserRequestBody, b *string) *servicebodyqueryuser.PayloadType { v := &servicebodyqueryuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden index 2858a747ac..cbfdc77dd9 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyUnionUnion builds a ServiceBodyUnion service MethodBodyUnion +// NewMethodBodyUnionPayload builds a ServiceBodyUnion service MethodBodyUnion // endpoint payload. -func NewMethodBodyUnionUnion(body *MethodBodyUnionRequestBody) *servicebodyunion.Union { +func NewMethodBodyUnionPayload(body *MethodBodyUnionRequestBody) *servicebodyunion.Union { v := &servicebodyunion.Union{} if body.Values != nil { switch string(body.Values.Kind()) { diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden index 88199e1d06..f2ec00cc6b 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden @@ -1,10 +1,9 @@ -// NewMethodBodyUserInnerDefaultPayloadType builds a -// ServiceBodyUserInnerDefault service MethodBodyUserInnerDefault endpoint -// payload. -func NewMethodBodyUserInnerDefaultPayloadType(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { +// NewMethodBodyUserInnerDefaultPayload builds a ServiceBodyUserInnerDefault +// service MethodBodyUserInnerDefault endpoint payload. +func NewMethodBodyUserInnerDefaultPayload(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { v := &servicebodyuserinnerdefault.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeToServicebodyuserinnerdefaultInnerType(body.Inner) + v.Inner = unmarshalInnerTypeToInnerType(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden index d86f57143d..592ca29245 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyUserInnerPayloadType builds a ServiceBodyUserInner service +// NewMethodBodyUserInnerPayload builds a ServiceBodyUserInner service // MethodBodyUserInner endpoint payload. -func NewMethodBodyUserInnerPayloadType(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { +func NewMethodBodyUserInnerPayload(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { v := &servicebodyuserinner.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeToServicebodyuserinnerInnerType(body.Inner) + v.Inner = unmarshalInnerTypeToInnerType(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden index e55c690121..ea64f5258f 100644 --- a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden @@ -14,9 +14,9 @@ type BPayload struct { Bytes []byte `form:"bytes,omitempty" json:"bytes,omitempty" xml:"bytes,omitempty"` } -// NewMethodAAPayload builds a ServiceMixedPayloadInBody service MethodA +// NewMethodAPayload builds a ServiceMixedPayloadInBody service MethodA // endpoint payload. -func NewMethodAAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APayload { +func NewMethodAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APayload { v := &servicemixedpayloadinbody.APayload{ Any: body.Any, } @@ -32,9 +32,9 @@ func NewMethodAAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APa v.Map[tk] = tv } } - v.Object = unmarshalBPayloadToServicemixedpayloadinbodyBPayload(body.Object) + v.Object = unmarshalBPayloadToBPayload(body.Object) if body.DupObj != nil { - v.DupObj = unmarshalBPayloadToServicemixedpayloadinbodyBPayload(body.DupObj) + v.DupObj = unmarshalBPayloadToBPayload2(body.DupObj) } return v diff --git a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden index f4fb982974..a2df4459a6 100644 --- a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden @@ -17,9 +17,9 @@ type APayload struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// NewMethodAAPayload builds a ServiceMultipleMethods service MethodA endpoint +// NewMethodAPayload builds a ServiceMultipleMethods service MethodA endpoint // payload. -func NewMethodAAPayload(body *MethodARequestBody) *servicemultiplemethods.APayload { +func NewMethodAPayload(body *MethodARequestBody) *servicemultiplemethods.APayload { v := &servicemultiplemethods.APayload{ A: body.A, } @@ -27,14 +27,14 @@ func NewMethodAAPayload(body *MethodARequestBody) *servicemultiplemethods.APaylo return v } -// NewMethodBPayloadType builds a ServiceMultipleMethods service MethodB -// endpoint payload. -func NewMethodBPayloadType(body *MethodBRequestBody) *servicemultiplemethods.PayloadType { +// NewMethodBPayload builds a ServiceMultipleMethods service MethodB endpoint +// payload. +func NewMethodBPayload(body *MethodBRequestBody) *servicemultiplemethods.PayloadType { v := &servicemultiplemethods.PayloadType{ A: *body.A, B: body.B, } - v.C = unmarshalAPayloadToServicemultiplemethodsAPayload(body.C) + v.C = unmarshalAPayloadToAPayload(body.C) return v } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden index a06e7d4d5d..0b1a51656b 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden @@ -26,7 +26,7 @@ func NewResulttypesiblingcollectionCollection(res serviceresultcollectionusertyp body[i] = nil continue } - body[i] = marshalServiceresultcollectionusertypesiblingviewsResulttypesiblingcollectionViewToResulttypesiblingcollection(val) + body[i] = marshalResulttypesiblingcollectionViewToResulttypesiblingcollection(val) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden index f7a67cbfdd..b306299286 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden @@ -24,7 +24,7 @@ type Rt struct { func NewMethodResultWithResultCollectionResponseBody(res *serviceresultwithresultcollection.MethodResultWithResultCollectionResult) *MethodResultWithResultCollectionResponseBody { body := &MethodResultWithResultCollectionResponseBody{} if res.A != nil { - body.A = marshalServiceresultwithresultcollectionResulttypeToResulttype(res.A) + body.A = marshalResulttypeToResulttype(res.A) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden index 31a6657ba6..8676f69f44 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden @@ -24,10 +24,10 @@ type Wrapper struct { func NewMethodResultUserTypeNestedResponseBody(res *serviceresultusertypenestedviews.ResulttypenestedView) *MethodResultUserTypeNestedResponseBody { body := &MethodResultUserTypeNestedResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserType(res.A) + body.A = marshalUserTypeViewToUserType(res.A) } if res.Nested != nil { - body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapper(res.Nested) + body.Nested = marshalWrapperViewToWrapper(res.Nested) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden index 9796f5c1f1..32e964b92a 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden @@ -19,10 +19,10 @@ type UserType struct { func NewMethodResultUserTypeSiblingResponseBody(res *serviceresultusertypesiblingviews.ResulttypesiblingView) *MethodResultUserTypeSiblingResponseBody { body := &MethodResultUserTypeSiblingResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserType(res.A) + body.A = marshalUserTypeViewToUserType(res.A) } if res.B != nil { - body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserType(res.B) + body.B = marshalUserTypeViewToUserType2(res.B) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden index 4230d02562..e953de3f90 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResponseBodyFull(res *serviceresultwithresultv Name: res.Name, } if res.Rt != nil { - body.Rt = marshalServiceresultwithresultviewviewsRtViewToRt(res.Rt) + body.Rt = marshalRtViewToRt(res.Rt) } return body } diff --git a/http/codegen/testdata/golden/sse-all-fields.golden b/http/codegen/testdata/golden/sse-all-fields.golden index 9c3b36535f..3df667134b 100644 --- a/http/codegen/testdata/golden/sse-all-fields.golden +++ b/http/codegen/testdata/golden/sse-all-fields.golden @@ -8,6 +8,8 @@ type SSEAllFieldsMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,31 +23,8 @@ func (s *SSEAllFieldsMethodServerStream) Send(v *sseallfieldsservice.SSEAllField // "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" // endpoint SSE connection with context. func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v *sseallfieldsservice.SSEAllFieldsMethodResult) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v - if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) - } - if event := res.Event; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) - } - if retry := res.Retry; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) - } - var data string var payload any body := NewSSEAllFieldsMethodResponseBody(res) @@ -94,14 +73,48 @@ func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) + + if id := res.ID; id != "" { + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } + } + if event := res.Event; event != "" { + if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil { + return err + } + } + if retry := res.Retry; retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", retry); err != nil { + return err + } + } + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEAllFieldsMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-bool.golden b/http/codegen/testdata/golden/sse-bool.golden index 4d88c2f936..bb8c678880 100644 --- a/http/codegen/testdata/golden/sse-bool.golden +++ b/http/codegen/testdata/golden/sse-bool.golden @@ -7,6 +7,8 @@ type SSEBoolMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "bool" to the "SSEBoolMethod" endpoint SSE @@ -18,24 +20,11 @@ func (s *SSEBoolMethodServerStream) Send(v bool) error { // SendWithContext SendWithContext streams instances of "bool" to the // "SSEBoolMethod" endpoint SSE connection with context. func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string var payload any - body := NewSSEBoolMethodResponseBody(res) + body := res payload = body switch v := payload.(type) { case nil: @@ -81,14 +70,33 @@ func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEBoolMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-client-object.golden b/http/codegen/testdata/golden/sse-client-object.golden index a664f71f48..f5dacee53c 100644 --- a/http/codegen/testdata/golden/sse-client-object.golden +++ b/http/codegen/testdata/golden/sse-client-object.golden @@ -204,7 +204,7 @@ func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseob } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Decode JSON into the struct pointer directly + // Decode the event data into the result value returned by Recv. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), diff --git a/http/codegen/testdata/golden/sse-data-field.golden b/http/codegen/testdata/golden/sse-data-field.golden index f61cb95147..f82c1fd502 100644 --- a/http/codegen/testdata/golden/sse-data-field.golden +++ b/http/codegen/testdata/golden/sse-data-field.golden @@ -8,6 +8,8 @@ type SSEDataFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,19 +23,6 @@ func (s *SSEDataFieldMethodServerStream) Send(v *ssedatafieldservice.SSEDataFiel // "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" // endpoint SSE connection with context. func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedatafieldservice.SSEDataFieldMethodResult) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string @@ -84,14 +73,33 @@ func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-data-id-field.golden b/http/codegen/testdata/golden/sse-data-id-field.golden index 3e7004aef2..ef5d432250 100644 --- a/http/codegen/testdata/golden/sse-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-data-id-field.golden @@ -8,6 +8,8 @@ type SSEDataIDFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,25 +23,8 @@ func (s *SSEDataIDFieldMethodServerStream) Send(v *ssedataidfieldservice.SSEData // "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the // "SSEDataIDFieldMethod" endpoint SSE connection with context. func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v - if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) - } - var data string var payload any body := NewSSEDataIDFieldMethodResponseBody(res) @@ -88,14 +73,38 @@ func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) + + if id := res.ID; id != "" { + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } + } + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataIDFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-int.golden b/http/codegen/testdata/golden/sse-int.golden index 952688803c..8cd8be349d 100644 --- a/http/codegen/testdata/golden/sse-int.golden +++ b/http/codegen/testdata/golden/sse-int.golden @@ -7,6 +7,8 @@ type SSEIntMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "int" to the "SSEIntMethod" endpoint SSE @@ -18,24 +20,11 @@ func (s *SSEIntMethodServerStream) Send(v int) error { // SendWithContext SendWithContext streams instances of "int" to the // "SSEIntMethod" endpoint SSE connection with context. func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string var payload any - body := NewSSEIntMethodResponseBody(res) + body := res payload = body switch v := payload.(type) { case nil: @@ -81,14 +70,33 @@ func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) e } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEIntMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-object.golden b/http/codegen/testdata/golden/sse-object.golden index 14e24ddc88..d576d17b41 100644 --- a/http/codegen/testdata/golden/sse-object.golden +++ b/http/codegen/testdata/golden/sse-object.golden @@ -8,6 +8,8 @@ type SSEObjectMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "sseobjectservice.SSEObjectMethodResult" to @@ -20,19 +22,6 @@ func (s *SSEObjectMethodServerStream) Send(v *sseobjectservice.SSEObjectMethodRe // "sseobjectservice.SSEObjectMethodResult" to the "SSEObjectMethod" endpoint // SSE connection with context. func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *sseobjectservice.SSEObjectMethodResult) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string @@ -83,14 +72,33 @@ func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *ss } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEObjectMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-request-id.golden b/http/codegen/testdata/golden/sse-request-id.golden index ce1e310295..6d88b2618f 100644 --- a/http/codegen/testdata/golden/sse-request-id.golden +++ b/http/codegen/testdata/golden/sse-request-id.golden @@ -8,6 +8,8 @@ type SSERequestIDMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "string" to the "SSERequestIDMethod" endpoint @@ -19,24 +21,11 @@ func (s *SSERequestIDMethodServerStream) Send(v string) error { // SendWithContext SendWithContext streams instances of "string" to the // "SSERequestIDMethod" endpoint SSE connection with context. func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v string) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string var payload any - body := NewSSERequestIDMethodResponseBody(res) + body := res payload = body switch v := payload.(type) { case nil: @@ -82,14 +71,33 @@ func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSERequestIDMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-string.golden b/http/codegen/testdata/golden/sse-string.golden index 4f5e7b9ed7..42eac8dbf4 100644 --- a/http/codegen/testdata/golden/sse-string.golden +++ b/http/codegen/testdata/golden/sse-string.golden @@ -8,6 +8,8 @@ type SSEStringMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "string" to the "SSEStringMethod" endpoint @@ -19,24 +21,11 @@ func (s *SSEStringMethodServerStream) Send(v string) error { // SendWithContext SendWithContext streams instances of "string" to the // "SSEStringMethod" endpoint SSE connection with context. func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v string) error { - s.once.Do(func() { - header := s.w.Header() - if header.Get("Content-Type") == "" { - header.Set("Content-Type", "text/event-stream") - } - if header.Get("Cache-Control") == "" { - header.Set("Cache-Control", "no-cache") - } - if header.Get("Connection") == "" { - header.Set("Connection", "keep-alive") - } - s.w.WriteHeader(http.StatusOK) - }) res := v var data string var payload any - body := NewSSEStringMethodResponseBody(res) + body := res payload = body switch v := payload.(type) { case nil: @@ -82,14 +71,33 @@ func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v str } data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + s.once.Do(func() { + header := s.w.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "text/event-stream") + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Connection") == "" { + header.Set("Connection", "keep-alive") + } + s.w.WriteHeader(http.StatusOK) + s.attempted = true + }) - http.NewResponseController(s.w).Flush() + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEStringMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden index 654be71931..dd38e7e8eb 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden @@ -59,7 +59,7 @@ func (s *BidirectionalComplexClientStream) Recv() (*testservice.Response, error) if err != nil { return rv, err } - res := NewBidirectionalComplexResponseOK(&body) + res := NewBidirectionalComplexResultOK(&body) return res, nil } diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden index 3f06b9f3d7..f70ffba2d2 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden @@ -59,7 +59,7 @@ func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, erro if err != nil { return rv, err } - res := NewBidirectionalWithViewsResponseOK(&body) + res := NewBidirectionalWithViewsResultOK(&body) vres := &testserviceviews.Response{Projected: res, View: s.view} if err := testserviceviews.ValidateResponse(vres); err != nil { return rv, goahttp.ErrValidationError("TestService", "BidirectionalWithViews", err) diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index df5472d33c..9e02d59e06 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -122,6 +122,13 @@ func NewCreateHandler( } _, err = endpoint(ctx, v) if err != nil { + stream := v.Stream.(*CreateServerStream) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -160,8 +167,8 @@ func NewCreateHandler( // discardCreateServerStream implements the mixedresultsservice.CreateServerStream // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. type discardCreateServerStream struct{} // Send discards the event. @@ -412,7 +419,7 @@ func (s *StreamingResultMethodClientStream) Recv() (*streamingresultservice.User if err != nil { return rv, err } - res := NewStreamingResultMethodUserTypeOK(&body) + res := NewStreamingResultMethodResultOK(&body) return res, nil } @@ -480,7 +487,7 @@ func (s *StreamingResultWithViewsMethodClientStream) Recv() (*streamingresultwit if err != nil { return rv, err } - res := NewStreamingResultWithViewsMethodUsertypeOK(&body) + res := NewStreamingResultWithViewsMethodResultOK(&body) vres := &streamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} if err := streamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultWithViewsService", "StreamingResultWithViewsMethod", err) @@ -559,7 +566,7 @@ func (s *StreamingResultWithExplicitViewMethodClientStream) Recv() (*streamingre if err != nil { return rv, err } - res := NewStreamingResultWithExplicitViewMethodUsertypeOK(&body) + res := NewStreamingResultWithExplicitViewMethodResultOK(&body) vres := &streamingresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := streamingresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultWithExplicitViewService", "StreamingResultWithExplicitViewMethod", err) @@ -685,7 +692,7 @@ func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streaming if err != nil { return rv, err } - res := NewStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) + res := NewStreamingResultCollectionWithViewsMethodResultOK(body) vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) @@ -803,7 +810,7 @@ func (s *StreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (st if err != nil { return rv, err } - res := NewStreamingResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) + res := NewStreamingResultCollectionWithExplicitViewMethodResultOK(body) vres := streamingresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultCollectionWithExplicitViewService", "StreamingResultCollectionWithExplicitViewMethod", err) @@ -1053,7 +1060,7 @@ func (s *StreamingResultUserTypeArrayMethodClientStream) Recv() ([]*streamingres if err != nil { return rv, err } - res := NewStreamingResultUserTypeArrayMethodUserTypeOK(body) + res := NewStreamingResultUserTypeArrayMethodResultOK(body) return res, nil } @@ -1120,7 +1127,7 @@ func (s *StreamingResultUserTypeMapMethodClientStream) Recv() (map[string]*strea if err != nil { return rv, err } - res := NewStreamingResultUserTypeMapMethodMapStringUserTypeOK(body) + res := NewStreamingResultUserTypeMapMethodResultOK(body) return res, nil } @@ -1364,7 +1371,7 @@ func (s *StreamingPayloadMethodClientStream) CloseAndRecv() (*streamingpayloadse if err != nil { return rv, err } - res := NewStreamingPayloadMethodUserTypeOK(&body) + res := NewStreamingPayloadMethodResultOK(&body) return res, nil } @@ -1497,7 +1504,7 @@ func (s *StreamingPayloadNoPayloadMethodClientStream) CloseAndRecv() (*streaming if err != nil { return rv, err } - res := NewStreamingPayloadNoPayloadMethodUserTypeOK(&body) + res := NewStreamingPayloadNoPayloadMethodResultOK(&body) return res, nil } @@ -1711,7 +1718,7 @@ func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*str if err != nil { return rv, err } - res := NewStreamingPayloadResultWithViewsMethodUsertypeOK(&body) + res := NewStreamingPayloadResultWithViewsMethodResultOK(&body) vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} if err := streamingpayloadresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultWithViewsService", "StreamingPayloadResultWithViewsMethod", err) @@ -1835,7 +1842,7 @@ func (s *StreamingPayloadResultWithExplicitViewMethodClientStream) CloseAndRecv( if err != nil { return rv, err } - res := NewStreamingPayloadResultWithExplicitViewMethodUsertypeOK(&body) + res := NewStreamingPayloadResultWithExplicitViewMethodResultOK(&body) vres := &streamingpayloadresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := streamingpayloadresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultWithExplicitViewService", "StreamingPayloadResultWithExplicitViewMethod", err) @@ -1973,7 +1980,7 @@ func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRe if err != nil { return rv, err } - res := NewStreamingPayloadResultCollectionWithViewsMethodUsertypeCollectionOK(body) + res := NewStreamingPayloadResultCollectionWithViewsMethodResultOK(body) vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} if err := streamingpayloadresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithViewsService", "StreamingPayloadResultCollectionWithViewsMethod", err) @@ -2102,7 +2109,7 @@ func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) Clo if err != nil { return rv, err } - res := NewStreamingPayloadResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) + res := NewStreamingPayloadResultCollectionWithExplicitViewMethodResultOK(body) vres := streamingpayloadresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingpayloadresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithExplicitViewService", "StreamingPayloadResultCollectionWithExplicitViewMethod", err) @@ -2886,7 +2893,7 @@ func (s *BidirectionalStreamingMethodClientStream) Recv() (*bidirectionalstreami if err != nil { return rv, err } - res := NewBidirectionalStreamingMethodUserTypeOK(&body) + res := NewBidirectionalStreamingMethodResultOK(&body) return res, nil } @@ -3045,7 +3052,7 @@ func (s *BidirectionalStreamingNoPayloadMethodClientStream) Recv() (*bidirection if err != nil { return rv, err } - res := NewBidirectionalStreamingNoPayloadMethodUserTypeOK(&body) + res := NewBidirectionalStreamingNoPayloadMethodResultOK(&body) return res, nil } @@ -3217,7 +3224,7 @@ func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidir if err != nil { return rv, err } - res := NewBidirectionalStreamingResultWithViewsMethodUsertypeOK(&body) + res := NewBidirectionalStreamingResultWithViewsMethodResultOK(&body) vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} if err := bidirectionalstreamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithViewsService", "BidirectionalStreamingResultWithViewsMethod", err) @@ -3367,7 +3374,7 @@ func (s *BidirectionalStreamingResultWithExplicitViewMethodClientStream) Recv() if err != nil { return rv, err } - res := NewBidirectionalStreamingResultWithExplicitViewMethodUsertypeOK(&body) + res := NewBidirectionalStreamingResultWithExplicitViewMethodResultOK(&body) vres := &bidirectionalstreamingresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := bidirectionalstreamingresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithExplicitViewService", "BidirectionalStreamingResultWithExplicitViewMethod", err) @@ -3518,7 +3525,7 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv if err != nil { return rv, err } - res := NewBidirectionalStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) + res := NewBidirectionalStreamingResultCollectionWithViewsMethodResultOK(body) vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} if err := bidirectionalstreamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithViewsService", "BidirectionalStreamingResultCollectionWithViewsMethod", err) @@ -3657,7 +3664,7 @@ func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodClientStrea if err != nil { return rv, err } - res := NewBidirectionalStreamingResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) + res := NewBidirectionalStreamingResultCollectionWithExplicitViewMethodResultOK(body) vres := bidirectionalstreamingresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithExplicitViewService", "BidirectionalStreamingResultCollectionWithExplicitViewMethod", err) @@ -4140,7 +4147,7 @@ func (s *BidirectionalStreamingUserTypeArrayMethodClientStream) Recv() ([]*bidir if err != nil { return rv, err } - res := NewBidirectionalStreamingUserTypeArrayMethodResultTypeOK(body) + res := NewBidirectionalStreamingUserTypeArrayMethodResultOK(body) return res, nil } @@ -4268,7 +4275,7 @@ func (s *BidirectionalStreamingUserTypeMapMethodClientStream) Recv() (map[string if err != nil { return rv, err } - res := NewBidirectionalStreamingUserTypeMapMethodMapStringResultTypeOK(body) + res := NewBidirectionalStreamingUserTypeMapMethodResultOK(body) return res, nil } diff --git a/http/codegen/testing.go b/http/codegen/testing.go deleted file mode 100644 index f2ebe27da7..0000000000 --- a/http/codegen/testing.go +++ /dev/null @@ -1,43 +0,0 @@ -// This file builds HTTP code-generation analysis in tests using the same -// generation construction, planning, freezing, and rendering as production. -package codegen - -import ( - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" -) - -// CreateHTTPServices creates a new ServicesData instance for testing. -// Generation construction normalizes the root before any planner reads it. -func CreateHTTPServices(root *expr.RootExpr) *ServicesData { - return NewServicesData(createServiceServices(root), root.API.HTTP) -} - -// createServiceServices performs the complete package declaration lifecycle -// required by transport test helpers. -func createServiceServices(root *expr.RootExpr) *service.ServicesData { - generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) - if err != nil { - panic(err) - } - servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - if err != nil { - panic(err) - } - if err := Plan(generation); err != nil { - panic(err) - } - if err := example.Plan(generation); err != nil { - panic(err) - } - if err := generation.Freeze(); err != nil { - panic(err) - } - if err := servicePlan.Link(); err != nil { - panic(err) - } - return servicePlan.Services() -} diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index 92063a093b..c4dc48dd4e 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -24,8 +24,8 @@ func TestTransformHelperServer(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ServerEncodeDecodeFile(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + f := plan.ServerFiles()[1] sections := f.SectionTemplates require.Greater(t, len(sections), c.Offset) code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) @@ -48,8 +48,8 @@ func TestTransformHelperCLI(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ClientEncodeDecodeFile(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + f := plan.ClientFiles()[1] sections := f.SectionTemplates require.Greater(t, len(sections), c.Offset) code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) diff --git a/http/codegen/typedef.go b/http/codegen/typedef.go index 7afa972004..a46fb67498 100644 --- a/http/codegen/typedef.go +++ b/http/codegen/typedef.go @@ -26,12 +26,12 @@ import ( func goTypeDef(scope *codegen.NameScope, att *expr.AttributeExpr, ptr, useDefault bool) string { ctx := codegen.NewAttributeContext(ptr, false, useDefault, "", scope) ctx.UnionPointer = true - return goTypeDefForContext(scope, att, ctx) + return goTypeDefForContext(att, ctx) } // goTypeDefForContext recursively renders an HTTP body type using the same // field representation consulted by transport conversion and validation. -func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { +func goTypeDefForContext(att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { switch actual := att.Type.(type) { case expr.Primitive: if t, _ := codegen.GetMetaType(att); t != "" { @@ -39,17 +39,17 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx } return codegen.GoNativeTypeName(actual) case *expr.Array: - d := goTypeDefForContext(scope, actual.ElemType, ctx) + d := goTypeDefForContext(actual.ElemType, ctx) if expr.IsObject(actual.ElemType.Type) { d = "*" + d } return "[]" + d case *expr.Map: - keyDef := goTypeDefForContext(scope, actual.KeyType, ctx) + keyDef := goTypeDefForContext(actual.KeyType, ctx) if expr.IsObject(actual.KeyType.Type) { keyDef = "*" + keyDef } - elemDef := goTypeDefForContext(scope, actual.ElemType, ctx) + elemDef := goTypeDefForContext(actual.ElemType, ctx) if expr.IsObject(actual.ElemType.Type) { elemDef = "*" + elemDef } @@ -67,7 +67,7 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx ) { fn = codegen.GoifyAtt(at, name, true) - tdef = goTypeDefForContext(scope, at, ctx) + tdef = goTypeDefForContext(at, ctx) if ctx.IsFieldPointer(name, att) { tdef = "*" + tdef } @@ -93,7 +93,7 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx ss = append(ss, "}") return strings.Join(ss, "\n") case expr.UserType, *expr.Union: - return scope.GoTypeName(att) + return ctx.Scope.Name(att, ctx.Pkg(att), ctx.Pointer, ctx.UseDefault) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } diff --git a/http/codegen/types.go b/http/codegen/types.go index 5cbfd67a82..b522becd1d 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -9,8 +9,8 @@ import ( "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the HTTP transport type files. -func ServerTypeFiles(data *ServicesData) []*codegen.File { +// serverTypeFiles builds the server request and response types read by Plan.Link. +func serverTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { fw[i] = addEndpointImports(typesFile(svc, true, data), data, svc.HTTPEndpoints...) @@ -18,8 +18,8 @@ func ServerTypeFiles(data *ServicesData) []*codegen.File { return fw } -// ClientTypeFiles returns the HTTP transport client types files. -func ClientTypeFiles(data *ServicesData) []*codegen.File { +// clientTypeFiles builds the client request and response types read by Plan.Link. +func clientTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { fw[i] = addEndpointImports(typesFile(svc, false, data), data, svc.HTTPEndpoints...) @@ -185,11 +185,18 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod bodies := resp.ServerBody if !svr { bodies = nil - if resp.ClientBody != nil { + if len(resp.ViewedRepresentations) > 0 { + for _, representation := range resp.ViewedRepresentations { + bodies = append(bodies, representation.ClientBody) + } + } else if resp.ClientBody != nil { bodies = []*TypeData{resp.ClientBody} } } for _, td := range bodies { + if td == nil { + continue + } addDecl(responseBodySection, td) if td.Init != nil { if _, ok := seenInits[td.Init.Name]; !ok { @@ -273,10 +280,16 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod seenResultInits := make(map[string]struct{}) for _, adata := range data.Endpoints { for _, resp := range adata.Result.Responses { - if init := resp.ResultInit; init != nil { - if _, ok := seenResultInits[init.Name]; !ok { - seenResultInits[init.Name] = struct{}{} - sections = append(sections, resultInitSection("client-result-init", init)) + inits := []*InitData{resp.ResultInit} + for _, representation := range resp.ViewedRepresentations { + inits = append(inits, representation.ResultInit) + } + for _, init := range inits { + if init != nil { + if _, ok := seenResultInits[init.Name]; !ok { + seenResultInits[init.Name] = struct{}{} + sections = append(sections, resultInitSection("client-result-init", init)) + } } } } diff --git a/http/codegen/viewed_sse_test.go b/http/codegen/viewed_sse_test.go new file mode 100644 index 0000000000..1705526bc9 --- /dev/null +++ b/http/codegen/viewed_sse_test.go @@ -0,0 +1,376 @@ +// This file verifies that an HTTP server-sent event encodes and decodes the +// body selected for its result view. +package codegen + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestViewedSSEServerLocksFirstView verifies that a request-scoped stream +// rejects a later representation before encoding a body under the first view. +func TestViewedSSEServerLocksFirstView(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ServerFiles(), "sse.go") + + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `goa.InvalidEnumValueError("view", view, []any{s.sentView})`) + require.Less(t, + strings.Index(code, `if s.sentView != "" && view != s.sentView`), + strings.Index(code, `s.once.Do(func()`), + ) + require.Less(t, strings.Index(code, "res := "), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, "body := "), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, `s.sentView = view`), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, `s.once.Do(func()`), strings.Index(code, `s.attempted = true`)) +} + +// TestViewedSSEClientReconstructsCollections verifies collection events decode +// the selected body, run its constructor and validator, and return the service +// method's result type. +func TestViewedSSEClientReconstructsCollections(t *testing.T) { + root := expr.RunDSL(t, viewedSSECollectionDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ClientFiles(), "sse.go") + + require.Contains(t, code, "switch view {") + require.Contains(t, code, "Decode(&body)") + require.Contains(t, code, "projected := New") + require.Contains(t, code, "views.Validate") + require.Contains(t, code, "result := viewedssecollection.New") + require.NotContains(t, code, `partial_sse_parse`) +} + +// TestViewedSSEUsesConfiguredDataField verifies the server encodes and the +// client decodes only the result field selected as the event data. +func TestViewedSSEUsesConfiguredDataField(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDataFieldDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles(), "sse.go") + server := renderedFile(t, plan.ServerFiles(), "sse.go") + + require.Contains(t, client, "Decode(&body.Data)") + require.Contains(t, client, "projected := New") + require.Contains(t, client, "views.Validate") + require.Contains(t, server, "payload = body.Data") +} + +// TestViewedSSERebuildsRequiredResponseFields checks that the client reads the +// event id, event type, and data before it calls the generated result +// constructor and validator. +func TestViewedSSERebuildsRequiredResponseFields(t *testing.T) { + root := expr.RunDSL(t, viewedSSERequiredFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles(), "sse.go") + + for _, assignment := range []string{ + "body.ID = event.ID", + "body.Kind = event.Kind", + "Decode(&body.Data)", + } { + require.Contains(t, client, assignment) + require.Less(t, strings.Index(client, assignment), strings.Index(client, "projected := New")) + } + require.Less(t, strings.Index(client, "projected := New"), strings.Index(client, "views.Validate")) +} + +// TestViewedClientsUseAssignedValidator checks that each HTTP client calls the +// validator name chosen for the service views package when another declaration +// requests the validator's preferred spelling. +func TestViewedClientsUseAssignedValidator(t *testing.T) { + root := expr.RunDSL(t, viewedClientValidatorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + viewsPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "viewed_validator", "views")) + require.NoError(t, err) + preferred := "ValidateViewedClientCollision" + require.NoError(t, viewsPackage.DeclareName(codegen.NewExactName(codegen.NameFunction, preferred))) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + var declaration *codegen.NameDeclaration + for _, endpoint := range plans[0].services.Get("Viewed Validator").Endpoints { + if endpoint.Method.ViewedResult != nil { + declaration = endpoint.Method.ViewedResult.Validate.Declaration + break + } + } + require.NotNil(t, declaration) + require.NotEqual(t, preferred, declaration.Name()) + client := renderedFiles(t, plans[0].ClientFiles()) + require.GreaterOrEqual(t, strings.Count(client, "."+declaration.Name()+"("), 3) + require.NotContains(t, client, "."+preferred+"(") +} + +// TestViewedSSESoleViewIsFixed checks that the generated service supplies its +// only legal view, so the HTTP response needs no view selector. +func TestViewedSSESoleViewIsFixed(t *testing.T) { + plan := linkedHTTPPlan(t, viewedSSESoleViewDSL) + viewed, ok := plan.ViewedResult("Viewed SSE Sole View", "Watch") + require.True(t, ok) + + require.False(t, viewed.Variable) + require.Equal(t, expr.DefaultView, viewed.FixedView) + require.Len(t, viewed.Representations, 1) +} + +// TestViewedResultConstructorsUsePackageDeclarations verifies Go-equivalent +// view names receive distinct stable functions and every definition and call +// uses the same package-owned declaration. +func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, viewedSSEConstructorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + response := endpoint.Responses[0] + retained, ok := plans[0].ViewedResult("Viewed SSE Collision", "Watch") + require.True(t, ok) + require.GreaterOrEqual(t, len(retained.Representations), 2) + names := make(map[string]struct{}, len(retained.Representations)) + collidingNames := make(map[string]string, 2) + definitions := renderedFiles(t, plans[0].ClientTypeFiles()) + calls := renderedFile(t, plans[0].ClientFiles(), "sse.go") + for _, representation := range retained.Representations { + declaration := plans[0].constructors[viewedConstructorKey{ + endpoint: endpoint, + response: response, + view: representation.View, + }] + require.Same(t, declaration, representation.ResultInit.Declaration) + name := declaration.Name() + names[name] = struct{}{} + if representation.View == "foo-bar" || representation.View == "foo bar" { + collidingNames[representation.View] = name + } + require.Contains(t, definitions, "func "+name+"(") + require.Contains(t, calls, name+"(") + } + require.Len(t, names, len(retained.Representations)) + require.NotEqual(t, collidingNames["foo-bar"], collidingNames["foo bar"]) +} + +// renderedFile renders all sections of the file whose base name is suffix. +func renderedFile(t *testing.T, files []*codegen.File, suffix string) string { + t.Helper() + for _, file := range files { + if strings.HasSuffix(file.Path, suffix) { + return renderedFiles(t, []*codegen.File{file}) + } + } + t.Fatalf("generated file ending in %q was not planned", suffix) + return "" +} + +// renderedFiles renders every planned section so tests compare generated Go +// definitions and calls rather than template text. +func renderedFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates[1:] { + rendered.WriteString(codegen.SectionCode(t, section)) + } + } + return rendered.String() +} + +// viewedSSEType defines two legal branches with different body shapes. +func viewedSSEType(name string) *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd."+name, func() { + dsl.TypeName(name) + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) +} + +// viewedSSEDSL defines a variable-view object stream. +func viewedSSEDSL() { + event := viewedSSEType("ViewedSSEEvent") + dsl.Service("Viewed SSE", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSECollectionDSL defines a variable-view collection stream. +func viewedSSECollectionDSL() { + event := viewedSSEType("ViewedSSECollectionEvent") + dsl.Service("Viewed SSE Collection", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.CollectionOf(event)) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSEDataFieldDSL defines a variable-view stream whose data line carries +// one configured result field. +func viewedSSEDataFieldDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-data", func() { + dsl.TypeName("ViewedSSEData") + dsl.Attribute("data", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + +// viewedSSERequiredFieldsDSL maps required result fields across every input a +// streamed HTTP response can carry. +func viewedSSERequiredFieldsDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-required", func() { + dsl.TypeName("ViewedSSERequired") + dsl.Attribute("id", dsl.String) + dsl.Attribute("kind", dsl.String) + dsl.Attribute("data", dsl.String) + dsl.Required("id", "kind", "data") + for _, name := range []string{"summary", "detailed"} { + dsl.View(name, func() { + dsl.Attribute("id") + dsl.Attribute("kind") + dsl.Attribute("data") + }) + } + }) + dsl.Service("Viewed SSE Required", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("kind") + }) + }) + }) + }) +} + +// viewedClientValidatorCollisionDSL exposes one viewed type through unary, +// server-sent event, and WebSocket responses. +func viewedClientValidatorCollisionDSL() { + result := dsl.ResultType("application/vnd.viewed-client-collision", func() { + dsl.TypeName("ViewedClientCollision") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed Validator", func() { + dsl.Method("Read", func() { + dsl.Result(result) + dsl.HTTP(func() { dsl.GET("/read") }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + }) +} + +// viewedSSESoleViewDSL defines only Goa's default view. +func viewedSSESoleViewDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-sole-view", func() { + dsl.TypeName("ViewedSSESoleView") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View(expr.DefaultView, func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Sole View", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// linkedHTTPPlan runs the same planning steps as the generator and returns the +// HTTP plan after all service and package names are available. +func linkedHTTPPlan(t *testing.T, design func()) *Plan { + t.Helper() + root := expr.RunDSL(t, design) + return linkedHTTPPlanForRoot(t, root) +} + +// viewedSSEConstructorCollisionDSL defines two view names that Goify maps to +// the same preferred constructor spelling. +func viewedSSEConstructorCollisionDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-collision", func() { + dsl.TypeName("ViewedSSECollisionEvent") + dsl.Attribute("id", dsl.String) + dsl.View("foo-bar", func() { dsl.Attribute("id") }) + dsl.View("foo bar", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Collision", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 9a6f45aad5..b94d79d905 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -1,23 +1,32 @@ -// This file analyzes HTTP streaming endpoints into the WebSocket server and -// client data rendered by their dedicated generated files. +// This file builds the values used to write WebSocket client and server files +// for streaming HTTP methods. package codegen import ( "fmt" "path/filepath" "slices" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) type ( + // connConfigurerData gives WebSocket code the type and constructor names for + // either the client or server package. + connConfigurerData struct { + *ServiceData + Declaration *codegen.NameDeclaration + InitDeclaration *codegen.NameDeclaration + } + // WebSocketData contains the data needed to render struct type that // implements the server and client stream interfaces. WebSocketData struct { // VarName is the name of the struct. VarName string + // VarDeclaration is the package name used by the stream implementation type. + VarDeclaration *codegen.NameDeclaration // Type is type of the stream (server or client). Type string // Interface is the fully qualified name of the interface that @@ -116,15 +125,11 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin serverCode string err error ) - n := codegen.Goify(e.MethodExpr.Name, true) - p := codegen.Goify(svrPayload.Name, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.streamConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("streaming payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.MethodExpr.Name) if body != expr.Empty { ref := "body" @@ -134,7 +139,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin var svcode string if ut, ok := body.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { - httpctx := httpContext(sd.serverWireTypes.scope, true, true) + httpctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) svcode = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") } } @@ -143,8 +148,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin AttributeData: &AttributeData{ Name: "payload", VarName: "body", - TypeName: sd.serverWireTypes.scope.GoTypeName(streamBody), - TypeRef: sd.serverWireTypes.scope.GoTypeRef(streamBody), + TypeName: svrPayload.VarName, + TypeRef: svrPayload.Ref, Type: streamBody.Type, Required: true, Example: sds.Example(streamBody, streamOwner), @@ -154,8 +159,8 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin } if body != expr.Empty { var helpers []*codegen.TransformFunctionData - httpctx := httpContext(sd.serverWireTypes.scope, true, true) - serverCode, helpers, err = marshal(streamBody, e.MethodExpr.StreamingPayload, "body", "v", httpctx, svcctx) + httpctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(streamBody, e.MethodExpr.StreamingPayload, "body", "v", "marshal", httpctx, svcctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -164,6 +169,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin panic(err) // bug } svrPayload.Init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, @@ -268,9 +274,9 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod } } -// WebsocketClientFile returns the file implementing the WebSocket client +// websocketClientFile returns the file implementing the WebSocket client // streaming implementation if any. -func WebsocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil @@ -307,11 +313,12 @@ func WebsocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod // serverStructWSSections return section templates that generate WebSocket // related struct type definitions for the server. func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -330,11 +337,12 @@ func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // serverWSSections returns section templates that contain server WebSocket // specific code for the given service. func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -382,11 +390,12 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientStructWSSections return section templates that generate WebSocket // related struct type definitions for the client. func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -404,11 +413,12 @@ func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientWSSections returns section templates that contain client WebSocket // specific code for the given service. func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { diff --git a/http/codegen/websocket_golden_test.go b/http/codegen/websocket_golden_test.go index 7eb1aa2c78..7499627e14 100644 --- a/http/codegen/websocket_golden_test.go +++ b/http/codegen/websocket_golden_test.go @@ -73,13 +73,13 @@ func TestWebSocketGoldenFiles(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { root := expr.RunDSL(t, c.dsl) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) var files []*codegen.File if c.fileType == "server" { - files = ServerFiles(services) + files = plan.ServerFiles() } else { - files = ClientFiles(services) + files = plan.ClientFiles() } // Find the websocket.go file @@ -120,11 +120,11 @@ func TestWebSocketGoldenFiles(t *testing.T) { func TestWebSocketTemplateExercise(t *testing.T) { // Run a comprehensive test that should exercise all templates root := expr.RunDSL(t, comprehensiveWebSocketDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) // Generate both server and client files - serverFiles := ServerFiles(services) - clientFiles := ClientFiles(services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Verify WebSocket files were generated var serverWSFile, clientWSFile *codegen.File diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go index 1526eeedd9..1baae246f3 100644 --- a/http/codegen/wire_catalog.go +++ b/http/codegen/wire_catalog.go @@ -1,14 +1,14 @@ -// This file owns declaration identity and names for the wire types emitted by -// one generated HTTP or JSON-RPC client or server package. The catalog first -// collects detached shapes, then freezes names, and only then lets analysis -// build declarations, references, and validators from those records. +// This file assigns Go names to request and response types in one generated +// HTTP or JSON-RPC package. Each copied type is recorded before names are +// assigned. Its definition, references, and validation function then use the +// same record. package codegen import ( + "cmp" "fmt" "reflect" "slices" - "strconv" "strings" "goa.design/goa/v3/codegen" @@ -17,20 +17,41 @@ import ( ) type ( - // wireTypeCatalog owns the names and declarations emitted by one transport output package. + // wireTypeCatalog stores every request or response type written into one Go + // package and the Go name chosen for each type. wireTypeCatalog struct { + pkg *codegen.GeneratedPackage scope *codegen.NameScope records []*wireTypeRecord + transforms []*wireTransformRecord unionOccurrences []wireUnionOccurrence unions []*wireUnionRecord - names map[string]int - frozen bool + declared bool + linked bool + bindings map[*expr.AttributeExpr]*wireTypeRecord + unionBindings map[*expr.Union]*wireUnionRecord } - // wireUnionRecord owns one emitted positional union in this output package. + // wireTransformRecord stores one value conversion and any extra functions it + // needs. + wireTransformRecord struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + prefix string + owner string + plan *codegen.TransformPlan + used bool + } + + // wireUnionRecord stores one generated union and the Go names used for its + // type, branches, constants, and functions. wireUnionRecord struct { identity wireUnionIdentity union *expr.Union + declaration *codegen.NameDeclaration + kind *codegen.NameDeclaration + kindDecls []*codegen.NameDeclaration + ctorDecls []*codegen.NameDeclaration name string kindName string kindConsts []string @@ -38,32 +59,35 @@ type ( data *service.UnionTypeData } - // wireUnionOccurrence records one union use until branch declarations have names. + // wireUnionOccurrence stores one copied union until Goa assigns names to its branches. wireUnionOccurrence struct { union *expr.Union role wireTypeRole policy wireTypePolicy } - // wireUnionIdentity combines the authored wire shape with the exact frozen - // declarations referenced by its branches. + // wireUnionIdentity pairs a union definition with the Go type used by each branch. wireUnionIdentity struct { definition codegen.UnionTypeID declarations []*wireTypeRecord } - // wireTypeRecord is the canonical package-local declaration selected for a wire identity. + // wireTypeRecord stores one generated type and its optional functions. wireTypeRecord struct { - identity wireTypeIdentity - name string - ref string - data *TypeData + identity wireTypeIdentity + declaration *codegen.NameDeclaration + validator *codegen.NameDeclaration + constructor *codegen.NameDeclaration + needsValidator bool + needsConstructor bool + name string + ref string + data *TypeData } - // wireTypeIdentity contains typed declaration provenance and every policy - // fact that changes the emitted Go type. + // wireTypeIdentity contains a designed type and the rules that change its Go definition. wireTypeIdentity struct { - source expr.UserType + sourceID string resultID string role wireTypeRole preferred string @@ -71,7 +95,8 @@ type ( policy wireTypePolicy } - // wireTypePolicy describes the pointer, default, validation, and view rules applied to a wire shape. + // wireTypePolicy records how one copied type represents fields, pointers, + // default values, validation, and result views. wireTypePolicy struct { request bool pointer bool @@ -80,14 +105,38 @@ type ( view string } - // wireTypeRole identifies synthetic declarations that have no authored Origin. + // wireTypeRole says whether an unnamed designed type is used for a request, + // response, field, or stream value. wireTypeRole uint8 - // wireAttributePair identifies two recursive attributes already compared. + // wireAttributePair remembers two attributes already compared so values that + // refer back to themselves do not cause an endless loop. wireAttributePair struct { left *expr.AttributeExpr right *expr.AttributeExpr } + + // wireNameOrder contains designed values used to choose stable suffixes when + // several declarations ask for the same Go name. + wireNameOrder struct { + family string + source string + role uint8 + preferred string + shape string + view string + request bool + pointer bool + defaults bool + } + + // wireAttributeScope chooses the Go type name for each copied HTTP field. + wireAttributeScope struct { + catalog *wireTypeCatalog + base codegen.Attributor + pkg string + policy wireTypePolicy + } ) const ( @@ -97,38 +146,82 @@ const ( wireStreamPayload ) -// newWireTypeCatalog constructs an empty output-package catalog. -func newWireTypeCatalog(reserved ...string) *wireTypeCatalog { - scope := codegen.NewNameScope() - names := make(map[string]int, len(reserved)) - for _, name := range reserved { - scope.Unique(name) - names[name] = 1 +// newWireTypeCatalog creates the type list for one generated Go package. Tests +// may omit the package when they only compare copied attributes. +func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { + catalog := &wireTypeCatalog{ + bindings: make(map[*expr.AttributeExpr]*wireTypeRecord), + unionBindings: make(map[*expr.Union]*wireUnionRecord), + } + if len(pkg) > 0 { + catalog.pkg = pkg[0] } - return &wireTypeCatalog{scope: scope, names: names} + return catalog } // collect records attribute and every named type it contains. func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { - if c.frozen { - panic("cannot collect HTTP wire type after catalog freeze") + if c.declared { + panic("cannot collect an HTTP type after its package declarations are submitted") } return c.collectRecursive(attribute, role, policy, preferred, make(map[expr.UserType]struct{})) } -// Freeze assigns final names and binds copied user types to the catalog scope. -func (c *wireTypeCatalog) Freeze() { - if c.frozen { +// collectChildren records named types inside attribute without recording its +// top-level named type a second time. +func (c *wireTypeCatalog) collectChildren(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { + if userType, ok := attribute.Type.(expr.UserType); ok { + c.collectRecursive(userType.Attribute(), role, policy, "", make(map[expr.UserType]struct{})) return } + c.collectRecursive(attribute, role, policy, "", make(map[expr.UserType]struct{})) +} + +// Declare requests every type and function name this HTTP package can write. +// The caller invokes it before Goa chooses names so every file writing to the +// same package can resolve conflicts together. +func (c *wireTypeCatalog) Declare() error { + if c.declared { + return nil + } + if c.pkg == nil { + return fmt.Errorf("HTTP type declarations require a generated package") + } for _, record := range c.records { - record.name = c.uniqueName(record.identity.preferred) - record.ref = wireTypeRef(record.name, record.identity.attribute.Type) - setWireTypeName(record.identity.attribute, record.name) - if userType, ok := record.identity.attribute.Type.(expr.UserType); ok { - c.scope.HashedUnique(userType, record.name) - } else { - c.scope.Unique(record.name) + record.declaration = codegen.NewPreferredName( + codegen.NameType, + record.identity.preferred, + codegen.ExportedName, + record.identity.order("type"), + ) + if err := c.pkg.DeclareName(record.declaration); err != nil { + return err + } + if record.needsValidator { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "Validate", + "", + record.identity.order("validator"), + ) + if err != nil { + return err + } + record.validator = declaration + } + if record.needsConstructor { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "New", + "", + record.identity.order("constructor"), + ) + if err != nil { + return err + } + record.constructor = declaration } } for _, occurrence := range c.unionOccurrences { @@ -138,33 +231,199 @@ func (c *wireTypeCatalog) Freeze() { } } for _, union := range c.unions { - union.name = c.uniqueName(codegen.Goify(union.union.Name(), true)) - union.kindName = c.uniqueName(union.name + "Kind") - union.kindConsts = make([]string, len(union.union.Values)) - union.constructors = make([]string, len(union.union.Values)) + union.declaration = codegen.NewPreferredName( + codegen.NameType, + union.union.Name(), + codegen.ExportedName, + union.identity.order("union", union.union.Name(), ""), + ) + if err := c.pkg.DeclareName(union.declaration); err != nil { + return err + } + kind, err := c.pkg.DeclareDependentName( + codegen.NameType, + union.declaration, + "", + "Kind", + union.identity.order("union kind", union.union.Name(), ""), + ) + if err != nil { + return err + } + union.kind = kind + union.kindDecls = make([]*codegen.NameDeclaration, len(union.union.Values)) + union.ctorDecls = make([]*codegen.NameDeclaration, len(union.union.Values)) for index, branch := range union.union.Values { - fieldName := codegen.Goify(branch.Name, true) - union.kindConsts[index] = c.uniqueName(union.kindName + fieldName) - union.constructors[index] = c.uniqueName("New" + union.name + fieldName) + kindDeclaration, err := c.pkg.DeclareDependentName( + codegen.NameConstant, + union.kind, + "", + codegen.Goify(branch.Name, true), + union.identity.order("union constant", union.union.Name(), branch.Name), + ) + if err != nil { + return err + } + constructor, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + union.declaration, + "New", + codegen.Goify(branch.Name, true), + union.identity.order("union constructor", union.union.Name(), branch.Name), + ) + if err != nil { + return err + } + union.kindDecls[index] = kindDeclaration + union.ctorDecls[index] = constructor + } + } + for _, transform := range c.transforms { + for _, helper := range transform.plan.Helpers() { + preferred := transform.prefix + codegen.Goify(wireTransformTypeName(helper.Source), true) + "To" + codegen.Goify(wireTransformTypeName(helper.Target), true) + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + codegen.UnexportedName, + wireNameOrder{ + family: "transform helper", + source: expr.Hash(transform.source.Type, false, false, false), + preferred: preferred, + shape: expr.Hash(transform.target.Type, false, false, false), + role: uint8(helper.Occurrence), + view: transform.owner, + }, + ) + if err := c.pkg.DeclareName(declaration); err != nil { + return err + } + if err := transform.plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return err + } + } + } + c.declared = true + return nil +} + +// collectTransform records one request or response conversion before Goa +// chooses the names of any extra conversion functions. Calls use these records +// in the same order. +func (c *wireTypeCatalog) collectTransform(source, target *expr.AttributeExpr, prefix, owner string) { + if c.declared { + panic("cannot collect an HTTP conversion after package declarations are submitted") + } + source = expr.DupAtt(source) + target = expr.DupAtt(target) + plan, err := codegen.NewTransformPlan(source, target) + if err != nil { + panic(err) + } + c.transforms = append(c.transforms, &wireTransformRecord{source: source, target: target, prefix: prefix, owner: owner, plan: plan}) +} + +// renderTransform writes the next matching conversion with the function names +// chosen by Declare. It returns an error when collectTransform did not record +// the conversion. +func (c *wireTypeCatalog) renderTransform(source, target *expr.AttributeExpr, sourceVar, targetVar, prefix string, sourceContext, targetContext *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { + for _, transform := range c.transforms { + if transform.used || transform.prefix != prefix || + !wireAttributesEqual(transform.source, source, make(map[wireAttributePair]struct{})) || + !wireAttributesEqual(transform.target, target, make(map[wireAttributePair]struct{})) { + continue + } + if err := transform.plan.BindContexts(sourceContext, targetContext); err != nil { + return "", nil, err + } + c.bindTransformOccurrence(transform.source, source, sourceContext) + c.bindTransformOccurrence(transform.target, target, targetContext) + for _, helper := range transform.plan.Helpers() { + c.bindTransformHelper(helper.Source, sourceContext) + c.bindTransformHelper(helper.Target, targetContext) } + transform.used = true + return transform.plan.Render(sourceVar, targetVar, true) + } + return "", nil, fmt.Errorf("HTTP %s conversion was not submitted before package names were assigned", prefix) +} + +// bindTransformHelper gives a nested copied field the same Go type name used by +// its generated conversion function. Service values already receive names from +// the service generator. +func (c *wireTypeCatalog) bindTransformHelper(attribute *expr.AttributeExpr, context *codegen.AttributeContext) { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return + } + policy := scope.policy + policy.view = "" + c.applyNamesRecursive(attribute, wireAttribute, policy, make(map[expr.UserType]struct{})) +} + +// bindTransformOccurrence records the Go type used by one copied conversion +// value and each named field inside it. +func (c *wireTypeCatalog) bindTransformOccurrence(planned, rendered *expr.AttributeExpr, context *codegen.AttributeContext) { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return + } + record := c.bindings[rendered] + if record == nil { + c.applyNamesRecursive(planned, wireAttribute, scope.policy, make(map[expr.UserType]struct{})) + return + } + c.bindOccurrence(planned, record) + c.applyNamesRecursive(planned, record.identity.role, record.identity.policy, make(map[expr.UserType]struct{})) +} + +// wireTransformTypeName returns the designed type name used in a generated +// conversion function name. +func wireTransformTypeName(attribute *expr.AttributeExpr) string { + if userType, ok := attribute.Type.(expr.UserType); ok { + name := wireTypeDeclaredName(userType) + if location := codegen.UserTypeLocation(userType); location != nil { + return location.PackageName() + codegen.Goify(name, true) + } + return name + } + if union, ok := attribute.Type.(*expr.Union); ok { + return union.Name() + } + return attribute.Type.Name() +} + +// Link reads the assigned package names and builds the type definitions, +// references, unions, and validation functions written to files. +func (c *wireTypeCatalog) Link() { + if c.linked { + return + } + if !c.declared { + panic("cannot link HTTP types before declaring their package names") + } + c.scope = c.pkg.Scope() + for _, record := range c.records { + record.name = record.declaration.Name() + record.ref = wireTypeRef(record.name, record.identity.attribute.Type) } for _, union := range c.unions { - c.applyUnionRecord(union.union, union) - c.scope.HashedUnique(codegen.NewUnionTypeID(union.union), union.name) - c.scope.Unique(union.kindName) - for index := range union.kindConsts { - c.scope.Unique(union.kindConsts[index]) - c.scope.Unique(union.constructors[index]) + union.name = union.declaration.Name() + union.kindName = union.kind.Name() + union.kindConsts = make([]string, len(union.kindDecls)) + union.constructors = make([]string, len(union.ctorDecls)) + for index := range union.kindDecls { + union.kindConsts[index] = union.kindDecls[index].Name() + union.constructors[index] = union.ctorDecls[index].Name() } + c.applyUnionRecord(union.union, union) } for _, union := range c.unions { - union.data = buildHTTPUnionTypeData(union.union, c.scope, union) + union.data = buildHTTPUnionTypeData(union.union, c.resolver(c.scope, wireTypePolicy{}), union) } - c.scope.Freeze() - c.frozen = true + c.linked = true } -// wireTypeRef returns the Go reference owned by a frozen declaration record. +// wireTypeRef adds a pointer when the generated Go type requires one. func wireTypeRef(name string, dataType expr.DataType) string { if _, inline := dataType.(*expr.Object); inline { return name @@ -175,22 +434,24 @@ func wireTypeRef(name string, dataType expr.DataType) string { return name } -// lookup returns the frozen record and applies its name to an equivalent occurrence. +// lookup returns the chosen Go names for an equivalent copied type. It panics +// when the type was not recorded before names were assigned. func (c *wireTypeCatalog) lookup(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { - if !c.frozen { - panic("cannot resolve HTTP wire type before catalog freeze") + if !c.linked { + panic("cannot resolve an HTTP type before its generated package freezes") } identity := newWireTypeIdentity(attribute, role, policy, preferred) record := c.find(identity) if record != nil { - setWireTypeName(attribute, record.name) + c.bindOccurrence(attribute, record) return record } - panic(fmt.Sprintf("HTTP wire type %q was not collected before catalog freeze", preferred)) + panic(fmt.Sprintf("HTTP type %q was not submitted before package names were assigned", preferred)) } -// lookupUser returns the frozen record for a named user type and nil for an -// inline or primitive occurrence that has no top-level declaration. +// lookupUser returns the generated name information for a named design type. +// It returns nil for inline and primitive values because they define no named +// type at the top level. func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) *wireTypeRecord { if attribute.Type == expr.Empty { return nil @@ -206,13 +467,14 @@ func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTyp return c.lookup(attribute, role, policy, codegen.Goify(preferred, true)) } -// applyNames writes every frozen nested declaration name onto one detached -// occurrence before type definitions or transforms traverse it. +// applyNames associates every copied nested attribute with the Go type name +// used by its definition and conversions. func (c *wireTypeCatalog) applyNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { c.applyNamesRecursive(attribute, role, policy, make(map[expr.UserType]struct{})) } -// applyNamesRecursive follows named fields once per authored origin. +// applyNamesRecursive follows each named field once, including fields that +// refer back to an outer type. func (c *wireTypeCatalog) applyNamesRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, seen map[expr.UserType]struct{}) { if attribute.Type == expr.Empty { return @@ -246,13 +508,13 @@ func (c *wireTypeCatalog) applyNamesRecursive(attribute *expr.AttributeExpr, rol identity := c.unionIdentity(actual, role, policy) record := c.findUnion(identity) if record == nil { - panic(fmt.Sprintf("HTTP union %q was not collected before catalog freeze", actual.Name())) + panic(fmt.Sprintf("HTTP union %q was not submitted before package names were assigned", actual.Name())) } c.applyUnionRecord(actual, record) } } -// unionTypes returns the frozen union declarations in deterministic name order. +// unionTypes returns the generated union definitions in Go name order. func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { unions := make([]*service.UnionTypeData, len(c.unions)) for index, record := range c.unions { @@ -262,10 +524,18 @@ func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { return unions } -// bind attaches occurrence-specific TypeData to its canonical declaration and -// merges the validator generated by any occurrence of that declaration. +// bind associates the data used to write a type with its chosen Go name. When +// several equivalent copies need validation, it stores their shared validator. func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData { data.declaration = record + if data.ValidateDef != "" { + data.ValidatorName = record.validator.Name() + data.ValidateRef = strings.Replace(data.ValidateRef, "Validate"+record.name, data.ValidatorName, 1) + } + if data.Init != nil { + data.Init.Declaration = record.constructor + data.Init.Name = record.constructor.Name() + } if record.data == nil { if data.Def == "" && data.ValidateDef == "" { return data @@ -279,7 +549,7 @@ func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData if record.data.Def == "" { record.data.Def = data.Def } else if record.data.Def != data.Def { - panic(fmt.Sprintf("HTTP wire type %q produced conflicting declarations", record.name)) + panic(fmt.Sprintf("HTTP type %q produced conflicting declarations", record.name)) } } if data.ValidateDef != "" { @@ -287,13 +557,14 @@ func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData record.data.ValidateDef = data.ValidateDef record.data.ValidateRef = data.ValidateRef } else if record.data.ValidateDef != data.ValidateDef || record.data.ValidateRef != data.ValidateRef { - panic(fmt.Sprintf("HTTP wire type %q produced conflicting validators", record.name)) + panic(fmt.Sprintf("HTTP type %q produced conflicting validators", record.name)) } } return data } -// collectRecursive records named declarations and terminates cycles by source Origin. +// collectRecursive records named types and stops when a type refers back to one +// it is already reading. func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, seen map[expr.UserType]struct{}) *wireTypeRecord { if attribute.Type == expr.Empty { return nil @@ -348,17 +619,19 @@ func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role w return record } -// findOrAppend reuses a structurally equal typed record or appends a new one. +// findOrAppend reuses a record with the same generated type definition or adds +// a new record. func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecord { if record := c.find(identity); record != nil { + record.needsValidator = record.needsValidator || identity.policy.validate return record } - record := &wireTypeRecord{identity: identity} + record := &wireTypeRecord{identity: identity, needsValidator: identity.policy.validate} c.records = append(c.records, record) return record } -// find returns the declaration record equal to identity. +// find returns the record for the same generated type definition. func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { for _, record := range c.records { if wireTypeIdentitiesEqual(record.identity, identity) { @@ -368,8 +641,8 @@ func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { return nil } -// unionIdentity resolves every named declaration referenced by union without -// changing the detached occurrence. +// unionIdentity returns the Go type used by every named branch of +// union without changing union. func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, policy wireTypePolicy) wireUnionIdentity { identity := wireUnionIdentity{definition: codegen.NewUnionTypeID(union)} attribute := &expr.AttributeExpr{Type: union} @@ -377,7 +650,7 @@ func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, po return identity } -// collectUnionDeclarations records package declarations in branch traversal order. +// collectUnionDeclarations records generated branch types in branch order. func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, declarations *[]*wireTypeRecord, seen map[expr.UserType]struct{}) { if attribute.Type == expr.Empty { return @@ -389,7 +662,7 @@ func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr } record := c.find(newWireTypeIdentity(attribute, role, policy, codegen.Goify(preferred, true))) if record == nil { - panic(fmt.Sprintf("HTTP union branch type %q was not collected before catalog freeze", preferred)) + panic(fmt.Sprintf("HTTP union branch type %q was not submitted before package names were assigned", preferred)) } *declarations = append(*declarations, record) origin := userType.Origin() @@ -422,33 +695,33 @@ func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr } } -// applyUnionRecord writes exactly the branch declarations captured by record -// onto one equivalent union occurrence. +// applyUnionRecord gives one copied union the exact branch type names stored in +// record. func (c *wireTypeCatalog) applyUnionRecord(union *expr.Union, record *wireUnionRecord) { - union.TypeName = record.name + c.unionBindings[union] = record index := 0 seen := make(map[expr.UserType]struct{}) for _, branch := range union.Values { c.applyResolvedDeclarations(branch.Attribute, record.identity.declarations, &index, seen) } if index != len(record.identity.declarations) { - panic(fmt.Sprintf("HTTP union %q did not consume its frozen branch declarations", record.name)) + panic(fmt.Sprintf("HTTP union %q did not use every submitted branch name", record.name)) } } -// applyResolvedDeclarations consumes the typed declaration sequence captured -// while the union identity was built. +// applyResolvedDeclarations gives each named branch its previously chosen Go +// type in the same order those types were recorded. func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExpr, declarations []*wireTypeRecord, index *int, seen map[expr.UserType]struct{}) { if attribute.Type == expr.Empty { return } if userType, ok := attribute.Type.(expr.UserType); ok { if *index >= len(declarations) { - panic(fmt.Sprintf("HTTP union branch %q has no frozen declaration", wireTypeDeclaredName(userType))) + panic(fmt.Sprintf("HTTP union branch %q has no submitted Go type name", wireTypeDeclaredName(userType))) } record := declarations[*index] *index = *index + 1 - setWireTypeName(attribute, record.name) + c.bindOccurrence(attribute, record) origin := userType.Origin() if _, ok := seen[origin]; ok { return @@ -477,13 +750,142 @@ func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExp identity := wireUnionIdentity{definition: definition, declarations: declarations[start:*index]} record := c.findUnion(identity) if record == nil { - panic(fmt.Sprintf("HTTP nested union %q has no frozen declaration", actual.Name())) + panic(fmt.Sprintf("HTTP nested union %q has no submitted Go type name", actual.Name())) } - actual.TypeName = record.name + c.unionBindings[actual] = record } } -// findUnion returns the package union record equal to identity. +// resolver returns the chosen HTTP type names. The supplied name list is used +// only to keep local variable names unique. +func (c *wireTypeCatalog) resolver(scope *codegen.NameScope, policy wireTypePolicy) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy} +} + +// bindOccurrence records the Go type used by one copied named value and its fields. +func (c *wireTypeCatalog) bindOccurrence(attribute *expr.AttributeExpr, record *wireTypeRecord) { + c.bindings[attribute] = record + if userType, ok := attribute.Type.(expr.UserType); ok { + c.bindings[userType.Attribute()] = record + } +} + +// Name returns the type name selected for this HTTP attribute copy. +func (s *wireAttributeScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + if record := s.record(attribute); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + if union, ok := attribute.Type.(*expr.Union); ok { + if record := s.unionRecord(union); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + } + switch actual := attribute.Type.(type) { + case expr.Primitive: + if name, _ := codegen.GetMetaType(attribute); name != "" { + return name + } + return codegen.GoNativeTypeName(actual) + case *expr.Array, *expr.Map, *expr.Object: + context := &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: useDefault, + Scope: s, + UnionPointer: true, + } + return goTypeDefForContext(attribute, context) + case expr.UserType: + panic(fmt.Sprintf("HTTP type %q has no package declaration", wireTypeDeclaredName(actual))) + default: + return s.base.Name(attribute, pkg, pointer, useDefault) + } +} + +// Ref returns the pointer or value spelling for this HTTP attribute copy. +func (s *wireAttributeScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + return wireTypeRef(s.Name(attribute, pkg, s.policy.pointer, s.policy.useDefault), attribute.Type) +} + +// Field returns the generated Go field for an HTTP attribute. +func (*wireAttributeScope) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(attribute, name, firstUpper) +} + +// Package returns the Go package name written before the type for attribute. +func (s *wireAttributeScope) Package(attribute *expr.AttributeExpr) string { + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + return location.PackageName() + } + return s.pkg +} + +// Enter returns type names with the Go package name needed by nested fields. +func (s *wireAttributeScope) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + pkg := s.pkg + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + pkg = location.PackageName() + } + return &wireAttributeScope{catalog: s.catalog, base: s.base.Enter(attribute), pkg: pkg, policy: s.policy} +} + +// IsSumType reports that HTTP unions use generated sum-type structs. +func (*wireAttributeScope) IsSumType() bool { + return true +} + +// ValidatorName returns the validation function chosen for this copied type. +func (s *wireAttributeScope) ValidatorName(attribute *expr.AttributeExpr, view string) string { + if record := s.record(attribute); record != nil { + if record.validator == nil { + panic(fmt.Sprintf("HTTP type %q has no validator declaration", record.name)) + } + return record.validator.Name() + } + if userType, ok := attribute.Type.(expr.UserType); ok { + panic(fmt.Sprintf("HTTP validator for %q has no package declaration", wireTypeDeclaredName(userType))) + } + return s.base.ValidatorName(attribute, view) +} + +// record returns the chosen type for attribute. A copied nested value may reuse +// a type when its pointer and default-value rules are the same. +func (s *wireAttributeScope) record(attribute *expr.AttributeExpr) *wireTypeRecord { + if record := s.catalog.bindings[attribute]; record != nil { + return record + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + preferred := wireTypeDeclaredName(userType.Origin()) + if s.policy.view != "" { + preferred = wireTypeDeclaredName(userType) + } + preferred = codegen.Goify(preferred, true) + return s.catalog.find(newWireTypeIdentity(attribute, wireAttribute, s.policy, preferred)) +} + +// unionRecord returns the chosen union for union. A copied nested union may +// reuse it when its pointer and default-value rules are the same. +func (s *wireAttributeScope) unionRecord(union *expr.Union) *wireUnionRecord { + if record := s.catalog.unionBindings[union]; record != nil { + return record + } + return s.catalog.findUnion(s.catalog.unionIdentity(union, wireAttribute, s.policy)) +} + +// Scope returns the list used to keep local variable names unique. +func (s *wireAttributeScope) Scope() *codegen.NameScope { + return s.base.Scope() +} + +// findUnion returns the package record for the same generated union. func (c *wireTypeCatalog) findUnion(identity wireUnionIdentity) *wireUnionRecord { for _, record := range c.unions { if wireUnionIdentitiesEqual(record.identity, identity) { @@ -493,29 +895,14 @@ func (c *wireTypeCatalog) findUnion(identity wireUnionIdentity) *wireUnionRecord return nil } -// wireUnionIdentitiesEqual compares the typed declaration sequence referenced by a wire union. +// wireUnionIdentitiesEqual reports whether two unions use the same definition +// and the same generated branch types. func wireUnionIdentitiesEqual(left, right wireUnionIdentity) bool { return left.definition == right.definition && slices.Equal(left.declarations, right.declarations) } -// uniqueName allocates a package declaration without creating a second scope identity. -func (c *wireTypeCatalog) uniqueName(preferred string) string { - count := c.names[preferred] - if count == 0 { - c.names[preferred] = 1 - return preferred - } - for index := count + 1; ; index++ { - name := preferred + strconv.Itoa(index) - if c.names[name] == 0 { - c.names[preferred] = index - c.names[name] = 1 - return name - } - } -} - -// newWireTypeIdentity builds a typed identity from a detached occurrence. +// newWireTypeIdentity records the designed value and the rules that determine +// its generated Go type. func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) wireTypeIdentity { identity := wireTypeIdentity{role: role, preferred: preferred, attribute: expr.DupAtt(attribute), policy: policy} if userType, ok := attribute.Type.(expr.UserType); ok { @@ -523,19 +910,19 @@ func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, polic identity.resultID = resultType.Identifier identity.role = 0 } else if policy.view == "" { - identity.source = userType.Origin() + identity.sourceID = userType.Origin().ID() identity.role = 0 } } return identity } -// wireTypeIdentitiesEqual compares provenance, policy, and the detached attribute contract. +// wireTypeIdentitiesEqual reports whether two records produce the same Go type. func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { - if left.source != right.source || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { + if left.sourceID != right.sourceID || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { return false } - if left.source != nil { + if left.sourceID != "" { leftType := left.attribute.Type.(expr.UserType) rightType := right.attribute.Type.(expr.UserType) return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) @@ -547,16 +934,90 @@ func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { return wireAttributesEqual(left.attribute, right.attribute, make(map[wireAttributePair]struct{})) } -// wireTypePoliciesEqual compares only facts that change a Go declaration. -// Validation helpers are separate package records and do not create a second -// type when the wire representation is otherwise identical. +// order returns the designed values used to choose a stable suffix when several +// HTTP declarations ask for the same Go name. +func (i wireTypeIdentity) order(family string) wireNameOrder { + return wireNameOrder{ + family: family, + source: i.sourceID + i.resultID, + role: uint8(i.role), + preferred: i.preferred, + shape: expr.Hash(i.attribute.Type, false, false, false), + view: i.policy.view, + request: i.policy.request, + pointer: i.policy.pointer, + defaults: i.policy.useDefault, + } +} + +// order returns the designed values used to choose stable suffixes for a union, +// its constants, and its functions. +func (i wireUnionIdentity) order(family, name, branch string) wireNameOrder { + declarations := make([]string, len(i.declarations)) + for index, declaration := range i.declarations { + order := declaration.identity.order("type") + declarations[index] = fmt.Sprintf( + "%q:%d:%q:%q:%q:%t:%t:%t", + order.source, + order.role, + order.preferred, + order.shape, + order.view, + order.request, + order.pointer, + order.defaults, + ) + } + return wireNameOrder{ + family: family, + source: strings.Join(declarations, "\x00"), + preferred: name, + shape: string(i.definition), + view: branch, + } +} + +// ComparePackageName orders HTTP declarations from designed values so memory +// addresses and design reading order cannot change generated names. +func (o wireNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(wireNameOrder) + for _, compared := range []int{ + cmp.Compare(o.family, right.family), + cmp.Compare(o.source, right.source), + cmp.Compare(o.role, right.role), + cmp.Compare(o.preferred, right.preferred), + cmp.Compare(o.shape, right.shape), + cmp.Compare(o.view, right.view), + cmp.Compare(boolOrder(o.request), boolOrder(right.request)), + cmp.Compare(boolOrder(o.pointer), boolOrder(right.pointer)), + cmp.Compare(boolOrder(o.defaults), boolOrder(right.defaults)), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// boolOrder converts false to zero and true to one for name ordering. +func boolOrder(value bool) uint8 { + if value { + return 1 + } + return 0 +} + +// wireTypePoliciesEqual compares only rules that change a Go type definition. +// A validation function does not create a second type when every field is the +// same. func wireTypePoliciesEqual(left, right wireTypePolicy) bool { left.validate = false right.validate = false return left == right } -// wireAttributesEqual compares facts that change a declaration or validator and terminates cycles. +// wireAttributesEqual compares the designed facts that change a generated type +// or its validation function. It handles types that refer back to themselves. func wireAttributesEqual(left, right *expr.AttributeExpr, seen map[wireAttributePair]struct{}) bool { if left == right { return true @@ -615,7 +1076,7 @@ func wireAttributesEqual(left, right *expr.AttributeExpr, seen map[wireAttribute } } -// wireMetadataEqual compares authored metadata while ignoring the name written during Freeze. +// wireMetadataEqual compares design metadata while ignoring the Go name added later. func wireMetadataEqual(left, right expr.MetaExpr) bool { keys := make([]string, 0, len(left)) for key := range left { @@ -642,7 +1103,8 @@ func wireMetadataEqual(left, right expr.MetaExpr) bool { return true } -// sortedWireAttributes makes declaration allocation independent of authored object field order. +// sortedWireAttributes keeps generated Go names stable when object fields are +// listed in a different order. func sortedWireAttributes(attributes []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { sorted := slices.Clone(attributes) slices.SortFunc(sorted, func(left, right *expr.NamedAttributeExpr) int { @@ -651,18 +1113,8 @@ func sortedWireAttributes(attributes []*expr.NamedAttributeExpr) []*expr.NamedAt return sorted } -// setWireTypeName records the package-owned name on a detached user type. -func setWireTypeName(attribute *expr.AttributeExpr, name string) { - if attribute.Type == expr.Empty { - return - } - if userType, ok := attribute.Type.(expr.UserType); ok { - userType.Attribute().AddMeta("struct:type:name", name) - } -} - -// wireTypeDeclaredName returns the stable expression declaration name rather -// than a package name previously assigned through struct:type:name metadata. +// wireTypeDeclaredName returns the type name written in the design instead of a +// generated Go name saved in metadata. func wireTypeDeclaredName(userType expr.UserType) string { switch actual := userType.(type) { case *expr.UserTypeExpr: diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go index 80c9f1af19..af9a38b564 100644 --- a/http/codegen/wire_catalog_test.go +++ b/http/codegen/wire_catalog_test.go @@ -1,5 +1,5 @@ -// This file verifies HTTP output packages distinguish declarations by source -// provenance and wire policy while reusing identical emitted shapes. +// This file verifies copied HTTP request and response types receive the right +// Go names when their source types, field rules, or generated functions differ. package codegen import ( @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) @@ -16,22 +17,22 @@ func TestWireTypeCatalogIdentity(t *testing.T) { first := wireCatalogType("Shared", "same", "first", true) second := wireCatalogType("Shared", "same", "second", false) - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) firstBody := makeHTTPType(&expr.AttributeExpr{Type: first}) catalog.collect(firstBody, wireRequestBody, request, "") catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request, "") catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request, "") catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) firstRecord := catalog.lookupUser(firstBody, wireRequestBody, request) reusedRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) secondRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) responseRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) require.Same(t, firstRecord, reusedRecord) - require.Equal(t, "Shared", firstRecord.name) - require.Equal(t, "Shared2", secondRecord.name) - require.Equal(t, "Shared3", responseRecord.name) + require.Len(t, map[string]struct{}{ + firstRecord.name: {}, secondRecord.name: {}, responseRecord.name: {}, + }, 3) } func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { @@ -40,11 +41,11 @@ func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { recursive.AttributeExpr = &expr.AttributeExpr{Type: object} object.Set("next", &expr.AttributeExpr{Type: recursive}) - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) body := makeHTTPType(&expr.AttributeExpr{Type: recursive}) policy := wireTypePolicy{request: true, pointer: true} catalog.collect(body, wireRequestBody, policy, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) record := catalog.lookupUser(body, wireRequestBody, policy) require.Equal(t, "Node", record.name) @@ -55,13 +56,13 @@ func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *te typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} withoutValidator := wireTypePolicy{pointer: true} withValidator := wireTypePolicy{pointer: true, validate: true} - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator, "") second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator, "") require.Same(t, first, second) - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) catalog.bind(first, &TypeData{Def: "struct { Value string }"}) catalog.bind(second, &TypeData{Def: "struct { Value string }", ValidateDef: "validate shared"}) require.Equal(t, "Shared", first.name) @@ -77,11 +78,11 @@ func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { }, } attribute := &expr.AttributeExpr{Type: union} - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") require.Len(t, catalog.unionOccurrences, 1) - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) require.Len(t, catalog.unions, 1) require.NotNil(t, catalog.unions[0].data) @@ -90,9 +91,9 @@ func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T) { attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} policy := wireTypePolicy{pointer: true} - catalog := newWireTypeCatalog("Shared") + catalog, generation := testWireTypeCatalog(t, "Shared") catalog.collect(attribute, wireAttribute, policy, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) first := catalog.lookupUser(attribute, wireAttribute, policy) second := catalog.lookupUser(attribute, wireAttribute, policy) @@ -107,10 +108,10 @@ func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { attribute := &expr.AttributeExpr{Type: &expr.Object{ {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, }} - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) if originalNil { @@ -123,9 +124,9 @@ func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Known", "known", "value", true)} policy := wireTypePolicy{request: true, pointer: true} - catalog := newWireTypeCatalog() + catalog, generation := testWireTypeCatalog(t) catalog.collect(typeAttribute, wireRequestBody, policy, "") - catalog.Freeze() + linkTestWireTypeCatalog(t, generation, catalog) require.Panics(t, func() { catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy, "") @@ -138,8 +139,33 @@ func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { }) } -// wireCatalogType builds an independent authored declaration. Equal UIDs are -// intentional: wire identity follows Origin rather than the example ID. +// testWireTypeCatalog creates the generated package that assigns names for a test. +// Reserved names simulate declarations contributed by another generator. +func testWireTypeCatalog(t *testing.T, reserved ...string) (*wireTypeCatalog, *codegen.Generation) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/http/test/client") + require.NoError(t, err) + for _, name := range reserved { + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameVariable, name))) + } + return newWireTypeCatalog(pkg), generation +} + +// linkTestWireTypeCatalog submits the collected declarations, asks the +// generation to assign all package names, and makes those names available to +// the test. +func linkTestWireTypeCatalog(t *testing.T, generation *codegen.Generation, catalog *wireTypeCatalog) { + t.Helper() + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) + catalog.Link() +} + +// wireCatalogType builds an independent declared type. Equal UIDs are +// intentional because the original declared type, not the example ID, selects +// the copied HTTP type. func wireCatalogType(name, uid, field string, required bool) *expr.UserTypeExpr { attribute := &expr.AttributeExpr{Type: expr.String} attribute.Validation = &expr.ValidationExpr{Pattern: field} diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 0a8f2fa351..d47948cffa 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -1,5 +1,5 @@ -// This file renders JSON-RPC client calls and codecs per service and keeps -// generated-type imports local to each returned file. +// This file writes JSON-RPC client calls, request encoders, and response +// decoders for each service. Each file imports only the types it uses. package codegen import ( @@ -7,25 +7,46 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ClientFiles returns the generated JSON-RPC client files. -func ClientFiles(data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, addEndpointImports(clientFile(svc, data), data, svc.HTTPEndpoints...)) - if f := websocketClientFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, jsonRPCWebSocketEndpoints(svc)...)) +type ( + // clientTemplateData stores the service values and Go names used to write + // one client package. + clientTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BufferPool is the byte buffer variable used by clients without WebSockets. + BufferPool *codegen.NameDeclaration + // WebSocketConnection is the shared WebSocket connection type. + WebSocketConnection *codegen.NameDeclaration + // WebSocketRequestOwner is the type that marks one method stream closed. + WebSocketRequestOwner *codegen.NameDeclaration + // WebSocketPendingRequest is the type that stores one waiting request. + WebSocketPendingRequest *codegen.NameDeclaration + // WebSocketMessage is the type that reads one incoming WebSocket message. + WebSocketMessage *codegen.NameDeclaration + // WebSocketClosedError is the error returned after a method stream closes. + WebSocketClosedError *codegen.NameDeclaration + // NewWebSocketConnection is the shared WebSocket connection constructor. + NewWebSocketConnection *codegen.NameDeclaration + } +) + +// clientFiles builds client, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func clientFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + files = append(files, addFileImports(clientFile(planned), planned.data)) + if f := websocketClientFile(planned); f != nil { + files = append(files, addFileImports(f, planned.data)) } - if f := sseClientFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, jsonRPCSSEEndpoints(svc)...)) + if f := sseClientFile(planned); f != nil { + files = append(files, addFileImports(f, planned.data)) } } - for _, svc := range jsvcs { - f := httpcodegen.ClientEncodeDecodeFile(svc, data) + for _, planned := range services { + f := planned.data.ClientCodecFile() if f == nil { continue } @@ -40,30 +61,69 @@ func ClientFiles(data *httpcodegen.ServicesData) []*codegen.File { codegen.AddImport(s, codegen.GoaImport("jsonrpc")) case "response-decoder": s.Source = jsonrpcTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP) + s.FuncMap["buildResponseData"] = buildJSONRPCResponseData + for name, function := range viewedResultFuncs(planned) { + s.FuncMap[name] = function + } swapped++ } s.Name = "jsonrpc-" + s.Name } + viewed := clientViewedResultSections(planned) + if len(viewed) > 0 { + header := f.SectionTemplates[0] + codegen.AddImport(header, &codegen.ImportSpec{Path: "encoding/json"}) + codegen.AddImport(header, codegen.GoaImport("")) + codegen.AddImport(header, planned.data.ViewImport()) + f.SectionTemplates = append(f.SectionTemplates, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-body-decoder", + Source: jsonrpcTemplates.Read(viewedResultBodyDecodeT), + Data: planned.bodyDecoder, + }) + f.SectionTemplates = append(f.SectionTemplates, viewed...) + } // The HTTP client file emits exactly one response decoder per // endpoint. Guard against the two generators drifting apart. - if n := len(data.Get(svc.Name()).Endpoints); swapped != n { - panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, svc.Name(), n)) + if n := len(planned.data.Endpoints); swapped != n { + panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, planned.name, n)) } - files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) + files = append(files, addFileImports(f, planned.data)) } return files } -// clientFile returns the client HTTP transport file -func clientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +// buildJSONRPCResponseData gives the shared response reader one copied JSON-RPC +// response together with the service and method names written in client errors. +func buildJSONRPCResponseData(data httpcodegen.JSONRPCResponseData, serviceName string, method httpcodegen.JSONRPCMethodData) map[string]any { + return map[string]any{ + "Data": data, + "ServiceName": serviceName, + "Method": method, + } +} + +// clientFile builds the JSON-RPC client methods for one service. +func clientFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &clientTemplateData{ + JSONRPCServiceSnapshot: data, + BufferPool: planned.clientNames.bufferPool, + WebSocketConnection: planned.clientNames.websocketConnection, + WebSocketRequestOwner: planned.clientNames.websocketRequestOwner, + WebSocketPendingRequest: planned.clientNames.websocketPendingRequest, + WebSocketMessage: planned.clientNames.websocketMessage, + WebSocketClosedError: planned.clientNames.websocketClosedError, + NewWebSocketConnection: planned.clientNames.newWebsocketConnection, + } svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") - title := fmt.Sprintf("%s client JSON-RPC transport", svc.Name()) + title := fmt.Sprintf("%s client JSON-RPC transport", planned.name) imports := []*codegen.ImportSpec{ {Path: "bufio"}, {Path: "bytes"}, {Path: "context"}, + {Path: "encoding/json"}, + {Path: "errors"}, {Path: "fmt"}, {Path: "io"}, {Path: "net/http"}, @@ -76,7 +136,7 @@ func clientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "client", imports), @@ -84,44 +144,91 @@ func clientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-struct", Source: jsonrpcTemplates.Read(clientStructT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasWebSocket": hasJSONRPCWebSocket, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-init", Source: jsonrpcTemplates.Read(clientInitT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasWebSocket": hasJSONRPCWebSocket, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) - for _, e := range data.Endpoints { + funcs := viewedResultFuncs(planned) + for _, e := range planned.endpoints { sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-endpoint-init", Source: jsonrpcTemplates.Read(clientEndpointInitT), - Data: e, + Data: &e.JSONRPCEndpointSnapshot, FuncMap: map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "viewedDecodeName": funcs["viewedDecodeName"], + "websocketRequestOwnerName": planned.websocketRequestOwnerName, }, }) } - if httpcodegen.HasWebSocket(data) { + if hasJSONRPCWebSocket(data) { sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-websocket-conn", Source: jsonrpcTemplates.Read(websocketClientConnT), - Data: data, + Data: renderData, }) } return &codegen.File{Path: path, SectionTemplates: sections} } + +// websocketRequestOwnerName returns the type used to mark one method stream +// closed. +func (s *servicePlan) websocketRequestOwnerName() string { + return s.clientNames.websocketRequestOwner.Name() +} + +// hasJSONRPCWebSocket reports whether service has a method that uses WebSocket. +// Generated clients include shared connection fields only when one is needed. +func hasJSONRPCWebSocket(data any) bool { + service := jsonRPCClientService(data) + for index := range service.Endpoints { + if isJSONRPCWebSocketEndpoint(service.Endpoints[index]) { + return true + } + } + return false +} + +// hasJSONRPCSSE reports whether service has a method that sends server-sent +// events. Generated clients include stream fields only when one is needed. + +func hasJSONRPCSSE(data any) bool { + service := jsonRPCClientService(data) + for _, endpoint := range service.Endpoints { + if endpoint.SSE != nil { + return true + } + } + return false +} + +// jsonRPCClientService returns the copied service values used to write a +// generated client. +func jsonRPCClientService(data any) httpcodegen.JSONRPCServiceSnapshot { + switch value := data.(type) { + case httpcodegen.JSONRPCServiceSnapshot: + return value + case *clientTemplateData: + return value.JSONRPCServiceSnapshot + default: + panic(fmt.Sprintf("JSON-RPC client received data of type %T", data)) + } +} diff --git a/jsonrpc/codegen/example_server.go b/jsonrpc/codegen/example_server.go deleted file mode 100644 index e6cf14625b..0000000000 --- a/jsonrpc/codegen/example_server.go +++ /dev/null @@ -1,84 +0,0 @@ -// This file augments runnable HTTP server examples with JSON-RPC mounts while -// preserving the generated service and transport aliases selected during -// planning. -package codegen - -import ( - "path" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -// ExampleServerFiles returns example JSON-RPC server implementation. -func ExampleServerFiles(data *httpcodegen.ServicesData, files []*codegen.File) []*codegen.File { - var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := exampleServer(data, svr, files); m != nil { - fw = append(fw, m) - } - } - return fw -} - -// exampleServer adds a server's JSON-RPC imports and mount code to its shared -// HTTP example file. -func exampleServer(data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { - genpkg := data.GenPkg() - svrdata := example.Servers.Get(svr, data.Root) - httppath := filepath.Join("cmd", svrdata.Dir, "http.go") - - // Retrieve existing HTTP server file or create a new one - var file *codegen.File - var hasHTTP bool - for _, f := range files { - if f.Path == httppath { - file = f - hasHTTP = true - break - } - } - if file == nil { - file = httpcodegen.ExampleServer(data.Root, svr, data) - } - - // Add JSON-RPC imports to the HTTP server file. - header := file.SectionTemplates[0] - for _, svc := range data.Root.API.JSONRPC.Services { - sd := data.Get(svc.Name()) - svcName := sd.Service.PathName - codegen.AddImport(header, data.ServiceImport(svc.Name())) - codegen.AddImport(header, data.PackageImport(path.Join(genpkg, "jsonrpc", svcName, "server"))) - } - - // Add JSON-RPC to the HTTP server file - var svcdata []*httpcodegen.ServiceData - for _, svc := range svr.Services { - if d := data.Get(svc); d != nil { - svcdata = append(svcdata, d) - } - } - for _, s := range file.SectionTemplates { - switch s.Name { - case "server-http-start": - // Only set the JSON-RPC services if not already populated. - data := s.Data.(map[string]any) - if existing, _ := data["JSONRPCServices"].([]*httpcodegen.ServiceData); len(existing) == 0 { - data["JSONRPCServices"] = svcdata - } - case "server-http-init", "server-http-end": - updateData(s, svcdata, hasHTTP) - } - } - return file -} - -func updateData(s *codegen.SectionTemplate, svcdata []*httpcodegen.ServiceData, hasHTTP bool) { - s.Data.(map[string]any)["JSONRPCServices"] = svcdata - if !hasHTTP { - delete(s.Data.(map[string]any), "Services") - } -} diff --git a/jsonrpc/codegen/idempotency_test.go b/jsonrpc/codegen/idempotency_test.go index d7c2295240..2a987672f3 100644 --- a/jsonrpc/codegen/idempotency_test.go +++ b/jsonrpc/codegen/idempotency_test.go @@ -31,8 +31,8 @@ func TestIdempotentJSONRPCEndpointCodegen(t *testing.T) { }) }) }) - services := CreateJSONRPCServices(root) - clientFiles := ClientFiles(services) + plan := CreateJSONRPCPlan(root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) var clientCode string diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index 304cfe63af..f01f4b8644 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -38,13 +38,25 @@ func TestJSONRPCKitchenSink(t *testing.T) { examples := expr.NewExampleGenerator(root.API.RandomizerFactory) servicePlan, err := service.NewPlan(root, generation, examples) require.NoError(t, err) - require.NoError(t, jsonrpccodegen.Plan(generation)) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, Service: servicePlan, HTTP: jsonHTTPPlans[0], ApplicationHTTP: httpPlans[0], + }) + require.NoError(t, err) + grpcPlan, err := grpccodegen.Plan(generation, grpccodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonHTTPPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) services := servicePlan.Services() - tfiles := kitchenSinkTransportFiles(root, services) - efiles := kitchenSinkExampleFiles(root, servicePlan) + tfiles := kitchenSinkTransportFiles(services, grpcPlan, httpPlans[0], jsonPlans[0]) + efiles := kitchenSinkExampleFiles(root, servicePlan, grpcPlan, httpPlans[0], jsonPlans[0]) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -80,16 +92,15 @@ func TestJSONRPCKitchenSink(t *testing.T) { // kitchenSinkTransportFiles assembles every transport file through the public // subsystem APIs exercised by the golden fixture. -func kitchenSinkTransportFiles(root *expr.RootExpr, services *service.ServicesData) []*goacodegen.File { - httpServices := httpcodegen.NewServicesData(services, root.API.HTTP) - files := httpcodegen.ServerFiles(httpServices) - files = append(files, httpcodegen.ClientFiles(httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(httpServices)...) - files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(httpServices)...) +func kitchenSinkTransportFiles(services *service.ServicesData, grpcPlan *grpccodegen.PreparedPlan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { + files := httpPlan.ServerFiles() + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) - grpcServices := grpccodegen.NewServicesData(services) + grpcServices := grpccodegen.NewServicesData(services, grpcPlan) files = append(files, grpccodegen.ProtoFiles(grpcServices)...) files = append(files, grpccodegen.ServerFiles(grpcServices)...) files = append(files, grpccodegen.ClientFiles(grpcServices)...) @@ -97,18 +108,17 @@ func kitchenSinkTransportFiles(root *expr.RootExpr, services *service.ServicesDa files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(jsonrpcServices)...) - files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - return append(files, httpcodegen.ClientCLIFiles(jsonrpcServices)...) + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + return append(files, jsonPlan.ClientCLIFiles()...) } // kitchenSinkExampleFiles assembles example service and transport files // through their public subsystem APIs. -func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan) []*goacodegen.File { +func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan, grpcPlan *grpccodegen.PreparedPlan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { services := plan.Services() files := service.ExampleServiceFiles(plan) files = append(files, service.ExampleInterceptorsFiles(plan)...) @@ -116,17 +126,14 @@ func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan) []*goacode files = append(files, example.CLIFiles(root)...) if len(root.API.HTTP.Services) > 0 { - httpServices := httpcodegen.NewServicesData(services, root.API.HTTP) - files = append(files, httpcodegen.ExampleServerFiles(httpServices)...) - files = append(files, httpcodegen.ExampleCLIFiles(httpServices)...) + files = append(files, httpPlan.ExampleCLIFiles()...) } if len(root.API.JSONRPC.Services) > 0 { - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ExampleServerFiles(jsonrpcServices, files)...) - files = append(files, httpcodegen.ExampleCLIFiles(jsonrpcServices)...) + files = append(files, jsonPlan.ExampleServerFiles()...) + files = append(files, jsonPlan.ExampleCLIFiles()...) } if len(root.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services) + grpcServices := grpccodegen.NewServicesData(services, grpcPlan) files = append(files, grpccodegen.ExampleServerFiles(grpcServices)...) files = append(files, grpccodegen.ExampleCLIFiles(grpcServices)...) } diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go index 1ec6f21256..c986a6c6e1 100644 --- a/jsonrpc/codegen/plan.go +++ b/jsonrpc/codegen/plan.go @@ -1,22 +1,380 @@ -// This file declares the fixed import qualifiers used by JSON-RPC-generated -// files before service package aliases are frozen for the generation. +// This file prepares JSON-RPC files in two calls. NewPlans receives every +// design and requests all Go names. After Goa assigns those names and builds +// the service and HTTP values, Link creates the generated files. package codegen import ( + "fmt" "path" + "sort" "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// Plan reserves every literal import qualifier used by JSON-RPC render -// templates, including WebSocket and server-sent event support. -func Plan(generation *codegen.Generation) error { - if err := httpcodegen.Plan(generation); err != nil { - return err +type ( + // PlanInput supplies one design and the prepared values for its generated + // service, JSON requests, and JSON responses. + PlanInput struct { + // Root is the design that declares the JSON-RPC services. + Root *expr.RootExpr + // Service provides the generated service types and method definitions. + Service *service.Plan + // HTTP provides the JSON request and response types used inside JSON-RPC messages. + HTTP *httpcodegen.Plan + // ApplicationHTTP is the ordinary HTTP plan whose runnable server is + // combined with JSON-RPC. It is nil when Root has no ordinary HTTP services. + ApplicationHTTP *httpcodegen.Plan } + + // Plan stores the JSON-RPC function names chosen by NewPlans and the files + // created by Link. Goa creates one Plan for each design. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + http *httpcodegen.Plan + applicationHTTP *httpcodegen.Plan + services []*servicePlan + server []*codegen.File + client []*codegen.File + example []*codegen.File + exampleCLI []*codegen.File + linked bool + } + + // servicePlan stores one service's generated package path, function names, and + // HTTP request and response data. It also records whether methods return one + // response, server-sent events, or WebSocket messages. + servicePlan struct { + data httpcodegen.JSONRPCServiceSnapshot + name string + pathName string + endpoints []*endpointPlan + helpers map[string]*viewedHelperDeclarations + endpointNames map[string]*jsonRPCEndpointNames + clientNames jsonRPCClientNames + serverNames jsonRPCServerNames + bodyDecoder *codegen.NameDeclaration + hasHTTP bool + hasSSE bool + hasWebSocket bool + } + + // endpointPlan contains the HTTP request, HTTP response, and JSON-RPC result + // values for one service method. + endpointPlan struct { + httpcodegen.JSONRPCEndpointSnapshot + viewed *viewedRepresentation + websocketPending *codegen.NameDeclaration + websocketResult *codegen.NameDeclaration + websocketWrapper *codegen.NameDeclaration + } + + // jsonRPCClientNames stores the Go names written once for one client. + jsonRPCClientNames struct { + bufferPool *codegen.NameDeclaration + websocketConnection *codegen.NameDeclaration + websocketRequestOwner *codegen.NameDeclaration + websocketPendingRequest *codegen.NameDeclaration + websocketMessage *codegen.NameDeclaration + websocketClosedError *codegen.NameDeclaration + newWebsocketConnection *codegen.NameDeclaration + streamErrorType *codegen.NameDeclaration + streamErrorConnection *codegen.NameDeclaration + streamErrorProtocol *codegen.NameDeclaration + streamErrorParsing *codegen.NameDeclaration + streamErrorOrphaned *codegen.NameDeclaration + streamErrorTimeout *codegen.NameDeclaration + streamErrorHandler *codegen.NameDeclaration + } + + // jsonRPCServerNames stores the Go names written once for one server. + jsonRPCServerNames struct { + batchWriter *codegen.NameDeclaration + encodeError *codegen.NameDeclaration + sseStream *codegen.NameDeclaration + sseBuffer *codegen.NameDeclaration + websocketStream *codegen.NameDeclaration + } + + // jsonRPCEndpointNames stores the extra Go names written for one WebSocket + // method. + jsonRPCEndpointNames struct { + websocketPending *codegen.NameDeclaration + websocketResult *codegen.NameDeclaration + websocketWrapper *codegen.NameDeclaration + } + + // viewedRepresentation lists the JSON body type and constructor used for + // each view that a method may return. + viewedRepresentation struct { + variable bool + fixedView string + branches []viewBranch + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + writeMetadata *codegen.NameDeclaration + viewedResult httpcodegen.JSONRPCViewedResultData + servicePkg string + resultRef string + } + + // viewBranch stores the mapped service field, JSON body types, and client + // constructor for one view. + viewBranch struct { + view string + resultAttr string + serverBody *httpcodegen.JSONRPCBodyData + clientBody *httpcodegen.JSONRPCBodyData + resultInit httpcodegen.InitData + headers []httpcodegen.JSONRPCHeaderData + cookies []httpcodegen.JSONRPCCookieData + } + + // viewedHelperDeclarations stores the client decoder and server encoder names + // written for one method result. + viewedHelperDeclarations struct { + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + writeMetadata *codegen.NameDeclaration + } + + // jsonRPCNameOrder gives the same Go names the same order on every run. + jsonRPCNameOrder struct { + service string + method string + role uint8 + } +) + +const ( + viewedBodyDecoderRole uint8 = iota + 1 + viewedResultDecoderRole + viewedResultEncoderRole + viewedStreamEncoderRole + viewedMetadataWriterRole + jsonRPCBufferPoolRole + jsonRPCBatchWriterRole + jsonRPCEncodeErrorRole + jsonRPCSSEStreamRole + jsonRPCSSEBufferRole + jsonRPCWebSocketConnectionRole + jsonRPCWebSocketRequestOwnerRole + jsonRPCWebSocketPendingRequestRole + jsonRPCWebSocketMessageRole + jsonRPCWebSocketClosedErrorRole + jsonRPCNewWebSocketConnectionRole + jsonRPCStreamErrorTypeRole + jsonRPCStreamErrorConnectionRole + jsonRPCStreamErrorProtocolRole + jsonRPCStreamErrorParsingRole + jsonRPCStreamErrorOrphanedRole + jsonRPCStreamErrorTimeoutRole + jsonRPCStreamErrorHandlerRole + jsonRPCWebSocketServerStreamRole + jsonRPCWebSocketMethodPendingRole + jsonRPCWebSocketMethodResultRole + jsonRPCWebSocketServerWrapperRole +) + +// NewPlans checks that inputs contain every design with JSON-RPC services once, +// then creates one Plan for each input. It requests every helper name before +// Goa chooses unique Go names, so generated definitions and calls agree. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("JSON-RPC plans require a generation") + } + if generation.Frozen() { + return nil, fmt.Errorf("JSON-RPC plans must be collected before generation freeze") + } + if err := validatePlanInputs(generation, inputs); err != nil { + return nil, err + } + if err := example.Plan(generation); err != nil { + return nil, err + } + if err := planImports(generation, inputs); err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan := &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + http: input.HTTP, + applicationHTTP: input.ApplicationHTTP, + } + for _, transport := range input.Root.API.JSONRPC.Services { + planned, err := collectServicePlan(generation, transport) + if err != nil { + return nil, err + } + plan.services = append(plan.services, planned) + } + sort.Slice(plan.services, func(i, j int) bool { + return plan.services[i].name < plan.services[j].name + }) + plans[index] = plan + } + return plans, nil +} + +// Root returns the design used to create p. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Link reads the completed service and HTTP plans and builds every JSON-RPC +// file. The caller must first ask Goa to choose unique Go names and then link +// both input plans so all JSON body types and constructors are available. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("JSON-RPC plan cannot link before generation freeze") + } + if p.linked { + return fmt.Errorf("JSON-RPC plan is already linked") + } + for _, planned := range p.services { + data, ok := p.http.JSONRPCService(planned.name) + if !ok { + return fmt.Errorf("HTTP plan has no data for JSON-RPC service %q", planned.name) + } + planned.data = data + planned.pathName = data.Service.PathName + for _, endpoint := range data.Endpoints { + helper := planned.helpers[endpoint.Method.Name] + names := planned.endpointNames[endpoint.Method.Name] + viewed, hasViewedResult := p.http.ViewedResult(planned.name, endpoint.Method.Name) + plannedEndpoint := &endpointPlan{ + JSONRPCEndpointSnapshot: endpoint, + viewed: planViewedRepresentation(&endpoint, viewed, hasViewedResult, helper), + } + if names != nil { + plannedEndpoint.websocketPending = names.websocketPending + plannedEndpoint.websocketResult = names.websocketResult + plannedEndpoint.websocketWrapper = names.websocketWrapper + } + planned.endpoints = append(planned.endpoints, plannedEndpoint) + switch { + case endpoint.SSE != nil: + planned.hasSSE = true + case isJSONRPCWebSocketEndpoint(endpoint): + planned.hasWebSocket = true + default: + planned.hasHTTP = true + } + } + } + p.server = serverFiles(p.services) + p.client = clientFiles(p.services) + p.example = p.http.CombinedExampleServerFiles(p.applicationHTTP) + p.exampleCLI = p.http.ExampleCLIFiles() + p.linked = true + return nil +} + +// ServerFiles returns the JSON-RPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the JSON-RPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server JSON body files supplied by the HTTP plan. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ServerTypeFiles() +} + +// ClientTypeFiles returns the client JSON body files supplied by the HTTP plan. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientTypeFiles() +} + +// PathFiles returns the URL path helper files supplied by the HTTP plan. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.http.PathFiles() +} + +// ClientCLIFiles returns the command-line client files supplied by the HTTP plan. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientCLIFiles() +} + +// ExampleServerFiles returns the runnable servers built by Link. Each file +// mounts both ordinary HTTP and JSON-RPC services declared on that server. +func (p *Plan) ExampleServerFiles() []*codegen.File { + p.requireLinked() + return p.example +} + +// ExampleCLIFiles returns runnable command-line clients for p's JSON-RPC services. +func (p *Plan) ExampleCLIFiles() []*codegen.File { + p.requireLinked() + return p.exampleCLI +} + +// planViewedRepresentation copies each allowed result view and its JSON body +// type into the values used to write JSON-RPC files. A method fixed to one +// view stores one branch. A method that chooses a view for each result stores +// every branch and includes the selected view name in each response. +func planViewedRepresentation(endpoint *httpcodegen.JSONRPCEndpointSnapshot, viewed httpcodegen.ViewedResultSnapshot, hasViewedResult bool, helpers *viewedHelperDeclarations) *viewedRepresentation { + if !hasViewedResult { + return nil + } + if helpers == nil { + panic(fmt.Sprintf("JSON-RPC viewed endpoint %q has no helper names declared by NewPlans", endpoint.Method.Name)) + } + representation := &viewedRepresentation{ + variable: viewed.Variable, + fixedView: viewed.FixedView, + decode: helpers.decode, + encode: helpers.encode, + streamEncode: helpers.streamEncode, + writeMetadata: helpers.writeMetadata, + viewedResult: viewed.Service, + servicePkg: endpoint.ServicePkgName, + resultRef: endpoint.Result.Ref, + } + for _, branch := range viewed.Representations { + representation.branches = append(representation.branches, viewBranch{ + view: branch.View, + resultAttr: branch.ResultAttr, + serverBody: branch.ServerBody, + clientBody: branch.ClientBody, + resultInit: branch.ResultInit, + headers: branch.Headers, + cookies: branch.Cookies, + }) + } + return representation +} + +// requireLinked stops callers from reading files before Link has built them. +func (p *Plan) requireLinked() { + if !p.linked { + panic("JSON-RPC files requested before Plan.Link") + } +} + +// planImports records every import name written directly into JSON-RPC files. +func planImports(generation *codegen.Generation, inputs []PlanInput) error { imports := []*codegen.ImportSpec{ codegen.SimpleImport("bufio"), codegen.SimpleImport("bytes"), @@ -43,11 +401,8 @@ func Plan(generation *codegen.Generation) error { return err } } - for _, root := range generation.Roots() { - design, ok := root.(*expr.RootExpr) - if !ok { - continue - } + for _, input := range inputs { + design := input.Root for _, service := range design.API.JSONRPC.Services { pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) packageName := strings.ToLower(codegen.Goify(service.Name(), false)) @@ -69,3 +424,284 @@ func Plan(generation *codegen.Generation) error { } return nil } + +// validatePlanInputs checks every root and plan before NewPlans submits an +// import or generated helper name. This keeps a rejected input from changing +// names chosen for later generators in the same run. +func validatePlanInputs(generation *codegen.Generation, inputs []PlanInput) error { + roots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(root.API.JSONRPC.Services) > 0 { + roots[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil || !generation.HasRoot(input.Root) { + return fmt.Errorf("JSON-RPC plan requires a root in this generation") + } + if _, ok := roots[input.Root]; !ok { + return fmt.Errorf("root does not declare JSON-RPC services") + } + if _, ok := seen[input.Root]; ok { + return fmt.Errorf("JSON-RPC root is planned more than once: %s", rootServiceName(input.Root)) + } + seen[input.Root] = struct{}{} + if input.Service == nil { + return fmt.Errorf("JSON-RPC root %s requires a service plan", rootServiceName(input.Root)) + } + if input.Service.Root() != input.Root { + return fmt.Errorf("JSON-RPC service plan does not belong to root %s", rootServiceName(input.Root)) + } + if input.HTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires an HTTP plan for its JSON request and response types", rootServiceName(input.Root)) + } + if !input.HTTP.MatchesJSONRPC(input.Root, input.Service) { + return fmt.Errorf("JSON-RPC HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + hasHTTP := len(input.Root.API.HTTP.Services) > 0 + if hasHTTP && input.ApplicationHTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires its application HTTP plan", rootServiceName(input.Root)) + } + if !hasHTTP && input.ApplicationHTTP != nil { + return fmt.Errorf("JSON-RPC root %s has no ordinary HTTP services", rootServiceName(input.Root)) + } + if input.ApplicationHTTP != nil && !input.ApplicationHTTP.MatchesHTTP(input.Root, input.Service) { + return fmt.Errorf("application HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + } + if len(inputs) != len(roots) { + return fmt.Errorf("JSON-RPC planning requires all %d JSON-RPC roots, got %d", len(roots), len(inputs)) + } + return nil +} + +// rootServiceName returns the name shown in errors after validation has proved +// that root declares a JSON-RPC service. +func rootServiceName(root *expr.RootExpr) string { + return root.Services[0].Name +} + +// isJSONRPCSSEEndpoint reports whether the supplied method writes server-sent +// events. +func isJSONRPCSSEEndpoint(data any) bool { + return jsonRPCEndpoint(data).SSE != nil +} + +// isJSONRPCWebSocketEndpoint reports whether the supplied method sends or +// receives JSON-RPC messages through a WebSocket. +func isJSONRPCWebSocketEndpoint(data any) bool { + endpoint := jsonRPCEndpoint(data) + return endpoint.ClientWebSocket != nil || endpoint.ServerWebSocket != nil +} + +// jsonRPCEndpoint returns the method values used to write a generated file. +func jsonRPCEndpoint(data any) *httpcodegen.JSONRPCEndpointSnapshot { + switch endpoint := data.(type) { + case httpcodegen.JSONRPCEndpointSnapshot: + return &endpoint + case *httpcodegen.JSONRPCEndpointSnapshot: + return endpoint + case *endpointPlan: + return &endpoint.JSONRPCEndpointSnapshot + default: + panic(fmt.Sprintf("JSON-RPC template received endpoint data of type %T", data)) + } +} + +// collectServicePlan stores the designed service name and generated package +// path, then requests client decoder and server encoder names for every method +// that returns a result view. Link later adds the HTTP endpoint data used to +// build that service's files. +func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServiceExpr) (*servicePlan, error) { + pathName := codegen.SnakeCase(codegen.Goify(transport.Name(), false)) + clientPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "server") + client, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + server, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + planned := &servicePlan{ + name: transport.Name(), + pathName: pathName, + helpers: make(map[string]*viewedHelperDeclarations), + endpointNames: make(map[string]*jsonRPCEndpointNames), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, method string, role uint8) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, visibility, jsonRPCNameOrder{ + service: planned.name, + method: method, + role: role, + }) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + hasHTTP, hasSSE, hasWebSocket := false, false, false + for _, endpoint := range transport.HTTPEndpoints { + switch { + case endpoint.UsesSSE(): + hasSSE = true + case endpoint.UsesWebSocket(): + hasWebSocket = true + default: + hasHTTP = true + } + } + if !hasWebSocket { + planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", codegen.UnexportedName, "", jsonRPCBufferPoolRole) + if err != nil { + return nil, err + } + planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", codegen.UnexportedName, "", jsonRPCEncodeErrorRole) + if err != nil { + return nil, err + } + } + if hasHTTP { + planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", codegen.UnexportedName, "", jsonRPCBatchWriterRole) + if err != nil { + return nil, err + } + } + if hasSSE { + planned.serverNames.sseStream, err = declare(server, codegen.NameType, "sseServerStream", codegen.UnexportedName, "", jsonRPCSSEStreamRole) + if err != nil { + return nil, err + } + planned.serverNames.sseBuffer, err = declare(server, codegen.NameType, "sseEventBuffer", codegen.UnexportedName, "", jsonRPCSSEBufferRole) + if err != nil { + return nil, err + } + } + if hasWebSocket { + clientDeclarations := []struct { + target **codegen.NameDeclaration + kind codegen.PackageNameKind + preferred string + visibility codegen.PackageNameVisibility + role uint8 + }{ + {&planned.clientNames.websocketConnection, codegen.NameType, "websocketClientConn", codegen.UnexportedName, jsonRPCWebSocketConnectionRole}, + {&planned.clientNames.websocketRequestOwner, codegen.NameType, "websocketRequestOwner", codegen.UnexportedName, jsonRPCWebSocketRequestOwnerRole}, + {&planned.clientNames.websocketPendingRequest, codegen.NameType, "websocketPendingRequest", codegen.UnexportedName, jsonRPCWebSocketPendingRequestRole}, + {&planned.clientNames.websocketMessage, codegen.NameType, "websocketMessage", codegen.UnexportedName, jsonRPCWebSocketMessageRole}, + {&planned.clientNames.websocketClosedError, codegen.NameVariable, "errWebsocketMethodStreamClosed", codegen.UnexportedName, jsonRPCWebSocketClosedErrorRole}, + {&planned.clientNames.newWebsocketConnection, codegen.NameFunction, "newWebsocketClientConn", codegen.UnexportedName, jsonRPCNewWebSocketConnectionRole}, + {&planned.clientNames.streamErrorType, codegen.NameType, "StreamErrorType", codegen.ExportedName, jsonRPCStreamErrorTypeRole}, + {&planned.clientNames.streamErrorConnection, codegen.NameConstant, "StreamErrorConnection", codegen.ExportedName, jsonRPCStreamErrorConnectionRole}, + {&planned.clientNames.streamErrorProtocol, codegen.NameConstant, "StreamErrorProtocol", codegen.ExportedName, jsonRPCStreamErrorProtocolRole}, + {&planned.clientNames.streamErrorParsing, codegen.NameConstant, "StreamErrorParsing", codegen.ExportedName, jsonRPCStreamErrorParsingRole}, + {&planned.clientNames.streamErrorOrphaned, codegen.NameConstant, "StreamErrorOrphaned", codegen.ExportedName, jsonRPCStreamErrorOrphanedRole}, + {&planned.clientNames.streamErrorTimeout, codegen.NameConstant, "StreamErrorTimeout", codegen.ExportedName, jsonRPCStreamErrorTimeoutRole}, + {&planned.clientNames.streamErrorHandler, codegen.NameType, "StreamErrorHandler", codegen.ExportedName, jsonRPCStreamErrorHandlerRole}, + } + for _, item := range clientDeclarations { + *item.target, err = declare(client, item.kind, item.preferred, item.visibility, "", item.role) + if err != nil { + return nil, err + } + } + preferredStream := codegen.Goify(transport.Name(), false) + "Stream" + planned.serverNames.websocketStream, err = declare(server, codegen.NameType, preferredStream, codegen.UnexportedName, "", jsonRPCWebSocketServerStreamRole) + if err != nil { + return nil, err + } + } + for _, endpoint := range transport.HTTPEndpoints { + method := endpoint.MethodExpr + if endpoint.UsesWebSocket() { + names := &jsonRPCEndpointNames{} + if method.StreamingResult != nil { + names.websocketPending, err = declare(client, codegen.NameType, codegen.Goify(method.Name, false)+"ClientStreamPendingRequest", codegen.UnexportedName, method.Name, jsonRPCWebSocketMethodPendingRole) + if err != nil { + return nil, err + } + names.websocketResult, err = declare(client, codegen.NameType, codegen.Goify(method.Name, false)+"ClientStreamStreamResult", codegen.UnexportedName, method.Name, jsonRPCWebSocketMethodResultRole) + if err != nil { + return nil, err + } + } + if method.Stream == expr.ServerStreamKind || method.Stream == expr.BidirectionalStreamKind { + names.websocketWrapper, err = declare(server, codegen.NameType, codegen.Goify(method.Name, false)+"StreamWrapper", codegen.UnexportedName, method.Name, jsonRPCWebSocketServerWrapperRole) + if err != nil { + return nil, err + } + } + planned.endpointNames[method.Name] = names + } + if _, ok := method.Result.Type.(*expr.ResultTypeExpr); !ok { + continue + } + if planned.bodyDecoder == nil { + planned.bodyDecoder = codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: planned.name, role: viewedBodyDecoderRole}, + ) + if err := client.DeclareName(planned.bodyDecoder); err != nil { + return nil, err + } + } + methodName := codegen.Goify(method.Name, true) + helpers := &viewedHelperDeclarations{ + decode: codegen.NewPreferredName( + codegen.NameFunction, + "decode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedResultDecoderRole}, + ), + encode: codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedResultEncoderRole}, + ), + streamEncode: codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"Result", + codegen.UnexportedName, + jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedStreamEncoderRole}, + ), + writeMetadata: codegen.NewPreferredName( + codegen.NameFunction, + "write"+methodName+"ViewedResponseMetadata", + codegen.UnexportedName, + jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedMetadataWriterRole}, + ), + } + if err := client.DeclareName(helpers.decode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.encode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.streamEncode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.writeMetadata); err != nil { + return nil, err + } + planned.helpers[method.Name] = helpers + } + return planned, nil +} + +// ComparePackageName orders Go declarations by service, method, and use. +func (o jsonRPCNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(jsonRPCNameOrder) + if compared := strings.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := strings.Compare(o.method, right.method); compared != 0 { + return compared + } + return int(o.role) - int(right.role) +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go index 3c7a27e4a4..ed477d2981 100644 --- a/jsonrpc/codegen/plan_test.go +++ b/jsonrpc/codegen/plan_test.go @@ -4,6 +4,7 @@ package codegen import ( "path" + "strings" "testing" "github.com/stretchr/testify/require" @@ -13,6 +14,7 @@ import ( "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" ) func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { @@ -27,7 +29,10 @@ func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() @@ -49,7 +54,10 @@ func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() @@ -64,3 +72,570 @@ func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { require.NotEqual(t, services.ServiceImport("Foojssvr").Name, server.Name) require.Equal(t, "cli", cli.Name) } + +// TestNewPlansRequiresEveryJSONRPCRoot verifies that planning cannot reserve +// names from only part of a generation. The caller must supply each root that +// declares a JSON-RPC service exactly once. +func TestNewPlansRequiresEveryJSONRPCRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.EqualError(t, err, "JSON-RPC planning requires all 2 JSON-RPC roots, got 1") + assertViewedHelperNameAvailable(t, generation, "first") +} + +// TestNewPlansRejectsDuplicateRoot verifies that two inputs cannot plan the +// same JSON-RPC root. The rejected call must not consume a helper name that a +// later generator can use. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + input := PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + } + + _, err := NewPlans(generation, input, input) + require.EqualError(t, err, "JSON-RPC root is planned more than once: First") + assertViewedHelperNameAvailable(t, generation, "first") +} + +// TestNewPlansRejectsRootWithoutJSONRPC verifies that inputs contain only +// roots that declare JSON-RPC services. Ordinary HTTP roots are planned by the +// HTTP generator and must not influence JSON-RPC names. +func TestNewPlansRejectsRootWithoutJSONRPC(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[0]}, + PlanInput{Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + PlanInput{Root: roots[2], Service: services[2], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[2]}, + ) + require.EqualError(t, err, "root does not declare JSON-RPC services") + assertViewedHelperNameAvailable(t, generation, "first") +} + +// TestNewPlansRejectsMismatchedInputPlans verifies that every service and HTTP +// plan belongs to the root in the same input. Validation finishes before any +// JSON-RPC helper name is submitted. +func TestNewPlansRejectsMismatchedInputPlans(t *testing.T) { + tests := []struct { + name string + change func([]*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) []PlanInput + error string + }{ + { + name: "service", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC service plan does not belong to root First", + }, + { + name: "JSON-RPC HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "ordinary HTTP plan in JSON-RPC role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: applicationPlans[0], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "application HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + { + name: "JSON-RPC plan in application HTTP role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: jsonPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + _, err := NewPlans(generation, test.change(roots, services, jsonPlans, applicationPlans)...) + require.EqualError(t, err, test.error) + assertViewedHelperNameAvailable(t, generation, "first") + }) + } +} + +// TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService verifies that a +// service with ordinary HTTP and JSON-RPC methods writes the viewed-result +// encoder called by its generated JSON-RPC server. +func TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCWithHTTPServiceDSL) + plan := CreateJSONRPCPlan(root) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + require.NotNil(t, plan.services[0].endpoints[0].viewed) + helper := plan.services[0].helpers["JSONRPC"].encode.Name() + + var source strings.Builder + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + require.Contains(t, source.String(), "func "+helper+"(") +} + +// TestPlanRequiresLinkBeforeRender verifies callers cannot read files before +// Link finishes or ask Link to build the same files twice. +func TestPlanRequiresLinkBeforeRender(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].ServerFiles() + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "JSON-RPC plan is already linked") +} + +// TestPlanBuildsCombinedExampleWithoutChangingHTTP verifies Link creates a new +// runnable server with ordinary HTTP and JSON-RPC services and leaves the HTTP +// plan's file unchanged. +func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("mixed", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/read") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, applicationPlans[0].Link()) + require.NoError(t, httpPlans[0].Link()) + + httpFile := applicationPlans[0].ExampleServerFiles()[0] + httpImports := append([]*codegen.ImportSpec(nil), httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + require.NoError(t, plans[0].Link()) + + require.Equal(t, httpImports, httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range httpFile.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Empty(t, section.Data.(map[string]any)["JSONRPCServices"]) + } + } + combined := plans[0].ExampleServerFiles()[0] + require.NotSame(t, httpFile, combined) + for _, section := range combined.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Len(t, section.Data.(map[string]any)["JSONRPCServices"], 1) + } + } +} + +// TestPlanUsesHTTPViewedRepresentationBranches verifies each method uses the +// body types and constructors that the HTTP plan prepared for its result views. +func TestPlanUsesHTTPViewedRepresentationBranches(t *testing.T) { + _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + require.Len(t, plan.services, 1) + + endpoints := make(map[string]*endpointPlan) + for _, endpoint := range plan.services[0].endpoints { + endpoints[endpoint.Method.Name] = endpoint + } + variable := endpoints["fetch"].viewed + require.True(t, variable.variable) + httpViewed, ok := shared.ViewedResult("retained", "fetch") + require.True(t, ok) + require.Len(t, variable.branches, len(httpViewed.Representations)) + for index, branch := range variable.branches { + require.Equal(t, httpViewed.Representations[index].View, branch.view) + } + for _, branch := range variable.branches { + require.NotNil(t, branch.serverBody) + require.NotNil(t, branch.clientBody) + require.NotEmpty(t, branch.resultInit.Name) + } + + fixed := endpoints["fixed"].viewed + require.False(t, fixed.variable) + require.Equal(t, "detailed", fixed.fixedView) + require.Len(t, fixed.branches, 1) + require.Equal(t, "detailed", fixed.branches[0].view) +} + +// TestPlanUsesEveryViewForMappedResultField verifies that a JSON-RPC response +// mapped to one result field still carries and checks every view that the +// service method may return. Each branch uses the mapped field's JSON body and +// result constructor supplied by the HTTP plan. +func TestPlanUsesEveryViewForMappedResultField(t *testing.T) { + _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCMappedFieldPlanDSL) + httpViewed, ok := shared.ViewedResult("mapped", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 2) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.True(t, viewed.variable) + require.Empty(t, viewed.fixedView) + require.Len(t, viewed.branches, 2) + for index, name := range []string{"summary", "default"} { + require.Equal(t, name, viewed.branches[index].view) + require.Equal(t, representations[index].ServerBody, viewed.branches[index].serverBody) + require.Equal(t, representations[index].ClientBody, viewed.branches[index].clientBody) + require.Equal(t, representations[index].ResultInit, viewed.branches[index].resultInit) + } + require.Equal(t, viewed.branches[0].serverBody, viewed.branches[1].serverBody) + require.Equal(t, viewed.branches[0].clientBody, viewed.branches[1].clientBody) + require.Equal(t, viewed.branches[0].resultInit, viewed.branches[1].resultInit) +} + +// TestPlanTreatsSoleResultViewAsFixed verifies that a result type with one view +// produces a response body without a per-response view field. The service plan +// supplies that one view name, and JSON-RPC copies it without deriving a value +// from the first response branch. +func TestPlanTreatsSoleResultViewAsFixed(t *testing.T) { + _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCSoleViewPlanDSL) + httpViewed, ok := shared.ViewedResult("sole", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 1) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.False(t, viewed.variable) + require.Equal(t, "default", viewed.fixedView) + require.Len(t, viewed.branches, 1) + require.Equal(t, "default", viewed.branches[0].view) +} + +// TestPlanUsesAssignedViewedHelperNames verifies that result-view helpers use +// the unique names assigned when two method spellings produce the same Go name +// or another generated function already uses the requested name. +func TestPlanUsesAssignedViewedHelperNames(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCCollisionPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/server") + require.NoError(t, err) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultDecoderRole}, + ))) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{}, + ))) + require.NoError(t, server.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "encodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultEncoderRole}, + ))) + + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + + helpers := plans[0].services[0].helpers + require.Equal(t, "decodeJSONRPCResult2", plans[0].services[0].bodyDecoder.Name()) + require.Equal(t, "decodeFetchItemViewedResult2", helpers["fetch-item"].decode.Name()) + require.Equal(t, "encodeFetchItemViewedResult2", helpers["fetch-item"].encode.Name()) + require.NotEqual(t, helpers["fetch-item"].decode.Name(), helpers["fetch_item"].decode.Name()) + require.NotEqual(t, helpers["fetch-item"].encode.Name(), helpers["fetch_item"].encode.Name()) +} + +// linkedJSONRPCPlan evaluates one design, assigns all generated Go names, and +// links the service, HTTP, and JSON-RPC plans used by a test. It returns the +// completed plans and service data so each test can inspect generated facts. +func linkedJSONRPCPlan(t *testing.T, design func()) (*expr.RootExpr, *service.Plan, *httpcodegen.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, design) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + return root, servicePlan, httpPlans[0], plans[0] +} + +// jsonRPCPlanningInputs builds two roots and their matching service, ordinary +// HTTP, and JSON-RPC HTTP plans. Tests change one input before calling NewPlans +// to prove the constructor rejects an incomplete or mismatched set. +func jsonRPCPlanningInputs(t *testing.T) (*codegen.Generation, []*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) { + t.Helper() + roots := []*expr.RootExpr{ + expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")), + expr.RunDSL(t, jsonRPCPlanningRootDSL("Second", "/second")), + expr.RunDSL(t, ordinaryHTTPPlanningRootDSL), + } + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1], roots[2]}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: roots[0], Examples: expr.NewExampleGenerator(roots[0].API.RandomizerFactory)}, + service.PlanInput{Root: roots[1], Examples: expr.NewExampleGenerator(roots[1].API.RandomizerFactory)}, + service.PlanInput{Root: roots[2], Examples: expr.NewExampleGenerator(roots[2].API.RandomizerFactory)}, + ) + require.NoError(t, err) + httpInputs := []httpcodegen.PlanInput{ + {Root: roots[0], Service: servicePlans[0]}, + {Root: roots[1], Service: servicePlans[1]}, + {Root: roots[2], Service: servicePlans[2]}, + } + applicationPlans, err := httpcodegen.NewPlans(generation, httpInputs...) + require.NoError(t, err) + jsonPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpInputs[:2]...) + require.NoError(t, err) + return generation, roots, servicePlans, jsonPlans, applicationPlans +} + +// assertViewedHelperNameAvailable submits the helper name that a rejected plan +// would have used and verifies no earlier JSON-RPC input consumed it. +func assertViewedHelperNameAvailable(t *testing.T, generation *codegen.Generation, serviceName string) { + t.Helper() + client, err := generation.ClaimPackage(path.Join("generated.local/gen/jsonrpc", serviceName, "client")) + require.NoError(t, err) + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "decodeReadViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: "zzzz", method: "read", role: viewedResultDecoderRole}, + ) + require.NoError(t, client.DeclareName(declaration)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "decodeReadViewedResult", declaration.Name()) +} + +// jsonRPCPlanningRootDSL defines one viewed method exposed through both +// ordinary HTTP and JSON-RPC so tests can also validate ApplicationHTTP. +func jsonRPCPlanningRootDSL(name, route string) func() { + return func() { + result := dsl.ResultType("application/vnd."+strings.ToLower(name), func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service(name, func() { + dsl.Method("read", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET(route) + }) + dsl.JSONRPC(func() {}) + }) + }) + } +} + +// ordinaryHTTPPlanningRootDSL defines a root that must be excluded from +// JSON-RPC inputs even though it participates in the same generation. +func ordinaryHTTPPlanningRootDSL() { + dsl.Service("Ordinary", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + }) +} + +// viewedJSONRPCWithHTTPServiceDSL defines one service whose ordinary HTTP and +// JSON-RPC methods return the same one-view result type. +func viewedJSONRPCWithHTTPServiceDSL() { + result := dsl.ResultType("application/vnd.viewed-jsonrpc-http-service", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCPlanDSL defines one variable and one fixed viewed result. +func viewedJSONRPCPlanDSL() { + result := dsl.ResultType("application/vnd.retained-view", func() { + dsl.TypeName("RetainedView") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id", "detail") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("retained", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(result, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCCollisionPlanDSL defines two viewed methods whose authored +// names normalize to the same preferred Go helper spelling. +func viewedJSONRPCCollisionPlanDSL() { + result := dsl.ResultType("application/vnd.retained-collision", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("collisions", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + for _, name := range []string{"fetch-item", "fetch_item"} { + dsl.Method(name, func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + } + }) +} + +// viewedJSONRPCMappedFieldPlanDSL defines a result whose JSON-RPC response body +// contains only the id field while the selected view still accompanies the +// response and must be either the generated default view or the summary view. +func viewedJSONRPCMappedFieldPlanDSL() { + result := dsl.ResultType("application/vnd.mapped-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("mapped", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) +} + +// viewedJSONRPCSoleViewPlanDSL defines a result whose only legal view is the +// default view and does not repeat that choice on the method. +func viewedJSONRPCSoleViewPlanDSL() { + result := dsl.ResultType("application/vnd.sole-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("sole", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index 65ef9e7472..86c3b1e656 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -1,5 +1,5 @@ -// This file renders JSON-RPC server handlers and codecs per service and keeps -// generated-type imports local to each returned file. +// This file writes JSON-RPC server handlers, request decoders, and response +// encoders for each service. Each file imports only the types it uses. package codegen import ( @@ -8,32 +8,45 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ServerFiles returns the generated JSON-RPC server files if any. -func ServerFiles(data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, addEndpointImports(serverFile(svc, data), data, svc.HTTPEndpoints...)) - // Generate either WebSocket or SSE file based on transport type - if hasJSONRPCSSE(svc) { - if f := sseServerFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, jsonRPCSSEEndpoints(svc)...)) +type ( + // serverTemplateData stores the service values and extra Go names used to + // write one server package. + serverTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BatchWriter is the type that joins responses for one batch request. + BatchWriter *codegen.NameDeclaration + // EncodeError is the function that writes a JSON-RPC error response. + EncodeError *codegen.NameDeclaration + // SSEStream is the stream shared by all server-sent-event methods. + SSEStream *codegen.NameDeclaration + } +) + +// serverFiles builds server, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func serverFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + files = append(files, addFileImports(serverFile(planned), planned.data)) + // A service uses either a WebSocket file or an SSE file for streaming. + if planned.hasSSE { + if f := sseServerFile(planned); f != nil { + files = append(files, addFileImports(f, planned.data)) } - } else if f := websocketServerFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, jsonRPCWebSocketEndpoints(svc)...)) + } else if f := websocketServerFile(planned); f != nil { + files = append(files, addFileImports(f, planned.data)) } } - for _, svc := range jsvcs { - f := httpcodegen.ServerEncodeDecodeFile(svc, data) + for _, planned := range services { + f := planned.data.ServerCodecFile() if f == nil { continue } for _, s := range f.SectionTemplates { - // Add the JSON-RPC imports. + // These imports are used by the JSON-RPC error and body converters below. if s.Name == "source-header" { codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "io"}) @@ -41,22 +54,35 @@ func ServerFiles(data *httpcodegen.ServicesData) []*codegen.File { } s.Name = "jsonrpc-" + s.Name } - files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) + files = append(files, addFileImports(f, planned.data)) } return files } // serverFile returns the file implementing the JSON-RPC server. -func serverFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +func serverFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &serverTemplateData{ + JSONRPCServiceSnapshot: data, + BatchWriter: planned.serverNames.batchWriter, + EncodeError: planned.serverNames.encodeError, + SSEStream: planned.serverNames.sseStream, + } svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "server.go") - title := fmt.Sprintf("%s JSON-RPC server", svc.Name()) + title := fmt.Sprintf("%s JSON-RPC server", planned.name) funcs := map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, - "lowerInitial": lowerInitial, - "hasMixedTransports": func() bool { return hasMixedJSONRPCTransports(svc) }, + "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "lowerInitial": lowerInitial, + "encodeErrorName": planned.encodeErrorName, + "sseStreamName": planned.sseStreamName, + "websocketServerStreamName": planned.websocketServerStreamName, + "websocketWrapperName": planned.websocketWrapperName, + "hasMixedTransports": planned.hasMixedTransports, + } + for name, function := range viewedResultFuncs(planned) { + funcs[name] = function } imports := make([]*codegen.ImportSpec, 0, 15) imports = append(imports, @@ -73,70 +99,90 @@ func serverFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) * codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), ) + if serviceNeedsMetadataStrconv(planned) { + imports = append(imports, &codegen.ImportSpec{Path: "strconv"}) + } if serviceHasViewedResult(data) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, data.ViewImport()) } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), } sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: data, FuncMap: funcs}, - &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: httpcodegen.ReadTemplate(serverServiceT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: httpcodegen.ReadTemplate(serverMethodNamesT), Data: data}, + &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: renderData, FuncMap: funcs}, + &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: jsonrpcTemplates.Read(serverServiceT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: jsonrpcTemplates.Read(serverMethodNamesT), Data: renderData}, ) - // Use appropriate server handler based on transport + // Add the request handlers needed by this service. switch { - case hasMixedJSONRPCTransports(svc): - // For mixed transports, we need a unified handler with content negotiation - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: data}) - // Include the standard HTTP handlers that the mixed handler delegates to - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) - // Also include SSE handler for SSE-specific logic - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case hasJSONRPCSSE(svc): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case httpcodegen.HasWebSocket(data): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-websocket-server-handler", Source: jsonrpcTemplates.Read(websocketServerHandlerT), FuncMap: funcs, Data: data}) + case planned.hasHTTP && planned.hasSSE: + // ServeHTTP chooses an ordinary JSON-RPC response or server-sent events + // from the request's Accept header. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: renderData}) + // Add both handlers called by ServeHTTP. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) + case planned.hasSSE: + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) + case planned.hasWebSocket: + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-websocket-server-handler", Source: jsonrpcTemplates.Read(websocketServerHandlerT), FuncMap: funcs, Data: renderData}) default: - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) } - // Add transport flags to data + // Record which request handlers this service needs. mountData := struct { - *httpcodegen.ServiceData + httpcodegen.JSONRPCServiceSnapshot HasSSE bool HasMixed bool }{ - ServiceData: data, - HasSSE: hasJSONRPCSSE(svc), - HasMixed: hasMixedJSONRPCTransports(svc), + JSONRPCServiceSnapshot: data, + HasSSE: planned.hasSSE, + HasMixed: planned.hasHTTP && planned.hasSSE, } sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-mount", Source: jsonrpcTemplates.Read(serverMountT), Data: mountData}, ) - for _, e := range data.Endpoints { + for _, e := range planned.endpoints { sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: e}) + &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: &e.JSONRPCEndpointSnapshot}) } + sections = append(sections, serverViewedResultSections(planned)...) - if !httpcodegen.HasWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT)}) + if !planned.hasWebSocket { + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT), Data: renderData}) } return &codegen.File{Path: fpath, SectionTemplates: sections} } +// encodeErrorName returns the function that writes a JSON-RPC error response. +func (s *servicePlan) encodeErrorName() string { + return s.serverNames.encodeError.Name() +} + +// sseStreamName returns the shared server-sent-event stream type. +func (s *servicePlan) sseStreamName() string { + return s.serverNames.sseStream.Name() +} + +// hasMixedTransports reports whether the server accepts ordinary JSON-RPC +// requests and server-sent-event requests on the same HTTP path. +func (s *servicePlan) hasMixedTransports() bool { + return s.hasHTTP && s.hasSSE +} + // serviceHasViewedResult reports whether server.go emits endpoint conversion // code that references the service views package. -func serviceHasViewedResult(service *httpcodegen.ServiceData) bool { +func serviceHasViewedResult(service httpcodegen.JSONRPCServiceSnapshot) bool { for _, endpoint := range service.Endpoints { if endpoint.Method.ViewedResult != nil { return true @@ -149,28 +195,3 @@ func serviceHasViewedResult(service *httpcodegen.ServiceData) bool { func lowerInitial(s string) string { return strings.ToLower(s[:1]) + s[1:] } - -// hasJSONRPCSSE returns true if the service uses SSE for JSON-RPC streaming. -func hasJSONRPCSSE(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.MethodExpr.IsStreaming() && e.IsJSONRPC() && e.SSE != nil { - return true - } - } - return false -} - -// hasJSONRPCHTTP returns true if the service has non-streaming JSON-RPC endpoints. -func hasJSONRPCHTTP(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && !e.MethodExpr.IsStreaming() { - return true - } - } - return false -} - -// hasMixedJSONRPCTransports returns true if the service has both HTTP and SSE JSON-RPC endpoints. -func hasMixedJSONRPCTransports(svc *expr.HTTPServiceExpr) bool { - return hasJSONRPCHTTP(svc) && hasJSONRPCSSE(svc) -} diff --git a/jsonrpc/codegen/server_error_contract_test.go b/jsonrpc/codegen/server_error_contract_test.go new file mode 100644 index 0000000000..cf01d06a0c --- /dev/null +++ b/jsonrpc/codegen/server_error_contract_test.go @@ -0,0 +1,53 @@ +// This file verifies how generated JSON-RPC servers report request, service, +// and stream errors to callers. +package codegen + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/jsonrpc/codegen/testdata" +) + +// TestServerErrorResponses verifies request decoding writes JSON-RPC errors, +// service code can explicitly write a server-sent event error, service method +// failures return to the server, and unary failures still become responses. +func TestServerErrorResponses(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + + feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") + require.Contains(t, feedServer, "if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}") + require.Contains(t, feedServer, "if _, err := endpoint(ctx, v); err != nil {\n\t\t\treturn err") + require.NotContains(t, feedServer, "return strm.SendError") + + feedStream := renderPlannedFile(t, plan.ServerFiles(), "feed", "sse.go") + require.Contains(t, feedStream, "func (s *WatchServerStream) SendError(") + + calcServer := renderPlannedFile(t, plan.ServerFiles(), "calc", "server.go") + require.Contains(t, calcServer, "if err != nil {") + require.Contains(t, calcServer, "encodeJSONRPCError(ctx, w, req,") +} + +// renderPlannedFile renders one file stored by the plan into memory without +// writing generated output to the repository. +func renderPlannedFile(t *testing.T, files []*codegen.File, service, name string) string { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != name || filepath.Base(filepath.Dir(filepath.Dir(file.Path))) != service { + continue + } + var source strings.Builder + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + return source.String() + } + t.Errorf("generated %s/%s file not found", service, name) + return "" +} diff --git a/jsonrpc/codegen/service_imports.go b/jsonrpc/codegen/service_imports.go index 420d850f7a..9cbca42c86 100644 --- a/jsonrpc/codegen/service_imports.go +++ b/jsonrpc/codegen/service_imports.go @@ -1,45 +1,15 @@ -// This file derives imports from the JSON-RPC endpoint sections rendered into -// one generated file. Streaming-only files pass only their stream endpoints. +// This file adds the imports that the HTTP plan prepared for each JSON-RPC +// output file. package codegen import ( - "path" - "strings" - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// addEndpointImports adds the named service-type references used by endpoints -// to file's header. The output package is computed from the generated path. -func addEndpointImports(file *codegen.File, services *httpcodegen.ServicesData, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { - outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") - outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, httpcodegen.ServiceReferenceAttributes(endpoints...)...)...) +// addFileImports adds the service-type imports prepared for file's path and +// returns file so callers can append it directly to their result. +func addFileImports(file *codegen.File, service httpcodegen.JSONRPCServiceSnapshot) *codegen.File { + codegen.AddImport(file.SectionTemplates[0], service.FileImports(file.Path)...) return file } - -// jsonRPCWebSocketEndpoints returns only the endpoints whose stream sections -// are rendered into WebSocket files. -func jsonRPCWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { - var endpoints []*expr.HTTPEndpointExpr - for _, endpoint := range svc.HTTPEndpoints { - if endpoint.UsesWebSocket() { - endpoints = append(endpoints, endpoint) - } - } - return endpoints -} - -// jsonRPCSSEEndpoints returns only the endpoints whose stream sections are -// rendered into Server-Sent Events files. -func jsonRPCSSEEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { - var endpoints []*expr.HTTPEndpointExpr - for _, endpoint := range svc.HTTPEndpoints { - if endpoint.UsesSSE() { - endpoints = append(endpoints, endpoint) - } - } - return endpoints -} diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index e3ed22e365..07d8ba0055 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -1,5 +1,5 @@ -// This file renders JSON-RPC server-sent-event clients and servers with imports -// scoped to the service represented by each stream file. +// This file renders JSON-RPC server-sent-event clients and servers. Each file +// imports only the generated service types used by its stream methods. package codegen import ( @@ -7,26 +7,33 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" ) -// sseServerFile returns the file implementing the JSON-RPC SSE server -// streams if any. The file contains the shared SSE stream machinery followed -// by one stream implementation per SSE endpoint. -func sseServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil +type ( + // sseServerTemplateData stores the two Go names shared by every server stream + // in one service. + sseServerTemplateData struct { + // Stream stores the response writer and encoder. + Stream *codegen.NameDeclaration + // Buffer stores an encoded event before the response starts. + Buffer *codegen.NameDeclaration } - if !hasSSEEndpoint(data) { +) + +// sseServerFile returns the JSON-RPC server-sent-event file when the service +// has a method that sends events. The file writes the shared event sender once, +// followed by one stream type for each method. +func sseServerFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "server", "sse.go") - title := fmt.Sprintf("%s SSE server streaming", svc.Name()) + title := fmt.Sprintf("%s SSE server streaming", planned.name) imports := make([]*codegen.ImportSpec, 0, 9) imports = append(imports, + &codegen.ImportSpec{Path: "bytes"}, &codegen.ImportSpec{Path: "context"}, &codegen.ImportSpec{Path: "errors"}, &codegen.ImportSpec{Path: "fmt"}, @@ -35,40 +42,45 @@ func sseServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { Name: "jsonrpc-sse-server-stream-base", Source: jsonrpcTemplates.Read(sseServerStreamBaseT), + Data: &sseServerTemplateData{ + Stream: planned.serverNames.sseStream, + Buffer: planned.serverNames.sseBuffer, + }, }, } - for _, ed := range data.Endpoints { + funcs := viewedResultFuncs(planned) + funcs["sseStreamName"] = planned.sseStreamName + for _, ed := range planned.endpoints { if ed.SSE == nil { continue } sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-sse-server-stream", - Source: jsonrpcTemplates.Read(sseServerStreamT), - Data: ed, + Name: "jsonrpc-sse-server-stream", + Source: jsonrpcTemplates.Read(sseServerStreamT), + Data: ed, + FuncMap: funcs, }) } return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientFile returns the file implementing the SSE client streaming implementation if any. -func sseClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil - } - if !hasSSEEndpoint(data) { +// sseClientFile returns the server-sent-event client file when the service has +// a method that receives events. +func sseClientFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "client", "stream.go") - tmplSections := sseClientStreamSections(data) + tmplSections := sseClientStreamSections(planned) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) sections = append(sections, codegen.Header( @@ -86,7 +98,7 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData {Path: "sync"}, codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), }, ), ) @@ -94,29 +106,21 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientStreamSections returns section templates for SSE client endpoints. -func sseClientStreamSections(data *httpcodegen.ServiceData) []*codegen.SectionTemplate { +// sseClientStreamSections returns the generated code for each method that +// receives server-sent events. +func sseClientStreamSections(service *servicePlan) []*codegen.SectionTemplate { sections := make([]*codegen.SectionTemplate, 0) - for _, ed := range data.Endpoints { + for _, ed := range service.endpoints { if ed.SSE == nil { continue } - // Generate SSE client stream struct and methods + // Write the client stream type and its methods. sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-sse-client-stream", - Source: jsonrpcTemplates.Read(sseClientStreamT), - Data: ed, + Name: "jsonrpc-sse-client-stream", + Source: jsonrpcTemplates.Read(sseClientStreamT), + Data: ed, + FuncMap: viewedResultFuncs(service), }) } return sections } - -// hasSSEEndpoint returns true if any endpoint of the service uses SSE. -func hasSSEEndpoint(data *httpcodegen.ServiceData) bool { - for _, ed := range data.Endpoints { - if ed.SSE != nil { - return true - } - } - return false -} diff --git a/jsonrpc/codegen/sse_dedup_test.go b/jsonrpc/codegen/sse_dedup_test.go index 091025bef0..4b360caea7 100644 --- a/jsonrpc/codegen/sse_dedup_test.go +++ b/jsonrpc/codegen/sse_dedup_test.go @@ -17,10 +17,10 @@ import ( // endpoint. func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCSSEDuplicateEventDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate JSON-RPC server files (includes the SSE streams file) - fs := ServerFiles(services) + fs := plan.ServerFiles() require.NotEmpty(t, fs) // Render the SSE streams file (sse.go) @@ -40,8 +40,8 @@ func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { require.NotEmpty(t, code, "sse.go content not found") // The shared machinery must be declared exactly once. - require.Equal(t, 1, strings.Count(code, "type sseServerStream struct"), "expected a single sseServerStream declaration\n%s", code) - require.Equal(t, 1, strings.Count(code, "type sseEventWriter struct"), "expected a single sseEventWriter declaration\n%s", code) + require.Equal(t, 1, strings.Count(code, "sseServerStream struct"), "expected a single sseServerStream declaration\n%s", code) + require.Equal(t, 1, strings.Count(code, "sseEventBuffer struct"), "expected a single sseEventBuffer declaration\n%s", code) // Each endpoint gets its own stream type even when sharing the event type. require.Equal(t, 1, strings.Count(code, "type StreamAServerStream struct"), "expected a single StreamA stream declaration\n%s", code) diff --git a/jsonrpc/codegen/sse_integration_test.go b/jsonrpc/codegen/sse_integration_test.go index 54ec26473a..8958c42f26 100644 --- a/jsonrpc/codegen/sse_integration_test.go +++ b/jsonrpc/codegen/sse_integration_test.go @@ -20,11 +20,11 @@ func TestJSONRPCSSEIntegration(t *testing.T) { // Run the DSL root := expr.RunDSL(t, testdata.JSONRPCSSEObjectDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate all files - serverFiles := ServerFiles(services) - clientFiles := ClientFiles(services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Combine all files allFiles := make([]*codegen.File, 0, len(serverFiles)+len(clientFiles)) diff --git a/jsonrpc/codegen/sse_test.go b/jsonrpc/codegen/sse_test.go index 02fdbbd9bd..ef7c684ac9 100644 --- a/jsonrpc/codegen/sse_test.go +++ b/jsonrpc/codegen/sse_test.go @@ -24,10 +24,10 @@ func TestJSONRPCSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate server files (includes the SSE streams file) - fs := ServerFiles(services) + fs := plan.ServerFiles() require.NotEmpty(t, fs, "expected server files to be generated") // Debug: print all generated files diff --git a/jsonrpc/codegen/templates.go b/jsonrpc/codegen/templates.go index 0956843ea5..d35339c1d9 100644 --- a/jsonrpc/codegen/templates.go +++ b/jsonrpc/codegen/templates.go @@ -21,10 +21,13 @@ const ( mixedServerHandlerT = "mixed_server_handler" // Client - clientStructT = "client_struct" - clientInitT = "client_init" - clientEndpointInitT = "client_endpoint_init" - responseDecoderT = "response_decoder" + clientStructT = "client_struct" + clientInitT = "client_init" + clientEndpointInitT = "client_endpoint_init" + responseDecoderT = "response_decoder" + viewedResultBodyDecodeT = "viewed_result_body_decode" + viewedResultDecodeT = "viewed_result_decode" + viewedResultEncodeT = "viewed_result_encode" // WebSocket templates websocketServerStreamT = "websocket_server_stream" @@ -50,6 +53,8 @@ const ( queryTypeConversionP = "query_type_conversion" elementSliceConversionP = "element_slice_conversion" sliceItemConversionP = "slice_item_conversion" + headerConversionP = "header_conversion" + viewedResultMetadataP = "viewed_result_metadata" ) //go:embed templates/* diff --git a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl index 9c21167db8..92254cc0da 100644 --- a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl +++ b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,13 +1,13 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes JSON-RPC requests to the %s service %s method." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint { {{- if not (isWebSocketEndpoint .) }} var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}(c.encoder) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}(c.encoder) {{- end }} {{- if not (isSSEEndpoint .) }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) {{- end }} ) {{- end }} @@ -21,7 +21,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} if err := encodeRequest(req, v); err != nil { return nil, err } @@ -29,34 +29,31 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { {{- end }} {{- if isWebSocketEndpoint . }} {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - // For WebSocket, pass the base decoder to the stream and decode inner results + // The method stream uses the client response reader for each WebSocket result. decodeResponse := c.decoder {{- end }} - // Get direct WebSocket connection - ws, err := c.getConn(ctx) + conn, err := c.getConn(ctx) if err != nil { return nil, err } - // Create context with cancellation for the stream + // Closing the method stream cancels this context. streamCtx, cancel := context.WithCancel(ctx) - // Create the stream with direct WebSocket handling - stream := &{{ .ClientWebSocket.VarName }}{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, + stream := &{{ .ClientWebSocket.VarDeclaration.Name }}{ + conn: conn, + owner: &{{ websocketRequestOwnerName }}{}, + ctx: streamCtx, + cancel: cancel, + {{- if and .ClientWebSocket.SendName .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} + pendingReady: make(chan struct{}, 1), + {{- end }} {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} decoder: decodeResponse, {{- end }} } - // Start background response handler - go stream.responseHandler() - return stream, nil {{- else if isSSEEndpoint . }} // For SSE endpoints, send JSON-RPC request and establish stream @@ -78,13 +75,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { } // Create the SSE client stream - stream := &{{ .Method.VarName }}ClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, - } - - return stream, nil + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil {{- else }} resp, err := c.Doer.Do(req) if err != nil { diff --git a/jsonrpc/codegen/templates/client_init.go.tpl b/jsonrpc/codegen/templates/client_init.go.tpl index 4fa24f350c..8cab9f4b9d 100644 --- a/jsonrpc/codegen/templates/client_init.go.tpl +++ b/jsonrpc/codegen/templates/client_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s creates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, @@ -11,13 +11,13 @@ func New{{ .ClientStruct }}( cfn goahttp.ConnConfigureFunc, streamOpts ...jsonrpc.StreamConfigOption, {{- end }} -) *{{ .ClientStruct }} { +) *{{ .ClientStructDeclaration.Name }} { {{- if hasWebSocket . }} // Create stream configuration from options streamConfig := jsonrpc.NewStreamConfig(streamOpts...) {{- end }} - return &{{ .ClientStruct }}{ + return &{{ .ClientStructDeclaration.Name }}{ Doer: doer, {{- range .Endpoints }} {{- if isSSEEndpoint . }} diff --git a/jsonrpc/codegen/templates/client_struct.go.tpl b/jsonrpc/codegen/templates/client_struct.go.tpl index 3670bf2ef0..41c690f17a 100644 --- a/jsonrpc/codegen/templates/client_struct.go.tpl +++ b/jsonrpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{ printf "Doer is the HTTP client used to make requests to the %s service." .Service.Name | comment }} Doer goahttp.Doer {{- range .Endpoints }} @@ -20,17 +20,19 @@ type {{ .ClientStruct }} struct { dialer goahttp.Dialer configfn goahttp.ConnConfigureFunc - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool - - // Stream configuration (shared by all WebSocket streams) + connMu sync.Mutex + conn *{{ .WebSocketConnection.Name }} + connecting chan struct{} + closed atomic.Bool + + // streamConfig sets request timeouts and the function called when a + // WebSocket request or connection fails. streamConfig *jsonrpc.StreamConfig {{- end }} } {{- if not (hasWebSocket .) }} -// bufferPool is a pool of bytes.Buffers for encoding requests. -var bufferPool = sync.Pool{ +{{ printf "%s reuses byte buffers while requests are encoded." .BufferPool.Name | comment }} +var {{ .BufferPool.Name }} = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } {{- end }} diff --git a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl index 6390f0966e..756bf8122d 100644 --- a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl @@ -1,13 +1,14 @@ -// ServeHTTP handles JSON-RPC requests with content negotiation for mixed HTTP/SSE transports. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Check Accept header for SSE +// ServeHTTP writes server-sent events when the Accept header requests them and +// writes one ordinary JSON-RPC response for every other request. +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // The event-stream media type asks this server to keep writing results. accept := r.Header.Get("Accept") if strings.Contains(accept, "text/event-stream") { - // Route to SSE handler for streaming methods + // handleSSE writes each streaming result as a server-sent event. s.handleSSE(w, r) return } - // Otherwise handle as regular JSON-RPC HTTP request + // handleHTTP writes one response and completes the request. s.handleHTTP(w, r) } diff --git a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl index c9658f58c4..1e984ffd67 100644 --- a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl @@ -1,4 +1,4 @@ - {{ .VarName }} = make({{ goTypeRef .Type }}, len({{ .VarName }}Raw)) + {{ .VarName }} = make({{ .TypeRef }}, len({{ .VarName }}Raw)) for i, rv := range {{ .VarName }}Raw { {{- template "partial_slice_item_conversion" . }} } diff --git a/jsonrpc/codegen/templates/partial/header_conversion.go.tpl b/jsonrpc/codegen/templates/partial/header_conversion.go.tpl new file mode 100644 index 0000000000..a88b48fc2e --- /dev/null +++ b/jsonrpc/codegen/templates/partial/header_conversion.go.tpl @@ -0,0 +1,38 @@ + {{- if eq .TypeName "boolean" -}} + {{ .VarName }} := strconv.FormatBool({{ if not .Required }}*{{ end }}{{ .Target }}) + {{- else if eq .TypeName "int" -}} + {{ .VarName }} := strconv.Itoa({{ if not .Required }}*{{ end }}{{ .Target }}) + {{- else if eq .TypeName "int32" -}} + {{ .VarName }} := strconv.FormatInt(int64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "int64" -}} + {{ .VarName }} := strconv.FormatInt({{ if not .Required }}*{{ end }}{{ .Target }}, 10) + {{- else if eq .TypeName "uint" -}} + {{ .VarName }} := strconv.FormatUint(uint64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "uint32" -}} + {{ .VarName }} := strconv.FormatUint(uint64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "uint64" -}} + {{ .VarName }} := strconv.FormatUint({{ if not .Required }}*{{ end }}{{ .Target }}, 10) + {{- else if eq .TypeName "float32" -}} + {{ .VarName }} := strconv.FormatFloat(float64({{ if not .Required }}*{{ end }}{{ .Target }}), 'f', -1, 32) + {{- else if eq .TypeName "float64" -}} + {{ .VarName }} := strconv.FormatFloat({{ if not .Required }}*{{ end }}{{ .Target }}, 'f', -1, 64) + {{- else if eq .TypeName "string" -}} + {{ .VarName }} := {{ .Target }} + {{- else if eq .TypeName "bytes" -}} + {{ .VarName }} := string({{ .Target }}) + {{- else if eq .TypeName "any" -}} + {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) + {{- else if eq .TypeName "array" -}} + {{- if eq .ElemTypeName "string" -}} + {{ .VarName }} := strings.Join({{ .Target }}, ", ") + {{- else -}} + {{ .VarName }}Slice := make([]string, len({{ .Target }})) + for i, e := range {{ .Target }} { + {{ template "partial_header_conversion" (headerConversionData .ElemTypeName "" "es" true "e") }} + {{ .VarName }}Slice[i] = es + } + {{ .VarName }} := strings.Join({{ .VarName }}Slice, ", ") + {{- end }} + {{- else }} + // The Goa design must use a primitive value or an array for an HTTP response header or cookie. + {{- end }} diff --git a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl index 9765e1e141..e367786266 100644 --- a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl @@ -1,6 +1,6 @@ - {{- if eq .Type.Name "bytes" }} + {{- if eq .TypeName "bytes" }} {{ .VarName }} = []byte({{.VarName}}Raw) - {{- else if eq .Type.Name "int" }} + {{- else if eq .TypeName "int" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -11,7 +11,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int32" }} + {{- else if eq .TypeName "int32" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -22,13 +22,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int64" }} + {{- else if eq .TypeName "int64" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "int64") (ne .TypeRef "*int64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "uint" }} + {{- else if eq .TypeName "uint" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -39,7 +39,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint32" }} + {{- else if eq .TypeName "uint32" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -50,13 +50,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint64" }} + {{- else if eq .TypeName "uint64" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "uint64") (ne .TypeRef "*uint64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "float32" }} + {{- else if eq .TypeName "float32" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) @@ -67,18 +67,18 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}float32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "float64" }} + {{- else if eq .TypeName "float64" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "float64") (ne .TypeRef "*float64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "boolean" }} + {{- else if eq .TypeName "boolean" }} v, err2 := strconv.ParseBool({{ .VarName }}Raw) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "boolean")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "bool") (ne .TypeRef "*bool")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} {{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} + // The Goa design must use bytes, a number, or a boolean for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/partial/single_response.go.tpl b/jsonrpc/codegen/templates/partial/single_response.go.tpl index 764446207d..542355238a 100644 --- a/jsonrpc/codegen/templates/partial/single_response.go.tpl +++ b/jsonrpc/codegen/templates/partial/single_response.go.tpl @@ -29,19 +29,19 @@ ) {{- range .Headers }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{ .VarName }}Raw := resp.Header.Get("{{ .CanonicalName }}") {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "header")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} @@ -135,18 +135,18 @@ } {{- range .Cookies }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "cookie")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} diff --git a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl index ece0457571..3ab157de32 100644 --- a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -1,63 +1,63 @@ - {{- if eq .Type.ElemType.Type.Name "string" }} - {{ .VarName }}[i] = rv - {{- else if eq .Type.ElemType.Type.Name "bytes" }} - {{ .VarName }}[i] = []byte(rv) - {{- else if eq .Type.ElemType.Type.Name "int" }} + {{- if eq .ElemTypeName "string" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) + {{- else if eq .ElemTypeName "bytes" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}([]byte(rv)) + {{- else if eq .ElemTypeName "int" }} v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int(v) - {{- else if eq .Type.ElemType.Type.Name "int32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int32" }} v, err2 := strconv.ParseInt(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int32(v) - {{- else if eq .Type.ElemType.Type.Name "int64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int64" }} v, err2 := strconv.ParseInt(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "uint" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint" }} v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint(v) - {{- else if eq .Type.ElemType.Type.Name "uint32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint32" }} v, err2 := strconv.ParseUint(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint32(v) - {{- else if eq .Type.ElemType.Type.Name "uint64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint64" }} v, err2 := strconv.ParseUint(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "float32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float32" }} v, err2 := strconv.ParseFloat(rv, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = float32(v) - {{- else if eq .Type.ElemType.Type.Name "float64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float64" }} v, err2 := strconv.ParseFloat(rv, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "boolean" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "boolean" }} v, err2 := strconv.ParseBool(rv) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of booleans")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "any" }} - {{ .VarName }}[i] = rv + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "any" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) {{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} + // The Goa design must use primitive array elements for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl b/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl new file mode 100644 index 0000000000..9eee4e7d10 --- /dev/null +++ b/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl @@ -0,0 +1,77 @@ +{{- range .Headers }} + {{- $hasDefault := and (or .FieldPointer .Slice) .DefaultValue }} + {{- $checkNil := or .FieldPointer .Slice (eq .TypeName "bytes") (eq .TypeName "any") $hasDefault }} + {{- if $checkNil }} + if res.Projected.{{ .FieldName }} != nil { + {{- end }} + {{- if and (eq .TypeName "string") (not .IsAliased) }} + w.Header().Set("{{ .CanonicalName }}", {{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{- else }} + {{- if not $checkNil }} + { + {{- end }} + {{- if .IsAliased }} + val := {{ goTypeRef .TypeName .ElemTypeName }}({{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%ss" .VarName) true "val") }} + {{- else }} + val := res.Projected.{{ .FieldName }} + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%ss" .VarName) (not .FieldPointer) "val") }} + {{- end }} + w.Header().Set("{{ .CanonicalName }}", {{ .VarName }}s) + {{- if not $checkNil }} + } + {{- end }} + {{- end }} + {{- if $hasDefault }} + } else { + w.Header().Set("{{ .CanonicalName }}", "{{ printValue .TypeName .ElemTypeName .DefaultValue }}") + {{- end }} + {{- if or $checkNil $hasDefault }} + } + {{- end }} +{{- end }} +{{- range .Cookies }} + {{- $hasDefault := and (or .FieldPointer .Slice) .DefaultValue }} + {{- $checkNil := or .FieldPointer .Slice (eq .TypeName "bytes") (eq .TypeName "any") $hasDefault }} + {{- if $checkNil }} + if res.Projected.{{ .FieldName }} != nil { + {{- end }} + {{- if eq .TypeName "string" }} + {{ .VarName }} := {{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }} + {{- else if .IsAliased }} + {{ .VarName }}raw := {{ goTypeRef .TypeName .ElemTypeName }}({{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%sraw" .VarName) true .VarName) }} + {{- else }} + {{ .VarName }}raw := res.Projected.{{ .FieldName }} + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%sraw" .VarName) (not .FieldPointer) .VarName) }} + {{- end }} + {{- if $hasDefault }} + } else { + {{ .VarName }} := "{{ printValue .TypeName .ElemTypeName .DefaultValue }}" + {{- end }} + http.SetCookie(w, &http.Cookie{ + Name: {{ printf "%q" .HTTPName }}, + Value: {{ .VarName }}, + {{- if .MaxAge }} + MaxAge: {{ .MaxAge }}, + {{- end }} + {{- if .Path }} + Path: {{ printf "%q" .Path }}, + {{- end }} + {{- if .Domain }} + Domain: {{ printf "%q" .Domain }}, + {{- end }} + {{- if .Secure }} + Secure: true, + {{- end }} + {{- if .HTTPOnly }} + HttpOnly: true, + {{- end }} + {{- if .SameSite }} + SameSite: {{ .SameSite }}, + {{- end }} + }) + {{- if or $checkNil $hasDefault }} + } + {{- end }} +{{- end }} diff --git a/jsonrpc/codegen/templates/response_decoder.go.tpl b/jsonrpc/codegen/templates/response_decoder.go.tpl index 3945a9244f..bbb9801302 100644 --- a/jsonrpc/codegen/templates/response_decoder.go.tpl +++ b/jsonrpc/codegen/templates/response_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { +{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { return func(resp *http.Response) (any, error) { if restoreBody { b, err := io.ReadAll(resp.Body) @@ -47,39 +47,14 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor } } -{{- with index .Result.Responses 0 }} + {{- if .Method.ViewedResult }} + return {{ viewedDecodeName .Method.Name }}(decoder, resp, jresp.Result) + {{- else }} +{{- with index .Result.Responses 0 }} resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- if .TagName }} - tmp := {{ printf "%q" .TagValue }} - p.{{ .TagName }} = &tmp - {{- end }} - {{- if $.Method.ViewedResult.ViewName }} - view := {{ printf "%q" $.Method.ViewedResult.ViewName }} - {{- else }} - view := resp.Header.Get("goa-view") - {{- end }} - vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} - {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { - return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) - } - {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) - {{- else }} res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- end }} - {{- if and .TagName (not .ViewedResult) }} - {{- if .TagPointer }} - tmp := {{ printf "%q" .TagValue }} - res.{{ .TagName }} = &tmp - {{- else }} - res.{{ .TagName }} = {{ printf "%q" .TagValue }} - {{- end }} - {{- end }} return res, nil {{- else if .ClientBody }} return body, nil @@ -91,5 +66,6 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return nil, nil {{- end }} {{- end }} + {{- end }} } } diff --git a/jsonrpc/codegen/templates/server_encode_error.go.tpl b/jsonrpc/codegen/templates/server_encode_error.go.tpl index ecacfa13ab..f4e80b6664 100644 --- a/jsonrpc/codegen/templates/server_encode_error.go.tpl +++ b/jsonrpc/codegen/templates/server_encode_error.go.tpl @@ -1,10 +1,10 @@ -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { - encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) +{{ printf "encodeJSONRPCError writes one JSON-RPC error response and preserves a missing request ID." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { + {{ .EncodeError.Name }}(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func encodeJSONRPCError( +{{ printf "%s writes one JSON-RPC error response and preserves a missing request ID." .EncodeError.Name | comment }} +func {{ .EncodeError.Name }}( ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, diff --git a/jsonrpc/codegen/templates/server_handler.go.tpl b/jsonrpc/codegen/templates/server_handler.go.tpl index 9f894b05a4..01b357d7f1 100644 --- a/jsonrpc/codegen/templates/server_handler.go.tpl +++ b/jsonrpc/codegen/templates/server_handler.go.tpl @@ -1,12 +1,12 @@ {{- if and (not (isWebSocketEndpoint (index .Endpoints 0))) (not (hasMixedTransports)) }} // ServeHTTP handles JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } {{- end }} {{- comment "handleHTTP handles JSON-RPC requests." }} -func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) handleHTTP(w http.ResponseWriter, r *http.Request) { // Peek at the first byte to determine request type bufReader := bufio.NewReader(r.Body) peek, err := bufReader.Peek(1) @@ -39,7 +39,7 @@ func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) } // handleSingle handles a single JSON-RPC request. -func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { // JSON-RPC parse error with null id and generic message @@ -53,7 +53,7 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { } // handleBatch handles a batch of JSON-RPC requests. -func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { // JSON-RPC parse error for batch with null id and generic message @@ -66,7 +66,7 @@ func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { // Write responses w.Header().Set("Content-Type", "application/json") - writer := &batchWriter{Writer: w} + writer := &{{ .BatchWriter.Name }}{Writer: w} for _, req := range reqs { // Process the request with batch writer @@ -80,7 +80,7 @@ func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { } // ProcessRequest processes a single JSON-RPC request. -func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { +func (s *{{ .ServerStructDeclaration.Name }}) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { if req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return @@ -103,29 +103,29 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses -type batchWriter struct { +{{ printf "%s joins the responses written for one JSON-RPC batch request." .BatchWriter.Name | comment }} +type {{ .BatchWriter.Name }} struct { io.Writer header http.Header statusCode int written bool } -func (rb *batchWriter) Header() http.Header { +func (rb *{{ .BatchWriter.Name }}) Header() http.Header { if rb.header == nil { rb.header = make(http.Header) } return rb.header } -func (rb *batchWriter) WriteHeader(statusCode int) { +func (rb *{{ .BatchWriter.Name }}) WriteHeader(statusCode int) { if rb.written { return } rb.statusCode = statusCode } -func (rb *batchWriter) Write(data []byte) (int, error) { +func (rb *{{ .BatchWriter.Name }}) Write(data []byte) (int, error) { if !rb.written { rb.written = true rb.Writer.Write([]byte{'['}) diff --git a/jsonrpc/codegen/templates/server_handler_init.go.tpl b/jsonrpc/codegen/templates/server_handler_init.go.tpl index 14d6d1305f..78304f5f0b 100644 --- a/jsonrpc/codegen/templates/server_handler_init.go.tpl +++ b/jsonrpc/codegen/templates/server_handler_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s creates a JSON-RPC handler which calls the %q service %q endpoint." .HandlerInit .ServiceName .Method.Name | comment }} -func {{ .HandlerInit }}( +{{ printf "%s creates a JSON-RPC handler which calls the %q service %q endpoint." .HandlerInitDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .HandlerInitDeclaration.Name }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, @@ -10,7 +10,7 @@ func {{ .HandlerInit }}( ) func(context.Context, *http.Request, *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { {{- if and (not (isSSEEndpoint .)) .Payload.Ref }} {{- if not (and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} {{- end }} return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, w http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { @@ -18,22 +18,23 @@ func {{ .HandlerInit }}( ctx = context.WithValue(ctx, goa.ServiceKey, {{ printf "%q" .ServiceName }}) {{- if isSSEEndpoint . }} - // Initialize SSE stream early so decode errors can be sent as SSE error events - strm := &{{ .SSE.StructName }}{ - sseServerStream: sseServerStream{ + // Create the stream before decoding so a request error can be written to it. + strm := &{{ .SSE.StructDeclaration.Name }}{ + {{ sseStreamName }}: {{ sseStreamName }}{ w: w, - r: r, encoder: encoder, }, requestID: req.ID, } {{- if .Payload.Ref }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) params, err := decodeParams(r, req) if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics + // Write the request error as a JSON-RPC server-sent event when the request has an ID. if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) + if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil { + return err + } } return nil } @@ -56,7 +57,7 @@ func {{ .HandlerInit }}( ctx = context.WithValue(ctx, "last-event-id", lastEventID) {{- if .Payload.Ref }} {{- if .Payload.Request }} - {{- if eq .Payload.Request.PayloadType.Name "Object" }} + {{- if eq .Payload.Request.PayloadTypeName "Object" }} params.{{ .SSE.RequestIDField }} = lastEventID {{- end }} {{- end }} @@ -68,20 +69,15 @@ func {{ .HandlerInit }}( {{- if .Payload.Ref }} Payload: params, {{- end }} - } + } if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil + return err } return nil {{- else }} {{- if .Payload.Ref }} {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} params, err := decodeParams(r, req) if err != nil { @@ -94,9 +90,9 @@ func {{ .HandlerInit }}( if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -144,7 +140,7 @@ func {{ .HandlerInit }}( if req.ID != nil && req.ID != "" { var en goa.GoaErrorNamer if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) return nil } switch en.GoaErrorName() { @@ -152,23 +148,23 @@ func {{ .HandlerInit }}( {{- range $err := $gerr.Errors }} case {{ printf "%q" .Name }}: {{- with .Response}} - encodeJSONRPCError(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) {{- end }} {{- end }} {{- end }} case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) default: code := jsonrpc.InternalError if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil @@ -217,15 +213,20 @@ func {{ .HandlerInit }}( } // Send response with the result - {{- if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - // Convert result to response body with proper JSON tags {{- if .Method.ViewedResult }} viewedRes := res.({{ .Method.ViewedResult.FullRef }}) - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(viewedRes.Projected) - {{- else }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(res.({{ .Result.Ref }})) + body, err := {{ viewedEncodeName .Method.Name }}(viewedRes) + if err != nil { + return err + } + {{- if viewedHasMetadata .Method.Name }} + {{ viewedMetadataName .Method.Name }}(w, viewedRes) {{- end }} response := jsonrpc.MakeSuccessResponse(id, body) + {{- else if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} + // Build the response body with the fields and JSON names declared by the service. + body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(res.({{ .Result.Ref }})) + response := jsonrpc.MakeSuccessResponse(id, body) {{- else }} response := jsonrpc.MakeSuccessResponse(id, res) {{- end }} diff --git a/jsonrpc/codegen/templates/server_init.go.tpl b/jsonrpc/codegen/templates/server_init.go.tpl index 83e6896e32..c2c927297c 100644 --- a/jsonrpc/codegen/templates/server_init.go.tpl +++ b/jsonrpc/codegen/templates/server_init.go.tpl @@ -1,9 +1,9 @@ -{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( +{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( {{- if isWebSocketEndpoint (index .Endpoints 0) }} - streamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error, + streamHandler func(context.Context, {{ .Service.PkgName }}.{{ .Service.StreamDeclaration.Name }}) error, {{- end }} - endpoints *{{ .Service.PkgName }}.Endpoints, + endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, @@ -12,8 +12,8 @@ func {{ .ServerInit }}( upgrader goahttp.Upgrader, configfn goahttp.ConnConfigureFunc, {{- end }} -) *{{ .ServerStruct }} { - s := &{{ .ServerStruct }}{ +) *{{ .ServerStructDeclaration.Name }} { + s := &{{ .ServerStructDeclaration.Name }}{ Methods: []string{ {{- range .Endpoints }} {{ printf "%q" .Method.Name }}, @@ -24,12 +24,12 @@ func {{ .ServerInit }}( {{- end }} {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder), + {{ lowerInitial .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(endpoints.{{ .Method.VarName }}, mux, decoder), {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} {{ lowerInitial .Method.VarName }}Endpoint: endpoints.{{ .Method.VarName }}, {{- end }} {{- else }} - {{ .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), + {{ .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), {{- end }} {{- end }} decoder: decoder, @@ -40,15 +40,15 @@ func {{ .ServerInit }}( configfn: configfn, {{- end }} } - // Default HTTP handler per transport kind + // Install the request handler required by this service's methods. {{- if isWebSocketEndpoint (index .Endpoints 0) }} - // WebSocket services implement ServeHTTP for upgrade + // ServeHTTP changes the HTTP connection to a WebSocket connection. s.Handler = http.HandlerFunc(s.ServeHTTP) {{- else if isSSEEndpoint (index .Endpoints 0) }} - // SSE-only services route via handleSSE + // handleSSE writes each result as a server-sent event. s.Handler = http.HandlerFunc(s.handleSSE) {{- else }} - // Plain HTTP JSON-RPC + // ServeHTTP writes one JSON-RPC response for each request. s.Handler = http.HandlerFunc(s.ServeHTTP) {{- end }} return s diff --git a/jsonrpc/codegen/templates/server_method_names.go.tpl b/jsonrpc/codegen/templates/server_method_names.go.tpl new file mode 100644 index 0000000000..d6a7ddc2aa --- /dev/null +++ b/jsonrpc/codegen/templates/server_method_names.go.tpl @@ -0,0 +1,2 @@ +{{ printf "MethodNames returns the methods served." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/jsonrpc/codegen/templates/server_mount.go.tpl b/jsonrpc/codegen/templates/server_mount.go.tpl index ecabd73a18..688acd1f5c 100644 --- a/jsonrpc/codegen/templates/server_mount.go.tpl +++ b/jsonrpc/codegen/templates/server_mount.go.tpl @@ -1,26 +1,26 @@ -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- if .HasMixed }} - // Mixed transports: mount unified handler that negotiates HTTP vs SSE by Accept header + // ServeHTTP checks the Accept header and chooses an ordinary response or server-sent events. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- else if .HasSSE }} - // SSE only: mount SSE handler + // Every method in this server writes server-sent events. {{- range .Endpoints }} {{- range .Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.handleSSE) {{- end }} {{- end }} {{- else }} - // HTTP only + // Every method in this server writes one ordinary JSON-RPC response. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- end }} } -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/jsonrpc/codegen/templates/server_service.go.tpl b/jsonrpc/codegen/templates/server_service.go.tpl new file mode 100644 index 0000000000..c8337b8caa --- /dev/null +++ b/jsonrpc/codegen/templates/server_service.go.tpl @@ -0,0 +1,2 @@ +{{ printf "%s returns the name of the service served." .ServerService | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } diff --git a/jsonrpc/codegen/templates/server_struct.go.tpl b/jsonrpc/codegen/templates/server_struct.go.tpl index eb3325b737..43d16bbeca 100644 --- a/jsonrpc/codegen/templates/server_struct.go.tpl +++ b/jsonrpc/codegen/templates/server_struct.go.tpl @@ -1,11 +1,11 @@ -{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { http.Handler // Methods is the list of methods served by this server. Methods []string {{- if isWebSocketEndpoint (index .Endpoints 0) }} // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error + StreamHandler func(context.Context, {{ .Service.PkgName }}.{{ .Service.StreamDeclaration.Name }}) error {{- end }} {{ range .Endpoints }} {{- if isWebSocketEndpoint . }} diff --git a/jsonrpc/codegen/templates/server_use.go.tpl b/jsonrpc/codegen/templates/server_use.go.tpl index 384309dd77..0e70742086 100644 --- a/jsonrpc/codegen/templates/server_use.go.tpl +++ b/jsonrpc/codegen/templates/server_use.go.tpl @@ -1,4 +1,4 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) } diff --git a/jsonrpc/codegen/templates/sse_client_stream.go.tpl b/jsonrpc/codegen/templates/sse_client_stream.go.tpl index a9f80fbe9c..8bb664249c 100644 --- a/jsonrpc/codegen/templates/sse_client_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_client_stream.go.tpl @@ -1,14 +1,37 @@ -{{ printf "%sClientStream implements the %s.%sClientStream interface using Server-Sent Events." .Method.VarName .ServicePkgName .Method.VarName | comment }} -type {{ .Method.VarName }}ClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +type ( + {{ printf "%s reads results sent as server-sent events." .SSE.ClientInterfaceDeclaration.Name | comment }} + {{ .SSE.ClientInterfaceDeclaration.Name }} interface { + {{ .Method.ClientStream.RecvName }}() ({{ .Result.Ref }}, error) + {{ .Method.ClientStream.RecvWithContextName }}(context.Context) ({{ .Result.Ref }}, error) + Close() error + } + + {{ printf "%s reads and decodes events for %s." .SSE.ClientStructDeclaration.Name .Method.Name | comment }} + {{ .SSE.ClientStructDeclaration.Name }} struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +{{ printf "%s creates a stream that reads server-sent events from resp." .SSE.ClientInitDeclaration.Name | comment }} +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent() (eventType string, data []byte, err error) { var event strings.Builder var dataLines []string @@ -16,7 +39,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d line, err := s.reader.ReadString('\n') if err != nil { if err == io.EOF && len(dataLines) > 0 { - // Process final event + // Return the last event even when the response has no final blank line. break } return "", nil, err @@ -26,7 +49,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d line = strings.TrimSuffix(line, "\r") if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } @@ -38,7 +61,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d } else if strings.HasPrefix(line, "data:") { dataLines = append(dataLines, strings.TrimSpace(line[5:])) } - // Ignore other fields like id:, retry: + // This client does not use the id and retry fields. } if len(dataLines) > 0 { @@ -49,7 +72,12 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d } {{ comment .Method.ClientStream.RecvDesc }} -func (s *{{ .Method.VarName }}ClientStream) {{ .Method.ClientStream.RecvName }}(ctx context.Context) ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .Result.Ref }}, error) { + return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) +} + +{{ comment .Method.ClientStream.RecvWithContextDesc }} +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(_ context.Context) ({{ .Result.Ref }}, error) { s.lock.Lock() defer s.lock.Unlock() @@ -156,15 +184,20 @@ func (s *{{ .Method.VarName }}ClientStream) {{ .Method.ClientStream.RecvName }}( } {{- if .Method.Result }} -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *{{ .Method.VarName }}ClientStream) decodeResult(data json.RawMessage) ({{ .Result.Ref }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMessage) ({{ .Result.Ref }}, error) { + {{- if .Method.ViewedResult }} + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return {{ viewedDecodeName .Method.Name }}(s.decoder, resp, data) + {{- else }} + // Give the configured decoder the successful result bytes as an HTTP response body. resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) var result {{ .Result.Ref }} if err := decoder.Decode(&result); err != nil { @@ -172,11 +205,12 @@ func (s *{{ .Method.VarName }}ClientStream) decodeResult(data json.RawMessage) ( } return result, nil + {{- end }} } {{- end }} {{ comment "Close closes the stream." }} -func (s *{{ .Method.VarName }}ClientStream) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() diff --git a/jsonrpc/codegen/templates/sse_server_handler.go.tpl b/jsonrpc/codegen/templates/sse_server_handler.go.tpl index 03ef92f715..10c0ffc57b 100644 --- a/jsonrpc/codegen/templates/sse_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_handler.go.tpl @@ -1,30 +1,36 @@ -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { +// handleSSE finds the requested method and writes its results as server-sent events. +func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Read the JSON-RPC request + // Read the JSON-RPC request. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + // Write the parse error as a server-sent event. + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write parse error event: %w", err)) + } return } - // Validate JSON-RPC request + // Reject requests that do not use JSON-RPC 2.0. if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // Find the appropriate handler based on method name + // Find the function for the requested method. var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { {{- range .Endpoints }} @@ -34,18 +40,20 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { {{- end }} {{- end }} default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } return } - // Call the handler for the specific method + // Call the requested method. if err := handler(ctx, r, &req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) return } - // For notifications (requests without ID) that don't stream, return 204 No Content + // A request without an ID receives no response when the method sends one result. switch req.Method { {{- range .Endpoints }} {{- if and .SSE (not .Method.ServerStream) }} @@ -56,4 +64,4 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { {{- end }} {{- end }} } -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/templates/sse_server_stream.go.tpl b/jsonrpc/codegen/templates/sse_server_stream.go.tpl index a61f6324ae..1514f6fc8f 100644 --- a/jsonrpc/codegen/templates/sse_server_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream.go.tpl @@ -1,107 +1,136 @@ -{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructName .ServicePkgName .Method.ServerStream.Interface) }} -type {{ .SSE.StructName }} struct { - // sseServerStream provides the shared SSE event encoding machinery - sseServerStream - // requestID is the JSON-RPC request ID for sending final response +{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructDeclaration.Name .ServicePkgName .Method.ServerStream.Interface) }} +type {{ .SSE.StructDeclaration.Name }} struct { + // {{ sseStreamName }} writes JSON-RPC messages as server-sent events. + {{ sseStreamName }} + // requestID identifies the request in the final response. requestID any - // closed indicates if the stream has been closed via SendAndClose + // closed records whether SendAndClose has written the final response. closed bool - // mu protects the closed flag + // mu protects closed and view while service code sends results. mu sync.Mutex + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + // view is the result view selected for the next event sent by this request. + view string + {{- end }} +} + +{{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} +{{ comment "SetView selects the result view used by later sends on this request stream." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.mu.Lock() + s.view = view + s.mu.Unlock() } +{{- end }} {{ comment "Send sends a JSON-RPC notification to the client." }} {{ comment "Notifications do not expect a response from the client." }} -func (s *{{ .SSE.StructName }}) Send(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is closed" }} +func (s *{{ .SSE.StructDeclaration.Name }}) Send(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.EventDeclaration.Name }}) error { + {{ comment "Reject a send after SendAndClose wrote the final response." }} s.mu.Lock() if s.closed { s.mu.Unlock() return fmt.Errorf("stream closed") } + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + view := s.view + {{- end }} s.mu.Unlock() - {{ comment "Type assert to the specific result type" }} + {{ comment "Read the service result value from the event." }} result, ok := event.({{ .SSE.EventTypeRef }}) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} + {{- if .Method.ViewedResult }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err + } + {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} + {{ comment "Build the JSON body declared for this service result." }} body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) {{- else }} body := result {{- end }} - {{ comment "Send as notification (no ID)" }} + {{ comment "Write a notification without a request ID." }} message := map[string]any{ "jsonrpc": "2.0", "method": {{ printf "%q" .Method.Name }}, "params": body, } - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } {{ comment "SendAndClose sends a final JSON-RPC response to the client and closes the stream." }} {{ comment "The response will include the original request ID unless the result has an ID field populated." }} {{ comment "After calling this method, no more events can be sent on this stream." }} -func (s *{{ .SSE.StructName }}) SendAndClose(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is already closed" }} +func (s *{{ .SSE.StructDeclaration.Name }}) SendAndClose(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.EventDeclaration.Name }}) error { + {{ comment "Reject a second final response." }} s.mu.Lock() if s.closed { s.mu.Unlock() return fmt.Errorf("stream already closed") } s.closed = true + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + view := s.view + {{- end }} s.mu.Unlock() - {{ comment "Type assert to the specific result type" }} + {{ comment "Read the service result value from the event." }} result, ok := event.({{ .SSE.EventTypeRef }}) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - {{ comment "Determine the ID to use for the response" }} + {{ comment "Start with the ID of the request that opened this stream." }} var id any = s.requestID {{- if .Result.IDAttribute }} {{- if .Result.IDAttributeRequired }} if result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} + {{ comment "Use the result ID when the service supplied one." }} id = result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} + {{ comment "Remove the ID from the result body because the response already contains it." }} result.{{ .Result.IDAttribute }} = "" } {{- else }} if result.{{ .Result.IDAttribute }} != nil && *result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} + {{ comment "Use the result ID when the service supplied one." }} id = *result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} + {{ comment "Remove the ID from the result body because the response already contains it." }} result.{{ .Result.IDAttribute }} = nil } {{- end }} {{- end }} - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} + {{- if .Method.ViewedResult }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err + } + {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} + {{ comment "Build the JSON body declared for this service result." }} body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) {{- else }} body := result {{- end }} - {{ comment "Send as response with ID" }} + {{ comment "Write the final response with its request ID." }} message := map[string]any{ "jsonrpc": "2.0", "id": id, "result": body, } - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "response", message) } {{ comment "SendError sends a JSON-RPC error response." }} -func (s *{{ .SSE.StructName }}) SendError(ctx context.Context, id string, err error) error { +func (s *{{ .SSE.StructDeclaration.Name }}) SendError(ctx context.Context, id string, err error) error { {{- if .Errors }} var en goa.GoaErrorNamer if !errors.As(err, &en) { @@ -128,7 +157,7 @@ func (s *{{ .SSE.StructName }}) SendError(ctx context.Context, id string, err er return s.sendError(ctx, id, code, err.Error(), nil) } {{- else }} - {{ comment "No custom errors defined - check if it's a validation error, otherwise use internal error" }} + {{ comment "Report request validation failures as invalid parameters and all other failures as internal errors." }} code := jsonrpc.InternalError if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams diff --git a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl index 8b469d2394..51cf6f87ae 100644 --- a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl @@ -1,44 +1,29 @@ -{{ comment "sseServerStream provides the SSE event encoding machinery shared by all JSON-RPC SSE server streams of the service." }} -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder -} - -{{ comment "sseEventWriter wraps http.ResponseWriter to format output as SSE events." }} -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool -} - -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) +type ( + {{ printf "%s writes JSON-RPC messages as server-sent events." .Stream.Name | comment }} + {{ .Stream.Name }} struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder } - return s.w.Write(data) -} -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() + {{ printf "%s stores an encoded event before any HTTP output is written." .Buffer.Name | comment }} + {{ .Buffer.Name }} struct { + bytes.Buffer + header http.Header } +) + +func (b *{{ .Buffer.Name }}) Header() http.Header { + return b.header } -// initSSEHeaders initializes the SSE response headers -func (s *sseServerStream) initSSEHeaders() { +func (b *{{ .Buffer.Name }}) WriteHeader(int) {} + +// initSSEHeaders writes the response headers before the first event. +func (s *{{ .Stream.Name }}) initSSEHeaders() { s.once.Do(func() { header := s.w.Header() header.Set("Content-Type", "text/event-stream") @@ -49,24 +34,37 @@ func (s *sseServerStream) initSSEHeaders() { }) } -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) - - // Finish the SSE event (adds newlines and flushes) - ew.finish() +// sendSSEEvent encodes one event before starting the response, then writes and +// flushes that complete event. +func (s *{{ .Stream.Name }}) sendSSEEvent(ctx context.Context, eventType string, value any) error { + event := &{{ .Buffer.Name }}{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } - return err + s.initSSEHeaders() + if eventType != "" { + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + } + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(event.Bytes()); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil } -// sendError sends a JSON-RPC error response to the SSE stream -func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { +// sendError writes one JSON-RPC error as a server-sent event. +func (s *{{ .Stream.Name }}) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) + return s.sendSSEEvent(ctx, "error", response) } diff --git a/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl new file mode 100644 index 0000000000..9a785d94df --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl @@ -0,0 +1,10 @@ +{{ printf "%s decodes one JSON-RPC result value with the configured HTTP decoder." .Name | comment }} +func {{ .Name }}(decoder func(*http.Response) goahttp.Decoder, data json.RawMessage, target any) error { + // A JSON-RPC result is a successful HTTP value even when it arrived inside + // a server-sent event or WebSocket message. + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + return decoder(resp).Decode(target) +} diff --git a/jsonrpc/codegen/templates/viewed_result_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl new file mode 100644 index 0000000000..d87ca05d37 --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl @@ -0,0 +1,57 @@ +{{ printf "%s decodes the JSON body selected by the result view for the %s service %s method." .Decode.Name .ServiceName .MethodName | comment }} +func {{ .Decode.Name }}(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage) ({{ .ResultRef }}, error) { + {{- if .Variable }} + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := {{ .BodyDecoder.Name }}(decoder, data, &representation); err != nil { + return nil, err + } + if representation.View == nil { + return nil, goa.MissingFieldError("view", "result") + } + view := *representation.View + switch view { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ClientBody }} + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} + projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + viewed := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: view, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}(viewed); err != nil { + return nil, err + } + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}(viewed), nil + {{- end }} + default: + return nil, goa.InvalidEnumValueError("view", view, []any{ + {{- range .Branches }}{{ printf "%q" .View }},{{ end }} + }) + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ClientBody }} + resp.Body = io.NopCloser(bytes.NewBuffer(data)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} + projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + viewed := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: {{ printf "%q" $.FixedView }}, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}(viewed); err != nil { + return nil, err + } + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}(viewed), nil + {{- end }} + {{- end }} +} diff --git a/jsonrpc/codegen/templates/viewed_result_encode.go.tpl b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl new file mode 100644 index 0000000000..b85dec06de --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl @@ -0,0 +1,77 @@ +{{ printf "%s builds the JSON body selected by the result view for the %s service %s method." .Encode.Name .ServiceName .MethodName | comment }} +func {{ .Encode.Name }}(viewed {{ .ViewedTypeRef }}) (any, error) { + if err := {{ .ViewedPkg }}.{{ .ViewedValidator }}(viewed); err != nil { + return nil, err + } + {{- if .Variable }} + switch viewed.View { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + body := {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}) + {{- else }} + body := viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }} + {{- end }} + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: {{ printf "%q" .View }}, + Body: body, + }, nil + {{- else }} + return struct { + View string `json:"view"` + }{ + View: {{ printf "%q" .View }}, + }, nil + {{- end }} + {{- end }} + default: + panic("validated viewed result has no JSON-RPC representation") + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + return {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}), nil + {{- else }} + return viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }}, nil + {{- end }} + {{- else }} + return nil, nil + {{- end }} + {{- end }} + {{- end }} +} + +{{ printf "%s builds and validates the selected result view before JSON-RPC encoding." .StreamEncode.Name | comment }} +func {{ .StreamEncode.Name }}(result {{ .ResultRef }}{{ if .Variable }}, view string{{ end }}) (any, error) { + viewed := {{ .ServicePkg }}.{{ .ServiceViewedConstructor }}(result, {{ if .Variable }}view{{ else }}{{ printf "%q" .FixedView }}{{ end }}) + return {{ .Encode.Name }}(viewed) +} + +{{- if .HasResponseMetadata }} +{{ printf "%s writes the HTTP response headers and cookies selected by the validated result view." .WriteMetadata.Name | comment }} +func {{ .WriteMetadata.Name }}(w http.ResponseWriter, viewed {{ .ViewedTypeRef }}) { + {{- if .Variable }} + switch viewed.View { + {{- range .Branches }} + case {{ printf "%q" .View }}: + res := viewed + {{- template "partial_viewed_result_metadata" . }} + {{- end }} + default: + panic("validated viewed result has an unknown result view") + } + {{- else }} + {{- with index .Branches 0 }} + res := viewed + {{- template "partial_viewed_result_metadata" . }} + {{- end }} + {{- end }} +} +{{- end }} diff --git a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl b/jsonrpc/codegen/templates/websocket_client_conn.go.tpl index c2a77f8dc5..8794b6fec5 100644 --- a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl +++ b/jsonrpc/codegen/templates/websocket_client_conn.go.tpl @@ -1,49 +1,124 @@ {{/* -websocket_client_conn.go.tpl generates WebSocket connection management methods for JSON-RPC clients. +websocket_client_conn.go.tpl generates the WebSocket shared by every JSON-RPC +method on one client. One function reads every server message. Each request +gets a unique ID, a timeout, and a function that receives its result or error. +The response ID selects that function. Only one caller writes to the socket at +a time. +*/}} +type ( + // {{ .WebSocketConnection.Name }} keeps the socket, the next request ID, and the + // functions called when waiting requests receive a result or error. + {{ .WebSocketConnection.Name }} struct { + ws *websocket.Conn -This template provides connection lifecycle management including: -- Connection establishment with health checking -- Connection reuse and automatic reconnection -- Thread-safe connection access with read/write locking -- Proper cleanup on client close + writeMu sync.Mutex + nextID atomic.Uint64 -Template variables: -- .ClientStruct: Name of the generated client struct -*/}} -// getConn returns the current WebSocket connection or creates a new one -func (c *{{ .ClientStruct }}) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() + stateMu sync.Mutex + pending map[string]*{{ .WebSocketPendingRequest.Name }} + err error + done chan struct{} + + closeOnce sync.Once + closeErr error + + ctx context.Context + config *jsonrpc.StreamConfig + } + + // {{ .WebSocketRequestOwner.Name }} identifies one method stream. closed records that + // Close was called so the stream cannot accept another request. + {{ .WebSocketRequestOwner.Name }} struct { + closed atomic.Bool + } + + // {{ .WebSocketPendingRequest.Name }} stores one request's context, timer, and function + // that receives its result or error. + {{ .WebSocketPendingRequest.Name }} struct { + owner *{{ .WebSocketRequestOwner.Name }} + ctx context.Context + timer *time.Timer + complete func(context.Context, *jsonrpc.RawResponse, error) + } + + // {{ .WebSocketMessage.Name }} keeps enough of an incoming JSON-RPC message to tell a + // server notification from a response, including an explicit null ID. + {{ .WebSocketMessage.Name }} struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpc.RawErrorResponse `json:"error,omitempty"` + ID json.RawMessage `json:"id"` + } +) + +// {{ .WebSocketClosedError.Name }} is returned after Close prevents a method +// stream from sending or receiving more messages. +var {{ .WebSocketClosedError.Name }} = errors.New("JSON-RPC WebSocket method stream is closed") + +// {{ .NewWebSocketConnection.Name }} returns a connection that uses config for timeouts and +// error reporting. It also starts readResponses, the only function that reads +// from ws. Socket errors use a context that remains valid after the method that +// opened the socket returns. +func {{ .NewWebSocketConnection.Name }}(ws *websocket.Conn, config *jsonrpc.StreamConfig) *{{ .WebSocketConnection.Name }} { + conn := &{{ .WebSocketConnection.Name }}{ + ws: ws, + pending: make(map[string]*{{ .WebSocketPendingRequest.Name }}), + done: make(chan struct{}), + ctx: context.Background(), + config: config, + } + go conn.readResponses() + return conn +} + +// getConn returns the open WebSocket connection. If no socket exists, it uses +// ctx to open one for all waiting callers. After the socket fails, later calls +// return that error instead of opening a new socket while earlier requests are +// still waiting for results. +func (c *{{ .ClientStructDeclaration.Name }}) getConn(ctx context.Context) (*{{ .WebSocketConnection.Name }}, error) { + for { + c.connMu.Lock() + if c.closed.Load() { + c.connMu.Unlock() + return nil, fmt.Errorf("JSON-RPC WebSocket client is closed") + } + if c.conn != nil { + conn := c.conn + c.connMu.Unlock() + if err := conn.terminalError(); err != nil { + return nil, err + } return conn, nil } - // Connection is dead, need new one - } - c.connMu.RUnlock() - - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() - - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil + if c.connecting != nil { + connecting := c.connecting + c.connMu.Unlock() + select { + case <-connecting: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } } - // Close the dead connection - c.conn.Close() + connecting := make(chan struct{}) + c.connecting = connecting + c.connMu.Unlock() + return c.connect(ctx, connecting) } - - // Convert scheme for WebSocket +} + +// connect uses ctx to open and configure one socket. It returns that socket to +// the caller and makes it available to other getConn callers, unless Close ran +// while the dial was in progress. +func (c *{{ .ClientStructDeclaration.Name }}) connect(ctx context.Context, connecting chan struct{}) (*{{ .WebSocketConnection.Name }}, error) { + wsScheme := "ws" if c.scheme == "https" { wsScheme = "wss" } - - // Find the WebSocket path from the service endpoints + {{- $found := false }} {{- range .Endpoints }} {{- range .Routes }} @@ -56,40 +131,365 @@ func (c *{{ .ClientStruct }}) getConn(ctx context.Context) (*websocket.Conn, err {{- if not $found }} url := wsScheme + "://" + c.host {{- end }} - + ws, _, err := c.dialer.DialContext(ctx, url, nil) if err != nil { + c.finishConnect(connecting, nil) return nil, goahttp.ErrRequestError("{{ .Service.Name }}", "connect", err) } - if c.configfn != nil { ws = c.configfn(ws, nil) } - - // Store the direct WebSocket connection - c.conn = ws - - return c.conn, nil -} -// Close closes the WebSocket connection and marks the client as closed -func (c *{{ .ClientStruct }}) Close() error { - if c.closed.Swap(true) { - return nil // Already closed + conn := {{ .NewWebSocketConnection.Name }}(ws, c.streamConfig) + if c.finishConnect(connecting, conn) { + return conn, nil } + err = fmt.Errorf("JSON-RPC WebSocket client closed while connecting") + if closeErr := conn.close(); closeErr != nil { + err = fmt.Errorf("%w; close new connection: %v", err, closeErr) + } + return nil, err +} +// finishConnect stores conn unless the client closed while the dial was in +// progress. It wakes every getConn caller waiting for the dial and returns +// whether conn was stored. +func (c *{{ .ClientStructDeclaration.Name }}) finishConnect(connecting chan struct{}, conn *{{ .WebSocketConnection.Name }}) bool { c.connMu.Lock() - defer c.connMu.Unlock() + accepted := !c.closed.Load() && conn != nil + if accepted { + c.conn = conn + } + c.connecting = nil + c.connMu.Unlock() + close(connecting) + return accepted +} + +// sendRequest stores the function passed in complete, writes request, and +// returns its new ID. It returns an error without writing if ctx is canceled, +// the method stream is closed, or the socket has failed. Its timer calls +// complete with a timeout error even if the caller never calls Recv. +func (c *{{ .WebSocketConnection.Name }}) sendRequest(ctx context.Context, request *jsonrpc.Request, owner *{{ .WebSocketRequestOwner.Name }}, complete func(context.Context, *jsonrpc.RawResponse, error)) (string, error) { + id := strconv.FormatUint(c.nextID.Add(1), 10) + request.ID = id + pending := &{{ .WebSocketPendingRequest.Name }}{ + owner: owner, + ctx: ctx, + complete: complete, + } + + c.writeMu.Lock() + select { + case <-ctx.Done(): + err := ctx.Err() + if owner.closed.Load() { + err = {{ .WebSocketClosedError.Name }} + } + c.writeMu.Unlock() + return "", err + default: + } + c.stateMu.Lock() + switch { + case c.err != nil: + err := c.err + c.stateMu.Unlock() + c.writeMu.Unlock() + return "", err + case owner.closed.Load(): + c.stateMu.Unlock() + c.writeMu.Unlock() + return "", {{ .WebSocketClosedError.Name }} + } + c.pending[id] = pending + pending.timer = time.AfterFunc(c.config.RequestTimeout, func() { + c.timeoutRequest(id) + }) + c.stateMu.Unlock() + err := c.ws.WriteJSON(request) + c.writeMu.Unlock() + if err == nil { + return id, nil + } + + c.removeRequest(id) + err = fmt.Errorf("failed to write JSON-RPC WebSocket request: %w", err) + c.fail(err) + return "", err +} - if c.conn != nil { - err := c.conn.Close() - c.conn = nil +// sendNotification waits for the current socket write to finish and then +// writes request without an ID. It returns an error if ctx is canceled, the +// method stream is closed, or the socket write fails. +func (c *{{ .WebSocketConnection.Name }}) sendNotification(ctx context.Context, request *jsonrpc.Request, owner *{{ .WebSocketRequestOwner.Name }}) error { + c.writeMu.Lock() + select { + case <-ctx.Done(): + err := ctx.Err() + if owner.closed.Load() { + err = {{ .WebSocketClosedError.Name }} + } + c.writeMu.Unlock() + return err + default: + } + c.stateMu.Lock() + switch { + case c.err != nil: + err := c.err + c.stateMu.Unlock() + c.writeMu.Unlock() + return err + case owner.closed.Load(): + c.stateMu.Unlock() + c.writeMu.Unlock() + return {{ .WebSocketClosedError.Name }} + } + c.stateMu.Unlock() + err := c.ws.WriteJSON(request) + c.writeMu.Unlock() + if err != nil { + err = fmt.Errorf("failed to write JSON-RPC WebSocket notification: %w", err) + c.fail(err) return err } return nil } -// IsClosed returns true if the client connection has been closed -func (c *{{ .ClientStruct }}) IsClosed() bool { +// readResponses reads every message from the shared WebSocket. No other +// function reads from that socket. It reports server notifications and uses +// each response ID to find the function that receives the request result. +func (c *{{ .WebSocketConnection.Name }}) readResponses() { + for { + var message {{ .WebSocketMessage.Name }} + if err := c.ws.ReadJSON(&message); err != nil { + c.fail(fmt.Errorf("failed to read JSON-RPC WebSocket message: %w", err)) + return + } + if message.Method != "" { + c.handleIncomingMethod(&message) + continue + } + c.handleIncomingResponse(&message) + } +} + +// handleIncomingMethod reports the name of a server notification. A message +// with an ID, including null, is a server request that this client does not +// support. +func (c *{{ .WebSocketConnection.Name }}) handleIncomingMethod(message *{{ .WebSocketMessage.Name }}) { + if len(message.ID) > 0 { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received unsupported JSON-RPC WebSocket server request %q", message.Method), nil) + return + } + c.handleError(c.ctx, jsonrpc.StreamErrorNotification, fmt.Errorf("received JSON-RPC WebSocket notification %q", message.Method), nil) +} + +// handleIncomingResponse checks the response ID and passes the unchanged +// result or error to the function stored under that ID. +func (c *{{ .WebSocketConnection.Name }}) handleIncomingResponse(message *{{ .WebSocketMessage.Name }}) { + response := &jsonrpc.RawResponse{ + JSONRPC: message.JSONRPC, + Result: message.Result, + Error: message.Error, + } + if len(message.ID) == 0 { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response without an ID"), response) + return + } + if string(message.ID) == "null" { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response with a null ID"), response) + return + } + if err := json.Unmarshal(message.ID, &response.ID); err != nil { + c.handleError(c.ctx, jsonrpc.StreamErrorParsing, fmt.Errorf("decode JSON-RPC WebSocket response ID: %w", err), response) + return + } + id := jsonrpc.IDToString(response.ID) + pending := c.removeRequest(id) + if pending == nil { + c.handleError(c.ctx, jsonrpc.StreamErrorOrphaned, fmt.Errorf("received JSON-RPC WebSocket response for unknown ID %q", id), response) + return + } + pending.complete(pending.ctx, response, nil) +} + +// timeoutRequest removes the request with id, reports its timeout, and calls +// the function waiting for its result even if the method stream has not called +// Recv. It does nothing if a response, cancellation, or close already removed +// the request. +func (c *{{ .WebSocketConnection.Name }}) timeoutRequest(id string) { + c.stateMu.Lock() + pending := c.pending[id] + if pending != nil { + delete(c.pending, id) + } + c.stateMu.Unlock() + if pending == nil { + return + } + err := fmt.Errorf("JSON-RPC WebSocket request timed out after %v", c.config.RequestTimeout) + c.handleError(pending.ctx, jsonrpc.StreamErrorTimeout, err, nil) + pending.complete(pending.ctx, nil, err) +} + +// removeRequest removes one request and stops its timer. It returns nil if a +// response, timeout, cancellation, or close already removed the request. +func (c *{{ .WebSocketConnection.Name }}) removeRequest(id string) *{{ .WebSocketPendingRequest.Name }} { + c.stateMu.Lock() + pending := c.pending[id] + if pending != nil { + delete(c.pending, id) + pending.timer.Stop() + } + c.stateMu.Unlock() + return pending +} + +// cancelRequest removes the request with id and passes err to the function +// waiting for its result. It returns whether it found and canceled the request. +func (c *{{ .WebSocketConnection.Name }}) cancelRequest(id string, err error) bool { + pending := c.removeRequest(id) + if pending == nil { + return false + } + pending.complete(pending.ctx, nil, err) + return true +} + +// closeOwner marks owner closed and passes {{ .WebSocketClosedError.Name }} to +// every request sent by that method stream. Requests from other method streams +// continue on the same socket. +func (c *{{ .WebSocketConnection.Name }}) closeOwner(owner *{{ .WebSocketRequestOwner.Name }}) { + c.stateMu.Lock() + if owner.closed.Swap(true) { + c.stateMu.Unlock() + return + } + var canceled []*{{ .WebSocketPendingRequest.Name }} + for id, pending := range c.pending { + if pending.owner == owner { + delete(c.pending, id) + pending.timer.Stop() + canceled = append(canceled, pending) + } + } + c.stateMu.Unlock() + for _, pending := range canceled { + pending.complete(pending.ctx, nil, {{ .WebSocketClosedError.Name }}) + } +} + +// terminalError returns the error that closed the connection. +func (c *{{ .WebSocketConnection.Name }}) terminalError() error { + c.stateMu.Lock() + defer c.stateMu.Unlock() + return c.err +} + +// fail records the first socket error, closes the socket to interrupt a blocked +// read or write, reports the error, and passes it to every function waiting for +// a request result. Later calls do nothing because the first failure already +// closed the socket. +func (c *{{ .WebSocketConnection.Name }}) fail(err error) { + pending, ended := c.beginEnd(err) + if !ended { + return + } + if closeErr := c.closeSocket(); closeErr != nil { + err = fmt.Errorf("%w; close JSON-RPC WebSocket: %v", err, closeErr) + } + c.finishEnd(err) + c.handleError(c.ctx, jsonrpc.StreamErrorConnection, err, nil) + for _, request := range pending { + request.complete(request.ctx, nil, err) + } +} + +// beginEnd records err and returns every request that was waiting for a +// response. Its boolean result is false if another call already recorded the +// connection error. The caller closes the socket and calls the waiting request +// functions after the shared request map is unlocked. +func (c *{{ .WebSocketConnection.Name }}) beginEnd(err error) ([]*{{ .WebSocketPendingRequest.Name }}, bool) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + if c.err != nil { + return nil, false + } + c.err = err + pending := make([]*{{ .WebSocketPendingRequest.Name }}, 0, len(c.pending)) + for id, request := range c.pending { + delete(c.pending, id) + request.timer.Stop() + pending = append(pending, request) + } + return pending, true +} + +// finishEnd replaces the connection error with err and closes done so Recv +// calls know that the socket has closed. +func (c *{{ .WebSocketConnection.Name }}) finishEnd(err error) { + c.stateMu.Lock() + c.err = err + close(c.done) + c.stateMu.Unlock() +} + +// closeSocket closes ws exactly once and returns the socket close error. It +// does not wait for a current WriteJSON call to finish, because closing the +// network connection must interrupt that blocked write. +func (c *{{ .WebSocketConnection.Name }}) closeSocket() error { + c.closeOnce.Do(func() { + c.closeErr = c.ws.Close() + }) + return c.closeErr +} + +// handleError passes errorType, err, and response to the configured error +// function. Request errors use the request context and socket errors use the +// connection context. Callers must finish changing the shared connection state +// first because user code may call back into the client. +func (c *{{ .WebSocketConnection.Name }}) handleError(ctx context.Context, errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { + if c.config.ErrorHandler != nil { + c.config.ErrorHandler(ctx, errorType, err, response) + } +} + +// close closes the shared socket, passes a connection-closed error to every +// function waiting for a request result, and returns the socket close error. +func (c *{{ .WebSocketConnection.Name }}) close() error { + err := fmt.Errorf("JSON-RPC WebSocket connection closed") + pending, ended := c.beginEnd(err) + if !ended { + return c.closeSocket() + } + closeErr := c.closeSocket() + c.finishEnd(err) + for _, request := range pending { + request.complete(request.ctx, nil, err) + } + return closeErr +} + +// Close rejects future client calls, closes the shared WebSocket, and returns +// the socket close error. +func (c *{{ .ClientStructDeclaration.Name }}) Close() error { + if c.closed.Swap(true) { + return nil + } + c.connMu.Lock() + conn := c.conn + c.conn = nil + c.connMu.Unlock() + if conn == nil { + return nil + } + return conn.close() +} + +// IsClosed reports whether Close has closed this client. +func (c *{{ .ClientStructDeclaration.Name }}) IsClosed() bool { return c.closed.Load() } diff --git a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl b/jsonrpc/codegen/templates/websocket_client_stream.go.tpl index eab73ce0b3..127d3216c8 100644 --- a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl +++ b/jsonrpc/codegen/templates/websocket_client_stream.go.tpl @@ -1,428 +1,281 @@ {{/* -websocket_client_stream.go.tpl generates JSON-RPC WebSocket streaming client implementations. - -This template creates stream types that handle direct WebSocket connections for JSON-RPC -streaming endpoints, providing: -- Direct WebSocket transport without intermediate wrappers -- Dual ID correlation (user payload ID + JSON-RPC request ID) -- Comprehensive error handling with user-configurable error handlers -- Generated decoder integration for consistent response parsing -- Thread-safe operations with proper lifecycle management - -Template variables: -- .VarName: Name of the generated stream struct -- .Endpoint.Method.Name: Name of the endpoint method -- .SendName/.SendTypeRef: Send method name and payload type (if stream accepts input) -- .RecvName/.RecvTypeRef: Receive method name and result type (if stream produces output) -- .Endpoint.ServiceVarName: Service name for JSON-RPC method naming - -The template handles three streaming patterns: -1. Client streaming (send-only): $hasSend && !$hasRecv -2. Server streaming (recv-only): !$hasSend && $hasRecv -3. Bidirectional streaming: $isBidirectional ($hasSend && $hasRecv) +This file writes one JSON-RPC method stream. One shared connection reads and +writes the socket and assigns request IDs. It finds the function waiting for +each response. This stream turns response fields into service results, returns +them in send order, and handles cancellation. */}} -{{ printf "%s implements the %s client stream with direct WebSocket handling." .VarName .Endpoint.Method.Name | comment }} {{- $hasRecv := and .RecvName .RecvTypeRef }} {{- $hasSend := .SendName }} {{- $isBidirectional := and $hasSend $hasRecv }} -type {{ .VarName }} struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*{{ .VarName }}PendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - {{- if $hasRecv }} - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies - {{- end }} -} +{{- $pendingType := .Pending.Name }} +{{- $resultType := .Result.Name }} +{{ printf "%s implements the %s client stream." .VarDeclaration.Name .Endpoint.Method.Name | comment }} +type ( + {{ .VarDeclaration.Name }} struct { + conn *{{ .Connection.Name }} + owner *{{ .RequestOwner.Name }} + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once -// Stream-specific types for {{ .VarName }} -type {{ .VarName }}PendingRequest struct { - userID string // User-provided payload ID - resultChan chan {{ .VarName }}StreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} + {{- if $hasRecv }} + decoder func(*http.Response) goahttp.Decoder + {{- end }} + {{- if $isBidirectional }} -type {{ .VarName }}StreamResult struct { -{{- if $hasRecv }} - result {{ .RecvTypeRef }} -{{- end }} - err error -} + sendMu sync.Mutex + pendingMu sync.Mutex + pending []*{{ $pendingType }} + pendingReady chan struct{} + {{- end }} + } + + {{- if $hasRecv }} + // {{ $pendingType }} stores the channel that receives the result or error + // for one request. The shared connection starts and stops its timer. + {{ $pendingType }} struct { + id string + resultChan chan {{ $resultType }} + } + + // {{ $resultType }} contains the decoded result or error returned by one + // request. + {{ $resultType }} struct { + result {{ .RecvTypeRef }} + err error + } + {{- end }} +) {{- if $hasSend }} -{{ printf "%s sends streaming data to the %s endpoint with dual ID correlation." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment .SendDesc }} +func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return s.{{ .SendName }}WithContext(s.ctx, v) } -{{ printf "%sWithContext sends streaming data to the %s endpoint with context." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}WithContext(ctx context.Context, v {{ .SendTypeRef }}) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - -{{- if $isBidirectional }} - // Honor user-provided ID or generate one - userID := "" -{{- if .SendTypeRef }} - {{- if .Endpoint.Payload }} - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - {{- end }} -{{- else }} - userID = s.generateUserID() -{{- end }} - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &{{ .VarName }}PendingRequest{ - userID: userID, - resultChan: make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request +{{ comment .SendWithContextDesc }} +func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}WithContext(ctx context.Context, v {{ .SendTypeRef }}) error { request := &jsonrpc.Request{ JSONRPC: "2.0", Method: "{{ .Endpoint.Method.Name }}", Params: v, - ID: &jsonrpcID, } -{{- else }} - // For payload-only streaming, use notification (fire-and-forget) - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - // No ID field for notifications + {{- if $isBidirectional }} + pending := &{{ $pendingType }}{ + resultChan: make(chan {{ $resultType }}, 1), } -{{- end }} - - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - + + s.sendMu.Lock() + id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { + s.completeResponse(ctx, pending, response, err) + }) + if err == nil { + pending.id = id + s.enqueuePending(pending) + } + s.sendMu.Unlock() if err != nil { -{{- if $isBidirectional }} - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() -{{- end }} - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) + return err } - return nil + {{- else }} + return s.conn.sendNotification(ctx, request, s.owner) + {{- end }} } {{- end }} {{- if $hasRecv }} -{{ printf "%s receives streaming data from the %s endpoint." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment .RecvDesc }} +func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return s.{{ .RecvName }}WithContext(s.ctx) } -{{ printf "%sWithContext receives streaming data from the %s endpoint with context." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}WithContext(ctx context.Context) ({{ .RecvTypeRef }}, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - -{{- if $isBidirectional }} - // Find the oldest pending request (FIFO ordering) - var oldestPending *{{ .VarName }}PendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call {{ .SendName }}() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") +{{ comment .RecvWithContextDesc }} +func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}WithContext(ctx context.Context) ({{ .RecvTypeRef }}, error) { + {{- if $isBidirectional }} + pending, err := s.nextPending(ctx) + if err != nil { + var zero {{ .RecvTypeRef }} + return zero, err } -{{- else }} - // For result-only streaming, make direct call - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - + return s.awaitPending(ctx, pending) + {{- else }} request := &jsonrpc.Request{ JSONRPC: "2.0", Method: "{{ .Endpoint.Method.Name }}", - Params: nil, - ID: &jsonrpcID, } - - // Create result channel for this request - resultChan := make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer) - pending := &{{ .VarName }}PendingRequest{ - userID: jsonrpcID, - resultChan: resultChan, - timeout: time.NewTimer(s.config.RequestTimeout), + pending := &{{ $pendingType }}{ + resultChan: make(chan {{ $resultType }}, 1), } - - s.pending.Store(jsonrpcID, pending) - defer func() { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - }() - - // Send request - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - + id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { + s.completeResponse(ctx, pending, response, err) + }) if err != nil { - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return nil, fmt.Errorf("failed to send request: %w", err) - } - - // Wait for response - select { - case result := <-resultChan: - return result.result, result.err - case <-pending.timeout.C: - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - case <-ctx.Done(): - return nil, ctx.Err() - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") + var zero {{ .RecvTypeRef }} + return zero, err } -{{- end }} + pending.id = id + return s.awaitPending(ctx, pending) + {{- end }} } -{{- end }} -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *{{ .VarName }}) responseHandler() { - defer close(s.done) - +// awaitPending waits for pending to receive a result or error. It returns the +// closed-stream error if Close runs, even when another cancellation is ready. +func (s *{{ .VarDeclaration.Name }}) awaitPending(ctx context.Context, pending *{{ $pendingType }}) ({{ .RecvTypeRef }}, error) { for { + if s.owner.closed.Load() { + var zero {{ .RecvTypeRef }} + return zero, {{ .ClosedError.Name }} + } select { + case result := <-pending.resultChan: + return result.result, s.methodStreamError(result.err) + case <-ctx.Done(): + err := s.methodStreamError(ctx.Err()) + s.conn.cancelRequest(pending.id, err) + var zero {{ .RecvTypeRef }} + return zero, err case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) + err := s.methodStreamError(s.ctx.Err()) + s.conn.cancelRequest(pending.id, err) + var zero {{ .RecvTypeRef }} + return zero, err + case <-s.conn.done: + var zero {{ .RecvTypeRef }} + return zero, s.methodStreamError(s.conn.terminalError()) } } } -func (s *{{ .VarName }}) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - var result {{ .VarName }}StreamResult - - if response.Error != nil { +// completeResponse turns response into this method's service result, or uses +// err when the request failed, and sends it to the Recv call waiting for pending. +func (s *{{ .VarDeclaration.Name }}) completeResponse(ctx context.Context, pending *{{ $pendingType }}, response *jsonrpc.RawResponse, err error) { + var result {{ $resultType }} + switch { + case err != nil: + result.err = err + case response.Error != nil: result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { -{{- if $hasRecv }} - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) + s.conn.handleError(ctx, jsonrpc.StreamErrorProtocol, response.Error, response) + default: + parsedResult, decodeErr := s.decodeResponse(response.Result) + if decodeErr != nil { + result.err = fmt.Errorf("failed to decode JSON-RPC WebSocket response: %w", decodeErr) + s.conn.handleError(ctx, jsonrpc.StreamErrorParsing, result.err, response) } else { {{- if .Endpoint.Result.IDAttribute }} - // Backfill the result ID from the envelope when missing {{- if .Endpoint.Result.IDAttributeRequired }} if parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { parsedResult.{{ .Endpoint.Result.IDAttribute }} = jsonrpc.IDToString(response.ID) } {{- else }} if parsedResult.{{ .Endpoint.Result.IDAttribute }} == nil || *parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.{{ .Endpoint.Result.IDAttribute }} = &idCopy + id := jsonrpc.IDToString(response.ID) + parsedResult.{{ .Endpoint.Result.IDAttribute }} = &id } {{- end }} {{- end }} result.result = parsedResult } -{{- end }} } - - // Non-blocking send to result channel + pending.resultChan <- result +} + +{{- if $isBidirectional }} +// enqueuePending adds pending to the requests waiting for Recv. It keeps their +// send order even when the server responds in a different order. +func (s *{{ .VarDeclaration.Name }}) enqueuePending(pending *{{ $pendingType }}) { + s.pendingMu.Lock() + s.pending = append(s.pending, pending) + if s.owner.closed.Load() { + s.pending = s.pending[:len(s.pending)-1] + s.pendingMu.Unlock() + return + } + s.pendingMu.Unlock() select { - case pending.resultChan <- result: + case s.pendingReady <- struct{}{}: default: - // Channel full - should not happen with buffer size 1 } } -// Helper methods -func (s *{{ .VarName }}) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) +// nextPending returns the first request sent by this method stream that has not +// yet been passed to Recv. It returns an error if the caller cancels, Close +// runs, or the socket fails first. +func (s *{{ .VarDeclaration.Name }}) nextPending(ctx context.Context) (*{{ $pendingType }}, error) { + for { + if s.owner.closed.Load() { + return nil, {{ .ClosedError.Name }} + } + s.pendingMu.Lock() + if len(s.pending) > 0 { + if s.owner.closed.Load() { + s.pendingMu.Unlock() + return nil, {{ .ClosedError.Name }} + } + pending := s.pending[0] + s.pending = s.pending[1:] + s.pendingMu.Unlock() + return pending, nil + } + s.pendingMu.Unlock() + select { + case <-s.pendingReady: + case <-ctx.Done(): + return nil, s.methodStreamError(ctx.Err()) + case <-s.ctx.Done(): + return nil, s.methodStreamError(s.ctx.Err()) + case <-s.conn.done: + return nil, s.methodStreamError(s.conn.terminalError()) + } + } } +{{- end }} -// handleError calls the user-provided error handler if available -func (s *{{ .VarName }}) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) +// methodStreamError returns the closed-stream error if Close has run. +// Otherwise it returns the supplied err unchanged. +func (s *{{ .VarDeclaration.Name }}) methodStreamError(err error) error { + if s.owner.closed.Load() { + return {{ .ClosedError.Name }} } + return err } - -{{- if $hasRecv }} -// decodeResponse decodes JSON-RPC response data using the user-provided decoder -func (s *{{ .VarName }}) decodeResponse(data json.RawMessage) ({{ .RecvTypeRef }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// decodeResponse reads data using this method's response format and returns the +// service result. +func (s *{{ .VarDeclaration.Name }}) decodeResponse(data json.RawMessage) ({{ .RecvTypeRef }}, error) { + {{- if .Endpoint.Method.ViewedResult }} + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return {{ viewedDecodeName .Endpoint.Method.Name }}(s.decoder, resp, data) + {{- else }} resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - - // Use user-provided decoder to decode the result (expects inner result JSON) dec := s.decoder(resp) var out {{ .RecvTypeRef }} if err := dec.Decode(&out); err != nil { - return nil, err + var zero {{ .RecvTypeRef }} + return zero, err } return out, nil + {{- end }} } {{- end }} -func (s *{{ .VarName }}) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *{{ .VarName }}) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *{{ .VarName }}) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- {{ .VarName }}StreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -{{ printf "Close closes the stream and cleans up resources." | comment }} -func (s *{{ .VarName }}) Close() error { - var err error +{{ printf "Close closes the %s method stream without closing the WebSocket shared by other methods." .Endpoint.Method.Name | comment }} +func (s *{{ .VarDeclaration.Name }}) Close() error { s.closeOnce.Do(func() { + s.conn.closeOwner(s.owner) + {{- if $isBidirectional }} + s.pendingMu.Lock() + s.pending = nil + s.pendingMu.Unlock() + {{- end }} s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } }) - return err + return nil } diff --git a/jsonrpc/codegen/templates/websocket_server_close.go.tpl b/jsonrpc/codegen/templates/websocket_server_close.go.tpl index 84741806db..f26ae52610 100644 --- a/jsonrpc/codegen/templates/websocket_server_close.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_close.go.tpl @@ -1,15 +1,16 @@ -{{ printf "Close closes the %s service websocket connection." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( +{{ printf "Close asks the %s client to close normally, closes the WebSocket, and returns errors from either operation." .Service.Name | comment }} +func (s *{{ websocketServerStreamName }}) Close() error { + controlErr := s.conn.WriteControl( websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second), - ); err != nil { - return err + ) + if controlErr != nil { + controlErr = fmt.Errorf("write normal WebSocket close message: %w", controlErr) + } + closeErr := s.conn.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close WebSocket connection: %w", closeErr) } - return s.conn.Close() + return errors.Join(controlErr, closeErr) } diff --git a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl b/jsonrpc/codegen/templates/websocket_server_handler.go.tpl index cc5ad4c51a..d5415b8f31 100644 --- a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_handler.go.tpl @@ -1,5 +1,5 @@ // ServeHTTP handles WebSocket JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithCancel(r.Context()) conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { @@ -12,7 +12,7 @@ func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) } defer conn.Close() - stream := &{{ lowerInitial .Service.StructName }}Stream{ + stream := &{{ websocketServerStreamName }}{ {{- range .Endpoints }} {{ lowerInitial .Method.VarName }}: s.{{ lowerInitial .Method.VarName }}, {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} diff --git a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl b/jsonrpc/codegen/templates/websocket_server_recv.go.tpl index c4b27eeaf2..91757fff39 100644 --- a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_recv.go.tpl @@ -1,25 +1,24 @@ {{ printf "Recv reads JSON-RPC requests from the %s service stream." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Recv(ctx context.Context) error { +func (s *{{ websocketServerStreamName }}) Recv(ctx context.Context) error { var req jsonrpc.RawRequest if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully + // Return an unexpected connection close because no later request can be read. if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection return err } - // JSON parse errors - send Parse Error response and continue + // Report every other read failure as a JSON-RPC parse error. if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken + // Return when the parse-error response cannot be written to the client. return fmt.Errorf("failed to send parse error: %w", err) } - // Continue processing after sending parse error + // The next Recv call reads the next request from this connection. return nil } return s.processRequest(ctx, &req) } -func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { +func (s *{{ websocketServerStreamName }}) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { if req.JSONRPC != "2.0" { if req.HasID { return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) @@ -38,7 +37,7 @@ func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx contex {{- range .Endpoints }} case {{ printf "%q" .Method.Name }}: {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // {{ if eq .Method.ServerStream.Kind 3 }}Server{{ else }}Bidirectional{{ end }} streaming: decode payload and create stream wrapper + // Decode the request fields for this {{ if eq .Method.ServerStream.Kind 3 }}server-streaming{{ else }}bidirectional-streaming{{ end }} call. {{- if .Payload.Ref }} payload, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) {{- else }} @@ -47,12 +46,17 @@ func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx contex if err != nil { return fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err) } - // Create wrapper that implements the method-specific stream interface - streamWrapper := &{{ lowerInitial .Method.VarName }}StreamWrapper{ + // Give the service a stream that writes responses on this connection + // with the ID from this request. + streamWrapper := &{{ websocketWrapperName .Method.Name }}{ stream: s, requestID: req.ID, + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + view: {{ printf "%q" .Result.View }}, + {{- end }} } - // Call the endpoint with payload and stream wrapper + // Pass the decoded payload, when present, and this request's stream + // to the service. endpointInput := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ {{- if .Payload.Ref }} Payload: payload.({{ .Payload.Ref }}), @@ -60,23 +64,24 @@ func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx contex Stream: streamWrapper, } if _, err := s.{{ lowerInitial .Method.VarName }}Endpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response + // Send the service error to callers that supplied a request ID. if req.HasID { - // Send error response to client if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { return fmt.Errorf("failed to send error response: %w", sendErr) } - // Continue processing other requests + // The error response completes this request. The next Recv call + // reads another request from the same connection. return nil } - // For notifications (no ID), just log and continue + // Notifications have no response, so finish this request without + // writing to the connection. return nil } return nil {{- else }} res, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) if err != nil { - // For non-streaming, send JSON-RPC error if request has an ID; otherwise continue + // Send the call error only when the caller supplied a request ID. if req.HasID { if sendErr := s.SendError(ctx, req.ID, err); sendErr != nil { return fmt.Errorf("failed to send error response: %w", sendErr) @@ -84,7 +89,7 @@ func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx contex } return nil } - // Only send a response if the request has an ID (i.e., it's not a notification) + // A notification has no request ID and receives no response. if req.HasID { if res == nil { return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) @@ -107,4 +112,3 @@ func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx contex return nil } } - diff --git a/jsonrpc/codegen/templates/websocket_server_send.go.tpl b/jsonrpc/codegen/templates/websocket_server_send.go.tpl index 80185bfaa5..f61324f8f1 100644 --- a/jsonrpc/codegen/templates/websocket_server_send.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_send.go.tpl @@ -1,31 +1,41 @@ {{- range .Endpoints }} {{- if .Result.Ref }} {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Notification(ctx context.Context, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} +func (s *{{ websocketServerStreamName }}) Send{{ .Method.VarName }}Notification(ctx context.Context, result {{ .Result.Ref }}{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, view string{{ end }}) error { + {{- if .Method.ViewedResult }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err + } + {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) {{- else }} body := result {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeNotification({{ printf "%q" .Method.Name }}, body)) + return s.writeJSON(jsonrpc.MakeNotification({{ printf "%q" .Method.Name }}, body)) } {{ printf "Send%sResponse sends a JSON-RPC response for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Response(ctx context.Context, id any, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} +func (s *{{ websocketServerStreamName }}) Send{{ .Method.VarName }}Response(ctx context.Context, id any, result {{ .Result.Ref }}{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, view string{{ end }}) error { + {{- if .Method.ViewedResult }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err + } + {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) {{- else }} body := result {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) + return s.writeJSON(jsonrpc.MakeSuccessResponse(id, body)) } {{- end }} {{- end }} {{ printf "SendError streams JSON-RPC errors." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) SendError(ctx context.Context, id any, err error) error { - {{- if allErrors . }} +func (s *{{ websocketServerStreamName }}) SendError(ctx context.Context, id any, err error) error { + {{- if allErrors .JSONRPCServiceSnapshot }} var en goa.GoaErrorNamer if !errors.As(err, &en) { code := jsonrpc.InternalError @@ -35,7 +45,7 @@ func (s *{{ lowerInitial $.Service.StructName }}Stream) SendError(ctx context.Co return s.sendError(ctx, id, code, err.Error(), nil) } switch en.GoaErrorName() { - {{- range allErrors . }} + {{- range allErrors .JSONRPCServiceSnapshot }} case {{ printf "%q" .Name }}: {{- with .Response}} return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) @@ -59,17 +69,24 @@ func (s *{{ lowerInitial $.Service.StructName }}Stream) SendError(ctx context.Co } {{ printf "send writes a JSON-RPC response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) send(id any, method string, result any) error { +func (s *{{ websocketServerStreamName }}) send(id any, method string, result any) error { // If there's no ID, send as a notification instead of a response // A JSON-RPC result with no ID is invalid per the spec if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) + return s.writeJSON(jsonrpc.MakeNotification(method, result)) } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) + return s.writeJSON(jsonrpc.MakeSuccessResponse(id, result)) } {{ printf "sendError sends a JSON-RPC error response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { +func (s *{{ websocketServerStreamName }}) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) + return s.writeJSON(response) +} + +{{ printf "writeJSON waits for the current socket write to finish, then writes one JSON-RPC message." | comment }} +func (s *{{ websocketServerStreamName }}) writeJSON(message any) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.conn.WriteJSON(message) } diff --git a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream.go.tpl index cc1470b656..1b8b59dd2a 100644 --- a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_stream.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%sStream implements the Stream interface." (lowerInitial .Service.StructName) | comment }} -type {{ lowerInitial .Service.StructName }}Stream struct { +{{ printf "%s implements the Stream interface." (websocketServerStreamName) | comment }} +type {{ websocketServerStreamName }} struct { {{- range .Endpoints }} {{ printf "%s decodes requests for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) @@ -16,4 +16,6 @@ type {{ lowerInitial .Service.StructName }}Stream struct { r *http.Request {{ comment "conn is the underlying websocket connection." }} conn *websocket.Conn + {{ comment "writeMu allows only one caller at a time to write a message to conn." }} + writeMu sync.Mutex } diff --git a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl index 0492fc1415..01a37a5f61 100644 --- a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl +++ b/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl @@ -1,29 +1,49 @@ {{- range .Endpoints }} {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} -// {{ lowerInitial .Method.VarName }}StreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. -type {{ lowerInitial .Method.VarName }}StreamWrapper struct { - stream *{{ lowerInitial $.Service.StructName }}Stream +// {{ websocketWrapperName .Method.Name }} gives this method its request ID and selected result view. +type {{ websocketWrapperName .Method.Name }} struct { + stream *{{ websocketServerStreamName }} requestID any // Store the JSON-RPC request ID for responses + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + viewMu sync.RWMutex + view string + {{- end }} +} + +{{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} +// SetView selects the result view used by later sends for this request. +func (w *{{ websocketWrapperName .Method.Name }}) SetView(view string) { + w.viewMu.Lock() + w.view = view + w.viewMu.Unlock() +} + +// selectedView returns the result view selected for this request. +func (w *{{ websocketWrapperName .Method.Name }}) selectedView() string { + w.viewMu.RLock() + defer w.viewMu.RUnlock() + return w.view } +{{- end }} // SendNotification sends a notification to the client (no response expected). -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendNotification(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Notification(ctx, res) +func (w *{{ websocketWrapperName .Method.Name }}) SendNotification(ctx context.Context, res {{ .Result.Ref }}) error { + return w.stream.Send{{ .Method.VarName }}Notification(ctx, res{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, w.selectedView(){{ end }}) } // SendResponse sends a response to the client for the original request. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendResponse(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Response(ctx, w.requestID, res) +func (w *{{ websocketWrapperName .Method.Name }}) SendResponse(ctx context.Context, res {{ .Result.Ref }}) error { + return w.stream.Send{{ .Method.VarName }}Response(ctx, w.requestID, res{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, w.selectedView(){{ end }}) } // SendError sends an error response to the client. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendError(ctx context.Context, err error) error { +func (w *{{ websocketWrapperName .Method.Name }}) SendError(ctx context.Context, err error) error { return w.stream.SendError(ctx, w.requestID, err) } // Close closes the underlying JSON-RPC stream. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) Close() error { +func (w *{{ websocketWrapperName .Method.Name }}) Close() error { return w.stream.Close() } {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl b/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl index b5f91fa648..c1e96500d9 100644 --- a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl +++ b/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl @@ -1,13 +1,13 @@ -// Stream error types for comprehensive error reporting -type StreamErrorType int +{{ printf "%s identifies the kind of WebSocket stream error." .Type.Name | comment }} +type {{ .Type.Name }} int const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout + {{ .Connection.Name }} {{ .Type.Name }} = iota // The WebSocket connection failed. + {{ .Protocol.Name }} // The JSON-RPC message was invalid. + {{ .Parsing.Name }} // The response could not be read. + {{ .Orphaned.Name }} // The response matched no request. + {{ .Timeout.Name }} // The request waited too long. ) -// StreamErrorHandler allows users to handle stream errors -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) +{{ printf "%s receives WebSocket stream errors." .Handler.Name | comment }} +type {{ .Handler.Name }} func(ctx context.Context, errorType {{ .Type.Name }}, err error, response *jsonrpc.RawResponse) diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden index a7face3f7f..91e52fcc27 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden @@ -1,20 +1,20 @@ // StreamServerStream implements the jsonrpcsseobjectservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response + // requestID identifies the request in the final response. requestID any - // closed indicates if the stream has been closed via SendAndClose + // closed records whether SendAndClose has written the final response. closed bool - // mu protects the closed flag + // mu protects closed and view while service code sends results. mu sync.Mutex } // Send sends a JSON-RPC notification to the client. // Notifications do not expect a response from the client. func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is closed + // Reject a send after SendAndClose wrote the final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -22,22 +22,22 @@ func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectser } s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(*jsonrpcsseobjectservice.StreamResult) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - // Convert to response body type for proper JSON encoding + // Build the JSON body declared for this service result. body := NewStreamResponseBody(result) - // Send as notification (no ID) + // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } // SendAndClose sends a final JSON-RPC response to the client and closes the @@ -46,7 +46,7 @@ func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectser // ID field populated. // After calling this method, no more events can be sent on this stream. func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is already closed + // Reject a second final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -55,37 +55,37 @@ func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsseo s.closed = true s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(*jsonrpcsseobjectservice.StreamResult) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - // Determine the ID to use for the response + // Start with the ID of the request that opened this stream. var id any = s.requestID if result.ID != nil && *result.ID != "" { - // Use the ID from the result if provided + // Use the result ID when the service supplied one. id = *result.ID - // Clear the ID field so it's not duplicated in the result + // Remove the ID from the result body because the response already contains it. result.ID = nil } - // Convert to response body type for proper JSON encoding + // Build the JSON body declared for this service result. body := NewStreamResponseBody(result) - // Send as response with ID + // Write the final response with its request ID. message := map[string]any{ "jsonrpc": "2.0", "id": id, "result": body, } - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "response", message) } // SendError sends a JSON-RPC error response. func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error + // Report request validation failures as invalid parameters and all other + // failures as internal errors. code := jsonrpc.InternalError if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden index b788f135fe..5c9289caa3 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden @@ -1,20 +1,20 @@ // StreamServerStream implements the jsonrpcssestringservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response + // requestID identifies the request in the final response. requestID any - // closed indicates if the stream has been closed via SendAndClose + // closed records whether SendAndClose has written the final response. closed bool - // mu protects the closed flag + // mu protects closed and view while service code sends results. mu sync.Mutex } // Send sends a JSON-RPC notification to the client. // Notifications do not expect a response from the client. func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is closed + // Reject a send after SendAndClose wrote the final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -22,21 +22,21 @@ func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringser } s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(string) if !ok { return fmt.Errorf("unexpected event type: %T", event) } body := result - // Send as notification (no ID) + // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } // SendAndClose sends a final JSON-RPC response to the client and closes the @@ -45,7 +45,7 @@ func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringser // ID field populated. // After calling this method, no more events can be sent on this stream. func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is already closed + // Reject a second final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -54,30 +54,30 @@ func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsses s.closed = true s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(string) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - // Determine the ID to use for the response + // Start with the ID of the request that opened this stream. var id any = s.requestID body := result - // Send as response with ID + // Write the final response with its request ID. message := map[string]any{ "jsonrpc": "2.0", "id": id, "result": body, } - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "response", message) } // SendError sends a JSON-RPC error response. func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error + // Report request validation failures as invalid parameters and all other + // failures as internal errors. code := jsonrpc.InternalError if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden index 854d337caa..275d9c1f24 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Calc service servers. +// NewClient creates HTTP clients for all the Calc service servers. func NewClient( scheme string, host string, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index af5082e93a..dfaaa043e2 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -95,7 +95,7 @@ func NewAddResultOK(body *AddResponseBody) *calc.AddResult { // NewAddOverflow builds a Calc service add endpoint overflow error. func NewAddOverflow(body *AddOverflowResponseBody) *goa.ServiceError { - v := &goa.ServiceError{ + v := &calc.Error{ Name: *body.Name, ID: *body.ID, Message: *body.Message, @@ -129,7 +129,7 @@ func ValidateAddResponseBody(body *AddResponseBody) (err error) { } // ValidateAddOverflowResponseBody runs the validations defined on -// add_overflow_response_body +// AddOverflowResponseBody func ValidateAddOverflowResponseBody(body *AddOverflowResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden index 652315b565..91c1a38d80 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden @@ -61,8 +61,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP writes one JSON-RPC response for each request. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -185,7 +185,7 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter joins the responses written for one JSON-RPC batch request. type batchWriter struct { io.Writer header http.Header @@ -219,7 +219,7 @@ func (rb *batchWriter) Write(data []byte) (int, error) { // Mount configures the mux to serve the JSON-RPC Calc service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // Every method in this server writes one ordinary JSON-RPC response. mux.Handle("POST", "/rpc", h.ServeHTTP) } @@ -251,7 +251,7 @@ func NewAddHandler( } encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -283,7 +283,7 @@ func NewAddHandler( encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil @@ -307,7 +307,7 @@ func NewAddHandler( } // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewAddResponseBody(res.(*calc.AddResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -351,7 +351,7 @@ func NewPingHandler( encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil @@ -370,7 +370,7 @@ func NewPingHandler( } // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewPingResponseBody(res.(*calc.PingResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -403,7 +403,7 @@ func NewLogHandler( } encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -434,7 +434,7 @@ func NewLogHandler( encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil @@ -454,14 +454,14 @@ func NewLogHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden index b0c1f21da4..a3c79325c8 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden @@ -9,7 +9,11 @@ package client import ( "context" + "encoding/json" + "errors" + "fmt" "net/http" + "strconv" "sync" "sync/atomic" "time" @@ -35,15 +39,17 @@ type Client struct { dialer goahttp.Dialer configfn goahttp.ConnConfigureFunc - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool + connMu sync.Mutex + conn *websocketClientConn + connecting chan struct{} + closed atomic.Bool - // Stream configuration (shared by all WebSocket streams) + // streamConfig sets request timeouts and the function called when a + // WebSocket request or connection fails. streamConfig *jsonrpc.StreamConfig } -// NewClient instantiates HTTP clients for all the Chat service servers. +// NewClient creates HTTP clients for all the Chat service servers. func NewClient( scheme string, host string, @@ -75,104 +81,503 @@ func NewClient( // echo method. func (c *Client) Echo() goa.Endpoint { return func(ctx context.Context, v any) (any, error) { - // For WebSocket, pass the base decoder to the stream and decode inner results + // The method stream uses the client response reader for each WebSocket result. decodeResponse := c.decoder - // Get direct WebSocket connection - ws, err := c.getConn(ctx) + conn, err := c.getConn(ctx) if err != nil { return nil, err } - // Create context with cancellation for the stream + // Closing the method stream cancels this context. streamCtx, cancel := context.WithCancel(ctx) - // Create the stream with direct WebSocket handling stream := &EchoClientStream{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, - decoder: decodeResponse, + conn: conn, + owner: &websocketRequestOwner{}, + ctx: streamCtx, + cancel: cancel, + pendingReady: make(chan struct{}, 1), + decoder: decodeResponse, } - // Start background response handler - go stream.responseHandler() - return stream, nil } } -// getConn returns the current WebSocket connection or creates a new one -func (c *Client) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() - return conn, nil - } - // Connection is dead, need new one +type ( + // websocketClientConn keeps the socket, the next request ID, and the + // functions called when waiting requests receive a result or error. + websocketClientConn struct { + ws *websocket.Conn + + writeMu sync.Mutex + nextID atomic.Uint64 + + stateMu sync.Mutex + pending map[string]*websocketPendingRequest + err error + done chan struct{} + + closeOnce sync.Once + closeErr error + + ctx context.Context + config *jsonrpc.StreamConfig } - c.connMu.RUnlock() - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() + // websocketRequestOwner identifies one method stream. closed records that + // Close was called so the stream cannot accept another request. + websocketRequestOwner struct { + closed atomic.Bool + } - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil + // websocketPendingRequest stores one request's context, timer, and function + // that receives its result or error. + websocketPendingRequest struct { + owner *websocketRequestOwner + ctx context.Context + timer *time.Timer + complete func(context.Context, *jsonrpc.RawResponse, error) + } + + // websocketMessage keeps enough of an incoming JSON-RPC message to tell a + // server notification from a response, including an explicit null ID. + websocketMessage struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpc.RawErrorResponse `json:"error,omitempty"` + ID json.RawMessage `json:"id"` + } +) + +// errWebsocketMethodStreamClosed is returned after Close prevents a method +// stream from sending or receiving more messages. +var errWebsocketMethodStreamClosed = errors.New("JSON-RPC WebSocket method stream is closed") + +// newWebsocketClientConn returns a connection that uses config for timeouts and +// error reporting. It also starts readResponses, the only function that reads +// from ws. Socket errors use a context that remains valid after the method that +// opened the socket returns. +func newWebsocketClientConn(ws *websocket.Conn, config *jsonrpc.StreamConfig) *websocketClientConn { + conn := &websocketClientConn{ + ws: ws, + pending: make(map[string]*websocketPendingRequest), + done: make(chan struct{}), + ctx: context.Background(), + config: config, + } + go conn.readResponses() + return conn +} + +// getConn returns the open WebSocket connection. If no socket exists, it uses +// ctx to open one for all waiting callers. After the socket fails, later calls +// return that error instead of opening a new socket while earlier requests are +// still waiting for results. +func (c *Client) getConn(ctx context.Context) (*websocketClientConn, error) { + for { + c.connMu.Lock() + if c.closed.Load() { + c.connMu.Unlock() + return nil, fmt.Errorf("JSON-RPC WebSocket client is closed") + } + if c.conn != nil { + conn := c.conn + c.connMu.Unlock() + if err := conn.terminalError(); err != nil { + return nil, err + } + return conn, nil } - // Close the dead connection - c.conn.Close() + if c.connecting != nil { + connecting := c.connecting + c.connMu.Unlock() + select { + case <-connecting: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + connecting := make(chan struct{}) + c.connecting = connecting + c.connMu.Unlock() + return c.connect(ctx, connecting) } +} + +// connect uses ctx to open and configure one socket. It returns that socket to +// the caller and makes it available to other getConn callers, unless Close ran +// while the dial was in progress. +func (c *Client) connect(ctx context.Context, connecting chan struct{}) (*websocketClientConn, error) { - // Convert scheme for WebSocket wsScheme := "ws" if c.scheme == "https" { wsScheme = "wss" } - - // Find the WebSocket path from the service endpoints url := wsScheme + "://" + c.host + "/ws/ws" ws, _, err := c.dialer.DialContext(ctx, url, nil) if err != nil { + c.finishConnect(connecting, nil) return nil, goahttp.ErrRequestError("Chat", "connect", err) } - if c.configfn != nil { ws = c.configfn(ws, nil) } - // Store the direct WebSocket connection - c.conn = ws + conn := newWebsocketClientConn(ws, c.streamConfig) + if c.finishConnect(connecting, conn) { + return conn, nil + } + err = fmt.Errorf("JSON-RPC WebSocket client closed while connecting") + if closeErr := conn.close(); closeErr != nil { + err = fmt.Errorf("%w; close new connection: %v", err, closeErr) + } + return nil, err +} - return c.conn, nil +// finishConnect stores conn unless the client closed while the dial was in +// progress. It wakes every getConn caller waiting for the dial and returns +// whether conn was stored. +func (c *Client) finishConnect(connecting chan struct{}, conn *websocketClientConn) bool { + c.connMu.Lock() + accepted := !c.closed.Load() && conn != nil + if accepted { + c.conn = conn + } + c.connecting = nil + c.connMu.Unlock() + close(connecting) + return accepted } -// Close closes the WebSocket connection and marks the client as closed -func (c *Client) Close() error { - if c.closed.Swap(true) { - return nil // Already closed +// sendRequest stores the function passed in complete, writes request, and +// returns its new ID. It returns an error without writing if ctx is canceled, +// the method stream is closed, or the socket has failed. Its timer calls +// complete with a timeout error even if the caller never calls Recv. +func (c *websocketClientConn) sendRequest(ctx context.Context, request *jsonrpc.Request, owner *websocketRequestOwner, complete func(context.Context, *jsonrpc.RawResponse, error)) (string, error) { + id := strconv.FormatUint(c.nextID.Add(1), 10) + request.ID = id + pending := &websocketPendingRequest{ + owner: owner, + ctx: ctx, + complete: complete, } - c.connMu.Lock() - defer c.connMu.Unlock() + c.writeMu.Lock() + select { + case <-ctx.Done(): + err := ctx.Err() + if owner.closed.Load() { + err = errWebsocketMethodStreamClosed + } + c.writeMu.Unlock() + return "", err + default: + } + c.stateMu.Lock() + switch { + case c.err != nil: + err := c.err + c.stateMu.Unlock() + c.writeMu.Unlock() + return "", err + case owner.closed.Load(): + c.stateMu.Unlock() + c.writeMu.Unlock() + return "", errWebsocketMethodStreamClosed + } + c.pending[id] = pending + pending.timer = time.AfterFunc(c.config.RequestTimeout, func() { + c.timeoutRequest(id) + }) + c.stateMu.Unlock() + err := c.ws.WriteJSON(request) + c.writeMu.Unlock() + if err == nil { + return id, nil + } - if c.conn != nil { - err := c.conn.Close() - c.conn = nil + c.removeRequest(id) + err = fmt.Errorf("failed to write JSON-RPC WebSocket request: %w", err) + c.fail(err) + return "", err +} + +// sendNotification waits for the current socket write to finish and then +// writes request without an ID. It returns an error if ctx is canceled, the +// method stream is closed, or the socket write fails. +func (c *websocketClientConn) sendNotification(ctx context.Context, request *jsonrpc.Request, owner *websocketRequestOwner) error { + c.writeMu.Lock() + select { + case <-ctx.Done(): + err := ctx.Err() + if owner.closed.Load() { + err = errWebsocketMethodStreamClosed + } + c.writeMu.Unlock() + return err + default: + } + c.stateMu.Lock() + switch { + case c.err != nil: + err := c.err + c.stateMu.Unlock() + c.writeMu.Unlock() + return err + case owner.closed.Load(): + c.stateMu.Unlock() + c.writeMu.Unlock() + return errWebsocketMethodStreamClosed + } + c.stateMu.Unlock() + err := c.ws.WriteJSON(request) + c.writeMu.Unlock() + if err != nil { + err = fmt.Errorf("failed to write JSON-RPC WebSocket notification: %w", err) + c.fail(err) return err } return nil } -// IsClosed returns true if the client connection has been closed +// readResponses reads every message from the shared WebSocket. No other +// function reads from that socket. It reports server notifications and uses +// each response ID to find the function that receives the request result. +func (c *websocketClientConn) readResponses() { + for { + var message websocketMessage + if err := c.ws.ReadJSON(&message); err != nil { + c.fail(fmt.Errorf("failed to read JSON-RPC WebSocket message: %w", err)) + return + } + if message.Method != "" { + c.handleIncomingMethod(&message) + continue + } + c.handleIncomingResponse(&message) + } +} + +// handleIncomingMethod reports the name of a server notification. A message +// with an ID, including null, is a server request that this client does not +// support. +func (c *websocketClientConn) handleIncomingMethod(message *websocketMessage) { + if len(message.ID) > 0 { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received unsupported JSON-RPC WebSocket server request %q", message.Method), nil) + return + } + c.handleError(c.ctx, jsonrpc.StreamErrorNotification, fmt.Errorf("received JSON-RPC WebSocket notification %q", message.Method), nil) +} + +// handleIncomingResponse checks the response ID and passes the unchanged +// result or error to the function stored under that ID. +func (c *websocketClientConn) handleIncomingResponse(message *websocketMessage) { + response := &jsonrpc.RawResponse{ + JSONRPC: message.JSONRPC, + Result: message.Result, + Error: message.Error, + } + if len(message.ID) == 0 { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response without an ID"), response) + return + } + if string(message.ID) == "null" { + c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response with a null ID"), response) + return + } + if err := json.Unmarshal(message.ID, &response.ID); err != nil { + c.handleError(c.ctx, jsonrpc.StreamErrorParsing, fmt.Errorf("decode JSON-RPC WebSocket response ID: %w", err), response) + return + } + id := jsonrpc.IDToString(response.ID) + pending := c.removeRequest(id) + if pending == nil { + c.handleError(c.ctx, jsonrpc.StreamErrorOrphaned, fmt.Errorf("received JSON-RPC WebSocket response for unknown ID %q", id), response) + return + } + pending.complete(pending.ctx, response, nil) +} + +// timeoutRequest removes the request with id, reports its timeout, and calls +// the function waiting for its result even if the method stream has not called +// Recv. It does nothing if a response, cancellation, or close already removed +// the request. +func (c *websocketClientConn) timeoutRequest(id string) { + c.stateMu.Lock() + pending := c.pending[id] + if pending != nil { + delete(c.pending, id) + } + c.stateMu.Unlock() + if pending == nil { + return + } + err := fmt.Errorf("JSON-RPC WebSocket request timed out after %v", c.config.RequestTimeout) + c.handleError(pending.ctx, jsonrpc.StreamErrorTimeout, err, nil) + pending.complete(pending.ctx, nil, err) +} + +// removeRequest removes one request and stops its timer. It returns nil if a +// response, timeout, cancellation, or close already removed the request. +func (c *websocketClientConn) removeRequest(id string) *websocketPendingRequest { + c.stateMu.Lock() + pending := c.pending[id] + if pending != nil { + delete(c.pending, id) + pending.timer.Stop() + } + c.stateMu.Unlock() + return pending +} + +// cancelRequest removes the request with id and passes err to the function +// waiting for its result. It returns whether it found and canceled the request. +func (c *websocketClientConn) cancelRequest(id string, err error) bool { + pending := c.removeRequest(id) + if pending == nil { + return false + } + pending.complete(pending.ctx, nil, err) + return true +} + +// closeOwner marks owner closed and passes errWebsocketMethodStreamClosed to +// every request sent by that method stream. Requests from other method streams +// continue on the same socket. +func (c *websocketClientConn) closeOwner(owner *websocketRequestOwner) { + c.stateMu.Lock() + if owner.closed.Swap(true) { + c.stateMu.Unlock() + return + } + var canceled []*websocketPendingRequest + for id, pending := range c.pending { + if pending.owner == owner { + delete(c.pending, id) + pending.timer.Stop() + canceled = append(canceled, pending) + } + } + c.stateMu.Unlock() + for _, pending := range canceled { + pending.complete(pending.ctx, nil, errWebsocketMethodStreamClosed) + } +} + +// terminalError returns the error that closed the connection. +func (c *websocketClientConn) terminalError() error { + c.stateMu.Lock() + defer c.stateMu.Unlock() + return c.err +} + +// fail records the first socket error, closes the socket to interrupt a blocked +// read or write, reports the error, and passes it to every function waiting for +// a request result. Later calls do nothing because the first failure already +// closed the socket. +func (c *websocketClientConn) fail(err error) { + pending, ended := c.beginEnd(err) + if !ended { + return + } + if closeErr := c.closeSocket(); closeErr != nil { + err = fmt.Errorf("%w; close JSON-RPC WebSocket: %v", err, closeErr) + } + c.finishEnd(err) + c.handleError(c.ctx, jsonrpc.StreamErrorConnection, err, nil) + for _, request := range pending { + request.complete(request.ctx, nil, err) + } +} + +// beginEnd records err and returns every request that was waiting for a +// response. Its boolean result is false if another call already recorded the +// connection error. The caller closes the socket and calls the waiting request +// functions after the shared request map is unlocked. +func (c *websocketClientConn) beginEnd(err error) ([]*websocketPendingRequest, bool) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + if c.err != nil { + return nil, false + } + c.err = err + pending := make([]*websocketPendingRequest, 0, len(c.pending)) + for id, request := range c.pending { + delete(c.pending, id) + request.timer.Stop() + pending = append(pending, request) + } + return pending, true +} + +// finishEnd replaces the connection error with err and closes done so Recv +// calls know that the socket has closed. +func (c *websocketClientConn) finishEnd(err error) { + c.stateMu.Lock() + c.err = err + close(c.done) + c.stateMu.Unlock() +} + +// closeSocket closes ws exactly once and returns the socket close error. It +// does not wait for a current WriteJSON call to finish, because closing the +// network connection must interrupt that blocked write. +func (c *websocketClientConn) closeSocket() error { + c.closeOnce.Do(func() { + c.closeErr = c.ws.Close() + }) + return c.closeErr +} + +// handleError passes errorType, err, and response to the configured error +// function. Request errors use the request context and socket errors use the +// connection context. Callers must finish changing the shared connection state +// first because user code may call back into the client. +func (c *websocketClientConn) handleError(ctx context.Context, errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { + if c.config.ErrorHandler != nil { + c.config.ErrorHandler(ctx, errorType, err, response) + } +} + +// close closes the shared socket, passes a connection-closed error to every +// function waiting for a request result, and returns the socket close error. +func (c *websocketClientConn) close() error { + err := fmt.Errorf("JSON-RPC WebSocket connection closed") + pending, ended := c.beginEnd(err) + if !ended { + return c.closeSocket() + } + closeErr := c.closeSocket() + c.finishEnd(err) + for _, request := range pending { + request.complete(request.ctx, nil, err) + } + return closeErr +} + +// Close rejects future client calls, closes the shared WebSocket, and returns +// the socket close error. +func (c *Client) Close() error { + if c.closed.Swap(true) { + return nil + } + c.connMu.Lock() + conn := c.conn + c.conn = nil + c.connMu.Unlock() + if conn == nil { + return nil + } + return conn.close() +} + +// IsClosed reports whether Close has closed this client. func (c *Client) IsClosed() bool { return c.closed.Load() } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden index 87a8db2ff1..27ec78741e 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden @@ -14,333 +14,245 @@ import ( "fmt" "io" "net/http" - "strconv" "sync" - "sync/atomic" - "time" chat "generated.local/gen/chat" - "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) -// Stream error types for comprehensive error reporting +// StreamErrorType identifies the kind of WebSocket stream error. type StreamErrorType int const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout + StreamErrorConnection StreamErrorType = iota // The WebSocket connection failed. + StreamErrorProtocol // The JSON-RPC message was invalid. + StreamErrorParsing // The response could not be read. + StreamErrorOrphaned // The response matched no request. + StreamErrorTimeout // The request waited too long. ) -// StreamErrorHandler allows users to handle stream errors +// StreamErrorHandler receives WebSocket stream errors. type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) -// EchoClientStream implements the echo client stream with direct WebSocket -// handling. -type EchoClientStream struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*EchoClientStreamPendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies -} - -// Stream-specific types for EchoClientStream -type EchoClientStreamPendingRequest struct { - userID string // User-provided payload ID - resultChan chan EchoClientStreamStreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} +// EchoClientStream implements the echo client stream. +type ( + EchoClientStream struct { + conn *websocketClientConn + owner *websocketRequestOwner + + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once + decoder func(*http.Response) goahttp.Decoder + + sendMu sync.Mutex + pendingMu sync.Mutex + pending []*echoClientStreamPendingRequest + pendingReady chan struct{} + } + // echoClientStreamPendingRequest stores the channel that receives the result or error + // for one request. The shared connection starts and stops its timer. + echoClientStreamPendingRequest struct { + id string + resultChan chan echoClientStreamStreamResult + } -type EchoClientStreamStreamResult struct { - result *chat.EchoResult - err error -} + // echoClientStreamStreamResult contains the decoded result or error returned by one + // request. + echoClientStreamStreamResult struct { + result *chat.EchoResult + err error + } +) -// Send sends streaming data to the echo endpoint with dual ID correlation. +// Send streams instances of "chat.EchoPayload" to the "echo" endpoint +// websocket connection. func (s *EchoClientStream) Send(v *chat.EchoPayload) error { return s.SendWithContext(s.ctx, v) } -// SendWithContext sends streaming data to the echo endpoint with context. +// SendWithContext streams instances of "chat.EchoPayload" to the "echo" +// endpoint websocket connection with context. func (s *EchoClientStream) SendWithContext(ctx context.Context, v *chat.EchoPayload) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - // Honor user-provided ID or generate one - userID := "" - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &EchoClientStreamPendingRequest{ - userID: userID, - resultChan: make(chan EchoClientStreamStreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request request := &jsonrpc.Request{ JSONRPC: "2.0", Method: "echo", Params: v, - ID: &jsonrpcID, + } + pending := &echoClientStreamPendingRequest{ + resultChan: make(chan echoClientStreamStreamResult, 1), } - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - + s.sendMu.Lock() + id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { + s.completeResponse(ctx, pending, response, err) + }) + if err == nil { + pending.id = id + s.enqueuePending(pending) + } + s.sendMu.Unlock() if err != nil { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) + return err } - return nil } -// Recv receives streaming data from the echo endpoint. +// Recv reads instances of "chat.EchoResult" from the "echo" endpoint websocket +// connection. func (s *EchoClientStream) Recv() (*chat.EchoResult, error) { return s.RecvWithContext(s.ctx) } -// RecvWithContext receives streaming data from the echo endpoint with context. +// RecvWithContext reads instances of "chat.EchoResult" from the "echo" +// endpoint websocket connection with context. func (s *EchoClientStream) RecvWithContext(ctx context.Context) (*chat.EchoResult, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - // Find the oldest pending request (FIFO ordering) - var oldestPending *EchoClientStreamPendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call Send() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") + pending, err := s.nextPending(ctx) + if err != nil { + var zero *chat.EchoResult + return zero, err } + return s.awaitPending(ctx, pending) } -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *EchoClientStream) responseHandler() { - defer close(s.done) - +// awaitPending waits for pending to receive a result or error. It returns the +// closed-stream error if Close runs, even when another cancellation is ready. +func (s *EchoClientStream) awaitPending(ctx context.Context, pending *echoClientStreamPendingRequest) (*chat.EchoResult, error) { for { + if s.owner.closed.Load() { + var zero *chat.EchoResult + return zero, errWebsocketMethodStreamClosed + } select { + case result := <-pending.resultChan: + return result.result, s.methodStreamError(result.err) + case <-ctx.Done(): + err := s.methodStreamError(ctx.Err()) + s.conn.cancelRequest(pending.id, err) + var zero *chat.EchoResult + return zero, err case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) + err := s.methodStreamError(s.ctx.Err()) + s.conn.cancelRequest(pending.id, err) + var zero *chat.EchoResult + return zero, err + case <-s.conn.done: + var zero *chat.EchoResult + return zero, s.methodStreamError(s.conn.terminalError()) } } } -func (s *EchoClientStream) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - var result EchoClientStreamStreamResult - - if response.Error != nil { +// completeResponse turns response into this method's service result, or uses +// err when the request failed, and sends it to the Recv call waiting for pending. +func (s *EchoClientStream) completeResponse(ctx context.Context, pending *echoClientStreamPendingRequest, response *jsonrpc.RawResponse, err error) { + var result echoClientStreamStreamResult + switch { + case err != nil: + result.err = err + case response.Error != nil: result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) + s.conn.handleError(ctx, jsonrpc.StreamErrorProtocol, response.Error, response) + default: + parsedResult, decodeErr := s.decodeResponse(response.Result) + if decodeErr != nil { + result.err = fmt.Errorf("failed to decode JSON-RPC WebSocket response: %w", decodeErr) + s.conn.handleError(ctx, jsonrpc.StreamErrorParsing, result.err, response) } else { - // Backfill the result ID from the envelope when missing if parsedResult.ID == nil || *parsedResult.ID == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.ID = &idCopy + id := jsonrpc.IDToString(response.ID) + parsedResult.ID = &id } result.result = parsedResult } } + pending.resultChan <- result +} - // Non-blocking send to result channel +// enqueuePending adds pending to the requests waiting for Recv. It keeps their +// send order even when the server responds in a different order. +func (s *EchoClientStream) enqueuePending(pending *echoClientStreamPendingRequest) { + s.pendingMu.Lock() + s.pending = append(s.pending, pending) + if s.owner.closed.Load() { + s.pending = s.pending[:len(s.pending)-1] + s.pendingMu.Unlock() + return + } + s.pendingMu.Unlock() select { - case pending.resultChan <- result: + case s.pendingReady <- struct{}{}: default: - // Channel full - should not happen with buffer size 1 } } -// Helper methods -func (s *EchoClientStream) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) +// nextPending returns the first request sent by this method stream that has not +// yet been passed to Recv. It returns an error if the caller cancels, Close +// runs, or the socket fails first. +func (s *EchoClientStream) nextPending(ctx context.Context) (*echoClientStreamPendingRequest, error) { + for { + if s.owner.closed.Load() { + return nil, errWebsocketMethodStreamClosed + } + s.pendingMu.Lock() + if len(s.pending) > 0 { + if s.owner.closed.Load() { + s.pendingMu.Unlock() + return nil, errWebsocketMethodStreamClosed + } + pending := s.pending[0] + s.pending = s.pending[1:] + s.pendingMu.Unlock() + return pending, nil + } + s.pendingMu.Unlock() + select { + case <-s.pendingReady: + case <-ctx.Done(): + return nil, s.methodStreamError(ctx.Err()) + case <-s.ctx.Done(): + return nil, s.methodStreamError(s.ctx.Err()) + case <-s.conn.done: + return nil, s.methodStreamError(s.conn.terminalError()) + } + } } -// handleError calls the user-provided error handler if available -func (s *EchoClientStream) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) +// methodStreamError returns the closed-stream error if Close has run. +// Otherwise it returns the supplied err unchanged. +func (s *EchoClientStream) methodStreamError(err error) error { + if s.owner.closed.Load() { + return errWebsocketMethodStreamClosed } + return err } -// decodeResponse decodes JSON-RPC response data using the user-provided decoder +// decodeResponse reads data using this method's response format and returns the +// service result. func (s *EchoClientStream) decodeResponse(data json.RawMessage) (*chat.EchoResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - - // Use user-provided decoder to decode the result (expects inner result JSON) dec := s.decoder(resp) var out *chat.EchoResult if err := dec.Decode(&out); err != nil { - return nil, err + var zero *chat.EchoResult + return zero, err } return out, nil } -func (s *EchoClientStream) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *EchoClientStream) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *EchoClientStream) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- EchoClientStreamStreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -// Close closes the stream and cleans up resources. +// Close closes the echo method stream without closing the WebSocket shared by +// other methods. func (s *EchoClientStream) Close() error { - var err error s.closeOnce.Do(func() { + s.conn.closeOwner(s.owner) + s.pendingMu.Lock() + s.pending = nil + s.pendingMu.Unlock() s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } }) - return err + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden index 1196bfc314..af64190b3d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden @@ -61,8 +61,8 @@ func New( upgrader: upgrader, configfn: configfn, } - // Default HTTP handler per transport kind - // WebSocket services implement ServeHTTP for upgrade + // Install the request handler required by this service's methods. + // ServeHTTP changes the HTTP connection to a WebSocket connection. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -105,7 +105,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Mount configures the mux to serve the JSON-RPC Chat service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // Every method in this server writes one ordinary JSON-RPC response. mux.Handle("GET", "/ws/ws", h.ServeHTTP) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden index 473903da48..4858e45f44 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden @@ -9,8 +9,10 @@ package server import ( "context" + "errors" "fmt" "net/http" + "sync" "time" chat "generated.local/gen/chat" @@ -34,9 +36,11 @@ type chatStream struct { r *http.Request // conn is the underlying websocket connection. conn *websocket.Conn + // writeMu allows only one caller at a time to write a message to conn. + writeMu sync.Mutex } -// echoStreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. +// echoStreamWrapper gives this method its request ID and selected result view. type echoStreamWrapper struct { stream *chatStream requestID any // Store the JSON-RPC request ID for responses @@ -65,13 +69,13 @@ func (w *echoStreamWrapper) Close() error { // SendEchoNotification sends a JSON-RPC notification for the echo method. func (s *chatStream) SendEchoNotification(ctx context.Context, result *chat.EchoResult) error { body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeNotification("echo", body)) + return s.writeJSON(jsonrpc.MakeNotification("echo", body)) } // SendEchoResponse sends a JSON-RPC response for the echo method. func (s *chatStream) SendEchoResponse(ctx context.Context, id any, result *chat.EchoResult) error { body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) + return s.writeJSON(jsonrpc.MakeSuccessResponse(id, body)) } // SendError streams JSON-RPC errors. @@ -89,33 +93,40 @@ func (s *chatStream) send(id any, method string, result any) error { // If there's no ID, send as a notification instead of a response // A JSON-RPC result with no ID is invalid per the spec if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) + return s.writeJSON(jsonrpc.MakeNotification(method, result)) } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) + return s.writeJSON(jsonrpc.MakeSuccessResponse(id, result)) } // sendError sends a JSON-RPC error response to the websocket connection. func (s *chatStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) + return s.writeJSON(response) +} + +// writeJSON waits for the current socket write to finish, then writes one +// JSON-RPC message. +func (s *chatStream) writeJSON(message any) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.conn.WriteJSON(message) } // Recv reads JSON-RPC requests from the Chat service stream. func (s *chatStream) Recv(ctx context.Context) error { var req jsonrpc.RawRequest if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully + // Return an unexpected connection close because no later request can be read. if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection return err } - // JSON parse errors - send Parse Error response and continue + // Report every other read failure as a JSON-RPC parse error. if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken + // Return when the parse-error response cannot be written to the client. return fmt.Errorf("failed to send parse error: %w", err) } - // Continue processing after sending parse error + // The next Recv call reads the next request from this connection. return nil } return s.processRequest(ctx, &req) @@ -138,32 +149,35 @@ func (s *chatStream) processRequest(ctx context.Context, req *jsonrpc.RawRequest switch req.Method { case "echo": - // Bidirectional streaming: decode payload and create stream wrapper + // Decode the request fields for this bidirectional-streaming call. payload, err := s.echo(ctx, s.r, req) if err != nil { return fmt.Errorf("handler error for %s: %w", "echo", err) } - // Create wrapper that implements the method-specific stream interface + // Give the service a stream that writes responses on this connection + // with the ID from this request. streamWrapper := &echoStreamWrapper{ stream: s, requestID: req.ID, } - // Call the endpoint with payload and stream wrapper + // Pass the decoded payload, when present, and this request's stream + // to the service. endpointInput := &chat.EchoEndpointInput{ Payload: payload.(*chat.EchoPayload), Stream: streamWrapper, } if _, err := s.echoEndpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response + // Send the service error to callers that supplied a request ID. if req.HasID { - // Send error response to client if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { return fmt.Errorf("failed to send error response: %w", sendErr) } - // Continue processing other requests + // The error response completes this request. The next Recv call + // reads another request from the same connection. return nil } - // For notifications (no ID), just log and continue + // Notifications have no response, so finish this request without + // writing to the connection. return nil } return nil @@ -175,18 +189,20 @@ func (s *chatStream) processRequest(ctx context.Context, req *jsonrpc.RawRequest } } -// Close closes the Chat service websocket connection. +// Close asks the Chat client to close normally, closes the WebSocket, and +// returns errors from either operation. func (s *chatStream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( + controlErr := s.conn.WriteControl( websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second), - ); err != nil { - return err + ) + if controlErr != nil { + controlErr = fmt.Errorf("write normal WebSocket close message: %w", controlErr) + } + closeErr := s.conn.Close() + if closeErr != nil { + closeErr = fmt.Errorf("close WebSocket connection: %w", closeErr) } - return s.conn.Close() + return errors.Join(controlErr, closeErr) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden index 9f9bfb0ee3..593b351e64 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden @@ -8,7 +8,6 @@ package client import ( - "bufio" "bytes" "context" "fmt" @@ -37,12 +36,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Feed service servers. +// NewClient creates HTTP clients for all the Feed service servers. func NewClient( scheme string, host string, @@ -96,12 +95,6 @@ func (c *Client) Watch() goa.Endpoint { } // Create the SSE client stream - stream := &WatchClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, - } - - return stream, nil + return NewWatchStream(resp, c.decoder), nil } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden index 8eebbdd70b..7e590d11b1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden @@ -23,18 +23,40 @@ import ( "goa.design/goa/v3/jsonrpc" ) -// WatchClientStream implements the feed.WatchClientStream interface using -// Server-Sent Events. -type WatchClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*feed.WatchResult, error) + RecvWithContext(context.Context) (*feed.WatchResult, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. +func (s *WatchStreamImpl) parseSSEEvent() (eventType string, data []byte, err error) { var event strings.Builder var dataLines []string @@ -42,7 +64,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err line, err := s.reader.ReadString('\n') if err != nil { if err == io.EOF && len(dataLines) > 0 { - // Process final event + // Return the last event even when the response has no final blank line. break } return "", nil, err @@ -52,7 +74,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err line = strings.TrimSuffix(line, "\r") if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } @@ -64,7 +86,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err } else if strings.HasPrefix(line, "data:") { dataLines = append(dataLines, strings.TrimSpace(line[5:])) } - // Ignore other fields like id:, retry: + // This client does not use the id and retry fields. } if len(dataLines) > 0 { @@ -75,7 +97,13 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err } // Recv reads instances of "WatchResult" from the stream. -func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) { +func (s *WatchStreamImpl) Recv() (*feed.WatchResult, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "WatchResult" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(_ context.Context) (*feed.WatchResult, error) { s.lock.Lock() defer s.lock.Unlock() @@ -169,15 +197,14 @@ func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) } } -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *WatchClientStream) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { + // Give the configured decoder the successful result bytes as an HTTP response body. resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) var result *feed.WatchResult if err := decoder.Decode(&result); err != nil { @@ -188,7 +215,7 @@ func (s *WatchClientStream) decodeResult(data json.RawMessage) (*feed.WatchResul } // Close closes the stream. -func (s *WatchClientStream) Close() error { +func (s *WatchStreamImpl) Close() error { s.lock.Lock() defer s.lock.Unlock() diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden index 2985b3bb6a..2f8bfbade5 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden @@ -50,8 +50,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // SSE-only services route via handleSSE + // Install the request handler required by this service's methods. + // handleSSE writes each result as a server-sent event. s.Handler = http.HandlerFunc(s.handleSSE) return s } @@ -67,55 +67,65 @@ func (s *Server) Use(m func(http.Handler) http.Handler) { // MethodNames returns the methods served. func (s *Server) MethodNames() []string { return feed.MethodNames[:] } -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. +// handleSSE finds the requested method and writes its results as server-sent events. func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Read the JSON-RPC request + // Read the JSON-RPC request. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + // Write the parse error as a server-sent event. + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write parse error event: %w", err)) + } return } - // Validate JSON-RPC request + // Reject requests that do not use JSON-RPC 2.0. if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // Find the appropriate handler based on method name + // Find the function for the requested method. var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { case "watch": handler = s.Watch default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } return } - // Call the handler for the specific method + // Call the requested method. if err := handler(ctx, r, &req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) return } - // For notifications (requests without ID) that don't stream, return 204 No Content + // A request without an ID receives no response when the method sends one result. switch req.Method { } -} // Mount configures the mux to serve the JSON-RPC Feed service methods. +} + +// Mount configures the mux to serve the JSON-RPC Feed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // SSE only: mount SSE handler + // Every method in this server writes server-sent events. mux.Handle("POST", "/feed", h.handleSSE) } @@ -136,11 +146,10 @@ func NewWatchHandler( return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, "watch") ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") - // Initialize SSE stream early so decode errors can be sent as SSE error events + // Create the stream before decoding so a request error can be written to it. strm := &WatchServerStream{ sseServerStream: sseServerStream{ w: w, - r: r, encoder: encoder, }, requestID: req.ID, @@ -148,9 +157,11 @@ func NewWatchHandler( decodeParams := DecodeWatchRequest(mux, decoder) params, err := decodeParams(r, req) if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics + // Write the request error as a JSON-RPC server-sent event when the request has an ID. if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) + if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil { + return err + } } return nil } @@ -166,25 +177,20 @@ func NewWatchHandler( Payload: params, } if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil + return err } return nil } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden index 6fd35a591f..a332dd721d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden @@ -8,6 +8,7 @@ package server import ( + "bytes" "context" "fmt" "net/http" @@ -19,47 +20,31 @@ import ( goa "goa.design/goa/v3/pkg" ) -// sseServerStream provides the SSE event encoding machinery shared by all -// JSON-RPC SSE server streams of the service. -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder -} - -// sseEventWriter wraps http.ResponseWriter to format output as SSE events. -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool -} - -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) +type ( + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder } - return s.w.Write(data) -} -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() + // sseEventBuffer stores an encoded event before any HTTP output is written. + sseEventBuffer struct { + bytes.Buffer + header http.Header } +) + +func (b *sseEventBuffer) Header() http.Header { + return b.header } -// initSSEHeaders initializes the SSE response headers +func (b *sseEventBuffer) WriteHeader(int) {} + +// initSSEHeaders writes the response headers before the first event. func (s *sseServerStream) initSSEHeaders() { s.once.Do(func() { header := s.w.Header() @@ -71,45 +56,58 @@ func (s *sseServerStream) initSSEHeaders() { }) } -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) - - // Finish the SSE event (adds newlines and flushes) - ew.finish() +// sendSSEEvent encodes one event before starting the response, then writes and +// flushes that complete event. +func (s *sseServerStream) sendSSEEvent(ctx context.Context, eventType string, value any) error { + event := &sseEventBuffer{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } - return err + s.initSSEHeaders() + if eventType != "" { + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + } + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(event.Bytes()); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil } -// sendError sends a JSON-RPC error response to the SSE stream +// sendError writes one JSON-RPC error as a server-sent event. func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) + return s.sendSSEEvent(ctx, "error", response) } // WatchServerStream implements the feed.WatchServerStream interface using // Server-Sent Events. type WatchServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response + // requestID identifies the request in the final response. requestID any - // closed indicates if the stream has been closed via SendAndClose + // closed records whether SendAndClose has written the final response. closed bool - // mu protects the closed flag + // mu protects closed and view while service code sends results. mu sync.Mutex } // Send sends a JSON-RPC notification to the client. // Notifications do not expect a response from the client. func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is closed + // Reject a send after SendAndClose wrote the final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -117,22 +115,22 @@ func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) err } s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(*feed.WatchResult) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - // Convert to response body type for proper JSON encoding + // Build the JSON body declared for this service result. body := NewWatchResponseBody(result) - // Send as notification (no ID) + // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "watch", "params": body, } - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } // SendAndClose sends a final JSON-RPC response to the client and closes the @@ -141,7 +139,7 @@ func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) err // ID field populated. // After calling this method, no more events can be sent on this stream. func (s *WatchServerStream) SendAndClose(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is already closed + // Reject a second final response. s.mu.Lock() if s.closed { s.mu.Unlock() @@ -150,31 +148,31 @@ func (s *WatchServerStream) SendAndClose(ctx context.Context, event feed.WatchEv s.closed = true s.mu.Unlock() - // Type assert to the specific result type + // Read the service result value from the event. result, ok := event.(*feed.WatchResult) if !ok { return fmt.Errorf("unexpected event type: %T", event) } - // Determine the ID to use for the response + // Start with the ID of the request that opened this stream. var id any = s.requestID - // Convert to response body type for proper JSON encoding + // Build the JSON body declared for this service result. body := NewWatchResponseBody(result) - // Send as response with ID + // Write the final response with its request ID. message := map[string]any{ "jsonrpc": "2.0", "id": id, "result": body, } - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "response", message) } // SendError sends a JSON-RPC error response. func (s *WatchServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error + // Report request validation failures as invalid parameters and all other + // failures as internal errors. code := jsonrpc.InternalError if _, ok := err.(*goa.ServiceError); ok { code = jsonrpc.InvalidParams diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden index 9f65d3eea8..e0017c39dd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Mixed service servers. +// NewClient creates HTTP clients for all the Mixed service servers. func NewClient( scheme string, host string, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden index 27a6b44d55..c51b1a741e 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden @@ -53,8 +53,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP writes one JSON-RPC response for each request. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -169,7 +169,7 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter joins the responses written for one JSON-RPC batch request. type batchWriter struct { io.Writer header http.Header @@ -203,7 +203,7 @@ func (rb *batchWriter) Write(data []byte) (int, error) { // Mount configures the mux to serve the JSON-RPC Mixed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // Every method in this server writes one ordinary JSON-RPC response. mux.Handle("POST", "/mixed/rpc/mixed/rpc", h.ServeHTTP) } @@ -235,7 +235,7 @@ func NewLookupHandler( } encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -265,7 +265,7 @@ func NewLookupHandler( encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil @@ -289,7 +289,7 @@ func NewLookupHandler( } // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewLookupResponseBody(res.(*mixed.LookupResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -299,14 +299,14 @@ func NewLookupHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one JSON-RPC error response and preserves a +// missing request ID. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 27aacb5750..8cc0f86294 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -10,17 +10,9 @@ import ( httpcodegen "goa.design/goa/v3/http/codegen" ) -// CreateJSONRPCServices creates a new ServicesData instance for JSON-RPC -// testing. Generation construction normalizes the root before any planner -// reads it. -func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { - services := createServiceServices(root) - return httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) -} - -// createServiceServices performs the complete package declaration lifecycle -// required by transport test helpers. -func createServiceServices(root *expr.RootExpr) *service.ServicesData { +// CreateJSONRPCPlan builds and links the same service, HTTP, and JSON-RPC plans +// that production generation uses. +func CreateJSONRPCPlan(root *expr.RootExpr) *Plan { generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) if err != nil { panic(err) @@ -29,7 +21,31 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err != nil { panic(err) } - if err := Plan(generation); err != nil { + var applicationHTTP *httpcodegen.Plan + if len(root.API.HTTP.Services) > 0 { + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + applicationHTTP = applicationPlans[0] + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationHTTP, + }) + if err != nil { panic(err) } if err := generation.Freeze(); err != nil { @@ -38,5 +54,16 @@ func createServiceServices(root *expr.RootExpr) *service.ServicesData { if err := servicePlan.Link(); err != nil { panic(err) } - return servicePlan.Services() + if applicationHTTP != nil { + if err := applicationHTTP.Link(); err != nil { + panic(err) + } + } + if err := httpPlans[0].Link(); err != nil { + panic(err) + } + if err := plans[0].Link(); err != nil { + panic(err) + } + return plans[0] } diff --git a/jsonrpc/codegen/viewed_result.go b/jsonrpc/codegen/viewed_result.go new file mode 100644 index 0000000000..488c2a761d --- /dev/null +++ b/jsonrpc/codegen/viewed_result.go @@ -0,0 +1,305 @@ +// This file connects each JSON-RPC result view to the HTTP JSON body and +// service constructor chosen for that endpoint. Unary calls, SSE streams, and +// WebSocket streams use the same generated functions, so clients decode the +// same JSON shape that servers encode. +package codegen + +import ( + "fmt" + "reflect" + "strings" + + "goa.design/goa/v3/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // viewedResultTemplateData contains one method's allowed views, body types, + // and generated function names. + viewedResultTemplateData struct { + ServiceName string + MethodName string + Decode *codegen.NameDeclaration + Encode *codegen.NameDeclaration + StreamEncode *codegen.NameDeclaration + WriteMetadata *codegen.NameDeclaration + BodyDecoder *codegen.NameDeclaration + Variable bool + FixedView string + Branches []*viewBranchTemplateData + ViewedTypeRef string + ViewedVarName string + ViewedPkg string + ViewedValidator string + ServiceResultConstructor string + ServiceViewedConstructor string + ServicePkg string + ResultRef string + IsCollection bool + HasResponseMetadata bool + } + + // viewBranchTemplateData contains one view's server body, client body, and + // function that rebuilds the result. + viewBranchTemplateData struct { + View string + ResultAttr string + ServerBody *httpcodegen.JSONRPCBodyData + ClientBody *httpcodegen.JSONRPCBodyData + ResultInit httpcodegen.InitData + Headers []httpcodegen.JSONRPCHeaderData + Cookies []httpcodegen.JSONRPCCookieData + } +) + +// clientViewedResultSections returns the result decoders written to one +// generated service client package. +func clientViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-decoder", + Source: jsonrpcTemplates.Read(viewedResultDecodeT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: viewedResultData(service, endpoint), + FuncMap: map[string]any{ + "viewedResponseData": viewedResponseData, + }, + }) + } + return sections +} + +// serverViewedResultSections returns the result encoders written to one +// generated service server package. +func serverViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-encoder", + Source: jsonrpcTemplates.Read(viewedResultEncodeT, headerConversionP, viewedResultMetadataP), + Data: viewedResultData(service, endpoint), + FuncMap: map[string]any{ + "headerConversionData": viewedHeaderConversionData, + "printValue": viewedPrintValue, + "goTypeRef": viewedGoTypeRef, + }, + }) + } + return sections +} + +// viewedResultFuncs returns functions that read names already declared in the +// generated client and server packages. +func viewedResultFuncs(service *servicePlan) map[string]any { + return map[string]any{ + "viewedDecodeName": service.viewedDecodeName, + "viewedEncodeName": service.viewedEncodeName, + "viewedStreamEncodeName": service.viewedStreamEncodeName, + "viewedMetadataName": service.viewedMetadataName, + "viewedHasMetadata": service.viewedHasMetadata, + } +} + +// viewedDecodeName returns the generated client decoder name for method. +func (s *servicePlan) viewedDecodeName(method string) string { + return s.viewedHelpers(method).decode.Name() +} + +// viewedEncodeName returns the generated server encoder name for method. +func (s *servicePlan) viewedEncodeName(method string) string { + return s.viewedHelpers(method).encode.Name() +} + +// viewedStreamEncodeName returns the generated server stream encoder name for method. +func (s *servicePlan) viewedStreamEncodeName(method string) string { + return s.viewedHelpers(method).streamEncode.Name() +} + +// viewedMetadataName returns the server function that writes a method's +// successful response headers and cookies. +func (s *servicePlan) viewedMetadataName(method string) string { + return s.viewedHelpers(method).writeMetadata.Name() +} + +// viewedHasMetadata reports whether the unary response for method writes at +// least one mapped HTTP header or cookie. +func (s *servicePlan) viewedHasMetadata(method string) bool { + for _, endpoint := range s.endpoints { + if endpoint.Method.Name != method || endpoint.viewed == nil { + continue + } + for _, branch := range endpoint.viewed.branches { + if len(branch.headers) > 0 || len(branch.cookies) > 0 { + return true + } + } + return false + } + panic("JSON-RPC response metadata requested for unplanned method " + method) +} + +// viewedHelpers returns the function names declared for a method that returns a +// result view. It panics when the generated file asks for an unknown method. +func (s *servicePlan) viewedHelpers(method string) *viewedHelperDeclarations { + declarations := s.helpers[method] + if declarations == nil { + panic("JSON-RPC viewed helper requested for unplanned method " + method) + } + return declarations +} + +// viewedResultData returns the method and function names used to write result +// conversion code. It does not read the design or create new names. +func viewedResultData(service *servicePlan, endpoint *endpointPlan) *viewedResultTemplateData { + representation := endpoint.viewed + viewed := representation.viewedResult + branches := make([]*viewBranchTemplateData, len(representation.branches)) + for index, branch := range representation.branches { + branches[index] = &viewBranchTemplateData{ + View: branch.view, + ResultAttr: branch.resultAttr, + ServerBody: branch.serverBody, + ClientBody: branch.clientBody, + ResultInit: branch.resultInit, + Headers: branch.headers, + Cookies: branch.cookies, + } + } + return &viewedResultTemplateData{ + ServiceName: endpoint.ServiceName, + MethodName: endpoint.Method.Name, + Decode: representation.decode, + Encode: representation.encode, + StreamEncode: representation.streamEncode, + WriteMetadata: representation.writeMetadata, + BodyDecoder: service.bodyDecoder, + Variable: representation.variable, + FixedView: representation.fixedView, + Branches: branches, + ViewedTypeRef: viewed.FullRef, + ViewedVarName: viewed.VarName, + ViewedPkg: viewed.ViewsPkg, + ViewedValidator: viewed.Validate.Name(), + ServiceResultConstructor: viewed.ResultInit.Name(), + ServiceViewedConstructor: viewed.Init.Name(), + ServicePkg: representation.servicePkg, + ResultRef: representation.resultRef, + IsCollection: viewed.IsCollection, + HasResponseMetadata: representationHasMetadata(representation), + } +} + +// representationHasMetadata reports whether any allowed view maps a result +// field to an HTTP response header or cookie. +func representationHasMetadata(representation *viewedRepresentation) bool { + for _, branch := range representation.branches { + if len(branch.headers) > 0 || len(branch.cookies) > 0 { + return true + } + } + return false +} + +// serviceNeedsMetadataStrconv reports whether a mapped response header or +// cookie contains a number or boolean that generated code must format as text. +func serviceNeedsMetadataStrconv(service *servicePlan) bool { + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + for _, branch := range endpoint.viewed.branches { + for _, header := range branch.headers { + if metadataTypeNeedsStrconv(header.TypeName, header.ElemTypeName) { + return true + } + } + for _, cookie := range branch.cookies { + if metadataTypeNeedsStrconv(cookie.TypeName, cookie.ElemTypeName) { + return true + } + } + } + } + return false +} + +// metadataTypeNeedsStrconv reports whether dataType or an array element uses +// strconv when generated code turns it into response text. +func metadataTypeNeedsStrconv(typeName, elementTypeName string) bool { + if typeName == "array" { + return metadataTypeNeedsStrconv(elementTypeName, "") + } + return typeName != "string" && typeName != "bytes" && typeName != "any" +} + +// viewedResponseData gives the response reader one view's body, header, and +// cookie fields together with the service and method names used in errors. +func viewedResponseData(branch *viewBranchTemplateData, serviceName, methodName string) map[string]any { + return map[string]any{ + "Data": map[string]any{ + "ClientBody": branch.ClientBody, + "Headers": branch.Headers, + "Cookies": branch.Cookies, + "MustValidate": len(branch.Headers) > 0 || len(branch.Cookies) > 0, + }, + "ServiceName": serviceName, + "Method": map[string]any{"Name": methodName}, + } +} + +// viewedHeaderConversionData names the value that generated code turns into +// response header text. +func viewedHeaderConversionData(typeName, elementTypeName, varName string, required bool, target string) map[string]any { + return map[string]any{ + "TypeName": typeName, + "ElemTypeName": elementTypeName, + "VarName": varName, + "Required": required, + "Target": target, + } +} + +// viewedPrintValue returns the text used for a designed default header or +// cookie value. Arrays join their element values with a comma and a space. +func viewedPrintValue(typeName, elementTypeName string, value any) string { + if typeName == "array" { + values := reflect.ValueOf(value) + parts := make([]string, values.Len()) + for index := 0; index < values.Len(); index++ { + parts[index] = viewedPrintValue(elementTypeName, "", values.Index(index).Interface()) + } + return strings.Join(parts, ", ") + } + switch typeName { + case "boolean", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "string", "bytes", "any": + return fmt.Sprintf("%v", value) + default: + panic("JSON-RPC response metadata has an unsupported default type " + typeName) + } +} + +// viewedGoTypeRef returns the built-in Go type used while converting an +// aliased result field to response text. +func viewedGoTypeRef(typeName, elementTypeName string) string { + if typeName == "array" { + return "[]" + viewedGoTypeRef(elementTypeName, "") + } + switch typeName { + case "boolean": + return "bool" + case "bytes": + return "[]byte" + case "any": + return "any" + case "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "string": + return typeName + default: + panic("JSON-RPC response metadata has an unsupported type " + typeName) + } +} diff --git a/jsonrpc/codegen/viewed_result_runtime_regression_test.go b/jsonrpc/codegen/viewed_result_runtime_regression_test.go new file mode 100644 index 0000000000..3699dde8d7 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_runtime_regression_test.go @@ -0,0 +1,758 @@ +// This file renders JSON-RPC clients and servers into a temporary Go module. +// The generated tests call each client with an application-supplied decoder +// and send an invalid request to an SSE server, then inspect the response data +// and errors that the generated code gives the application. +package codegen_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedViewedClientDecodersReceiveOKStatus renders a unary call, an +// SSE stream, and a WebSocket stream, then runs each generated client. +func TestGeneratedViewedClientDecodersReceiveOKStatus(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "unary_status", unaryStatusRuntimeTest) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseStatusRuntimeTest) + writeViewedResultRuntimeTest(t, dir, "web_socket_status", webSocketStatusRuntimeTest) + runViewedResultRuntimeTests(t, dir, + "./jsonrpc/unary_status/client", + "./jsonrpc/sse_status/client", + "./jsonrpc/web_socket_status/client", + ) +} + +// TestGeneratedMappedObjectBodyValidatesRequiredFields renders an explicit +// object response body, decodes both selected views, and checks the required +// field when the generated client receives the selected body. +func TestGeneratedMappedObjectBodyValidatesRequiredFields(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "mapped_body", mappedBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/mapped_body/client") +} + +// TestGeneratedViewedUnaryResponseMetadata sends viewed results through a +// generated server and client. It checks a response with a JSON body and a +// response whose result is carried only by an HTTP header and cookie. +func TestGeneratedViewedUnaryResponseMetadata(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "unary_metadata", unaryMetadataRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_metadata/server") +} + +// TestGeneratedSSEDecodeErrorReturnsWriteFailure sends a request that omits a +// required parameter and makes writing the JSON-RPC error event fail. The +// generated server must report that failure once without starting a new +// response. +func TestGeneratedSSEDecodeErrorReturnsWriteFailure(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseDecodeErrorRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +// renderViewedResultRuntimeModule writes the generated service and JSON-RPC +// client files used by these tests. It uses this Goa checkout and leaves the +// repository's generated files unchanged. +func renderViewedResultRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, viewedResultRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + goMod := fmt.Sprintf("module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", repository) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) + return moduleDir +} + +// writeViewedResultRuntimeTest adds a client test to the temporary module. +func writeViewedResultRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "client") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_result_runtime_test.go"), []byte(source), 0o600)) +} + +// writeViewedResultServerRuntimeTest adds a server test to the temporary +// module without writing into this repository's generated directories. +func writeViewedResultServerRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "server") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sse_error_runtime_test.go"), []byte(source), 0o600)) +} + +// runViewedResultRuntimeTests runs only the generated packages named by +// patterns so each failure identifies the client call or server request that +// supplied unexpected data. +func runViewedResultRuntimeTests(t *testing.T, moduleDir string, patterns ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + args := append([]string{"test", "-mod=mod"}, patterns...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = moduleDir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// viewedResultRuntimeDSL defines separate services because JSON-RPC WebSocket +// methods cannot share one service endpoint with HTTP or SSE methods. +func viewedResultRuntimeDSL() { + result := viewedStatusResult() + dsl.Service("Unary Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/unary") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("SSE Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/sse") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + dsl.Service("SSE Decode", func() { + dsl.JSONRPC(func() { + dsl.POST("/decode") + }) + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("topic", dsl.String) + dsl.Required("topic") + }) + dsl.StreamingResult(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + dsl.Service("Web Socket Status", func() { + dsl.JSONRPC(func() { + dsl.Path("/websocket") + }) + dsl.Method("watch", func() { + dsl.StreamingPayload(func() { + dsl.Attribute("key", dsl.String) + dsl.Required("key") + }) + dsl.StreamingResult(result) + dsl.JSONRPC(func() {}) + }) + }) + + mapped := dsl.ResultType("application/vnd.mapped-body", func() { + dsl.TypeName("MappedBody") + dsl.Attribute("id", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("Mapped Body", func() { + dsl.JSONRPC(func() { + dsl.POST("/mapped") + }) + dsl.Method("fetch", func() { + dsl.Result(mapped) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) + + metadata := dsl.ResultType("application/vnd.unary-metadata", func() { + dsl.TypeName("UnaryMetadata") + dsl.Attribute("value", dsl.String) + dsl.Attribute("etag", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Required("etag", "session") + dsl.View("summary", func() { + dsl.Attribute("value") + dsl.Attribute("etag") + dsl.Attribute("session") + }) + dsl.View("detailed", func() { + dsl.Attribute("value") + dsl.Attribute("etag") + dsl.Attribute("session") + }) + }) + metadataOnly := dsl.ResultType("application/vnd.unary-metadata-only", func() { + dsl.TypeName("UnaryMetadataOnly") + dsl.Attribute("etag", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Required("etag", "session") + dsl.View("default", func() { + dsl.Attribute("etag") + dsl.Attribute("session") + }) + }) + dsl.Service("Unary Metadata", func() { + dsl.JSONRPC(func() { + dsl.POST("/metadata") + }) + dsl.Method("fetch", func() { + dsl.Result(metadata) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("value") + dsl.Header("etag:X-ETag") + dsl.Cookie("session:SID") + }) + }) + }) + dsl.Method("only", func() { + dsl.Result(metadataOnly) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body(dsl.Empty) + dsl.Header("etag:X-ETag") + dsl.Cookie("session:SID") + }) + }) + }) + }) +} + +// viewedStatusResult defines two views so each client must decode both the +// selected view name and its corresponding JSON body. +func viewedStatusResult() *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd.decoder-status", func() { + dsl.TypeName("DecoderStatus") + dsl.Attribute("label", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("label") + dsl.View("summary", func() { + dsl.Attribute("label") + }) + dsl.View("detailed", func() { + dsl.Attribute("label") + dsl.Attribute("detail") + }) + }) +} + +const unaryStatusRuntimeTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +func TestUnaryViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 3) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"label":"ready"}}}` + "`" + `, + )), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + _, err := client.Fetch()(context.Background(), nil) + require.NoError(t, err) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} +` + +const sseStatusRuntimeTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_status" + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 2) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n", + )), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + raw, err := client.Watch()(context.Background(), nil) + require.NoError(t, err) + stream := raw.(*WatchStreamImpl) + var serviceStream service.WatchClientStream = stream + _, err = serviceStream.Recv() + require.NoError(t, err) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} +` + +const webSocketStatusRuntimeTest = `package client + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + service "generated.local/gen/web_socket_status" + goahttp "goa.design/goa/v3/http" +) + +func TestWebSocketViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + acknowledged := make(chan struct{}) + serverErrors := make(chan error, 2) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + connection, err := (&websocket.Upgrader{}).Upgrade(writer, request, nil) + if err != nil { + serverErrors <- err + return + } + defer func() { + if err := connection.Close(); err != nil { + serverErrors <- err + } + }() + var message struct { + ID any ` + "`" + `json:"id"` + "`" + ` + } + if err := connection.ReadJSON(&message); err != nil { + serverErrors <- err + return + } + if err := connection.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "id": message.ID, + "result": map[string]any{ + "view": "summary", + "body": map[string]any{"label": "ready"}, + }, + }); err != nil { + serverErrors <- err + return + } + <-acknowledged + })) + t.Cleanup(server.Close) + + statuses := make([]int, 0, 2) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + client := NewClient( + "http", strings.TrimPrefix(server.URL, "http://"), http.DefaultClient, + goahttp.RequestEncoder, decoder, false, websocket.DefaultDialer, nil, + ) + t.Cleanup(func() { + close(acknowledged) + require.NoError(t, client.Close()) + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + raw, err := client.Watch()(ctx, nil) + require.NoError(t, err) + stream := raw.(*WatchClientStream) + require.NoError(t, stream.Send(&service.WatchPayload{Key: "status"})) + _, err = stream.Recv() + select { + case serverErr := <-serverErrors: + require.NoError(t, serverErr) + default: + } + require.NoError(t, err) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} +` + +const mappedBodyRuntimeTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestMappedObjectBodyAcceptsBothViews(t *testing.T) { + for _, view := range []string{"summary", "detailed"} { + response := mappedResponse(` + "`" + `{"view":"` + "`" + ` + view + ` + "`" + `","body":{"value":"record-1"}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + } +} + +func TestMappedObjectBodyRejectsMissingRequiredField(t *testing.T) { + response := mappedResponse(` + "`" + `{"view":"summary","body":{}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, goa.MissingField, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, "value", *serviceError.Field) +} + +func mappedResponse(result string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + result + "}", + )), + } +} +` + +const sseDecodeErrorRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" +) + +var errWriteSSEError = errors.New("write SSE error") +var errEncodeSSE = errors.New("encode SSE event") +var errFlushSSE = errors.New("flush SSE event") + +type unusedService struct{} + +func (*unusedService) Watch(context.Context, *service.WatchPayload, service.WatchServerStream) error { + return nil +} + +type failingResponseWriter struct { + header http.Header + headerCalls int +} + +type stepResponseWriter struct { + header http.Header + headerCalls int + writes int + failWrite int + flushError error +} + +func (writer *stepResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *stepResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (writer *stepResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failWrite { + return 0, errWriteSSEError + } + return len(data), nil +} + +func (writer *stepResponseWriter) FlushError() error { + return writer.flushError +} + +func (writer *failingResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *failingResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (*failingResponseWriter) Write([]byte) (int, error) { + return 0, errWriteSSEError +} + +func TestSSERequestDecodeErrorReturnsWriteFailureOnce(t *testing.T) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(&unusedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := &failingResponseWriter{header: make(http.Header)} + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{}}` + "`" + `, + )) + server.ServeHTTP(writer, request) + + require.Len(t, reported, 1) + require.ErrorIs(t, reported[0], errWriteSSEError) + require.Equal(t, 1, writer.headerCalls) +} + +func TestSSEEventEncodesBeforeStartingResponse(t *testing.T) { + writer := &stepResponseWriter{header: make(http.Header)} + stream := &sseServerStream{ + w: writer, + encoder: func(context.Context, http.ResponseWriter) goahttp.Encoder { + return goahttp.EncodingFunc(func(any) error { return errEncodeSSE }) + }, + } + + err := stream.sendSSEEvent(context.Background(), "notification", map[string]any{"value": "one"}) + require.ErrorIs(t, err, errEncodeSSE) + require.Zero(t, writer.headerCalls) + require.Zero(t, writer.writes) +} + +func TestSSEEventReturnsEveryWriteAndFlushError(t *testing.T) { + tests := []struct { + name string + failWrite int + flushError error + }{ + {name: "event name", failWrite: 1}, + {name: "data label", failWrite: 2}, + {name: "encoded value", failWrite: 3}, + {name: "event ending", failWrite: 4}, + {name: "flush", flushError: errFlushSSE}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + writer := &stepResponseWriter{ + header: make(http.Header), + failWrite: test.failWrite, + flushError: test.flushError, + } + stream := &sseServerStream{w: writer, encoder: goahttp.ResponseEncoder} + + err := stream.sendSSEEvent(context.Background(), "notification", map[string]any{"value": "one"}) + if test.flushError != nil { + require.ErrorIs(t, err, test.flushError) + } else { + require.ErrorIs(t, err, errWriteSSEError) + } + require.Equal(t, 1, writer.headerCalls) + }) + } +} +` + +const unaryMetadataRuntimeTest = `package server + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/unary_metadata" + genclient "generated.local/gen/jsonrpc/unary_metadata/client" + goahttp "goa.design/goa/v3/http" +) + +type metadataService struct { + fetchView string +} + +func (s *metadataService) Fetch(context.Context) (*service.UnaryMetadata, string, error) { + value := "record-1" + return &service.UnaryMetadata{Value: &value, Etag: "etag-1", Session: "session-1"}, s.fetchView, nil +} + +func (*metadataService) Only(context.Context) (*service.UnaryMetadataOnly, error) { + return &service.UnaryMetadataOnly{Etag: "etag-2", Session: "session-2"}, nil +} + +func TestViewedUnaryResponseCarriesBodyHeaderAndCookie(t *testing.T) { + response := serve(t, &metadataService{fetchView: "summary"}, "fetch") + require.Equal(t, "etag-1", response.Header.Get("X-ETag")) + cookies := response.Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "SID", cookies[0].Name) + require.Equal(t, "session-1", cookies[0].Value) + + decoded, err := genclient.DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + result := decoded.(*service.UnaryMetadata) + require.Equal(t, "record-1", *result.Value) + require.Equal(t, "etag-1", result.Etag) + require.Equal(t, "session-1", result.Session) +} + +func TestViewedUnaryResponseCarriesOnlyHeaderAndCookie(t *testing.T) { + response := serve(t, &metadataService{}, "only") + require.Equal(t, "etag-2", response.Header.Get("X-ETag")) + cookies := response.Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "SID", cookies[0].Name) + require.Equal(t, "session-2", cookies[0].Value) + + decoded, err := genclient.DecodeOnlyResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + result := decoded.(*service.UnaryMetadataOnly) + require.Equal(t, "etag-2", result.Etag) + require.Equal(t, "session-2", result.Session) +} + +func TestUnknownViewWritesNoSuccessMetadata(t *testing.T) { + response := serve(t, &metadataService{fetchView: "unknown"}, "fetch") + require.Empty(t, response.Header.Get("X-ETag")) + require.Empty(t, response.Cookies()) + + _, err := genclient.DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.Error(t, err) +} + +func serve(t *testing.T, svc *metadataService, method string) *http.Response { + t.Helper() + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/metadata", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder.Result() +} +` diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go index f58383c1c8..959baf9f9f 100644 --- a/jsonrpc/codegen/websocket_client.go +++ b/jsonrpc/codegen/websocket_client.go @@ -1,5 +1,5 @@ -// This file renders one JSON-RPC WebSocket client implementation and leaves -// service-specific import attachment to the owning file builder. +// This file renders the JSON-RPC WebSocket client for each service and adds +// the imports used by that service's methods. package codegen import ( @@ -7,21 +7,61 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -func websocketClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { +type ( + // websocketClientTemplateData stores the names and request and result values + // used to write one method stream. + websocketClientTemplateData struct { + *httpcodegen.JSONRPCWebSocketData + // Endpoint contains the request and result values for this method. + Endpoint *endpointPlan + // Pending is the type that waits for one method result. + Pending *codegen.NameDeclaration + // Result is the type passed from the shared reader to the waiting method. + Result *codegen.NameDeclaration + // Connection is the WebSocket connection shared by all methods. + Connection *codegen.NameDeclaration + // RequestOwner is the type that marks one method stream closed. + RequestOwner *codegen.NameDeclaration + // ClosedError is returned after the method stream closes. + ClosedError *codegen.NameDeclaration + } + + // websocketErrorTemplateData stores the public error names written by one + // WebSocket client. + websocketErrorTemplateData struct { + // Type is the error category type. + Type *codegen.NameDeclaration + // Connection identifies connection failures. + Connection *codegen.NameDeclaration + // Protocol identifies invalid JSON-RPC messages. + Protocol *codegen.NameDeclaration + // Parsing identifies messages that cannot be decoded. + Parsing *codegen.NameDeclaration + // Orphaned identifies responses that match no request. + Orphaned *codegen.NameDeclaration + // Timeout identifies requests that waited too long. + Timeout *codegen.NameDeclaration + // Handler is the function type used to report a stream error. + Handler *codegen.NameDeclaration + } +) + +// websocketClientFile returns the client file for the WebSocket endpoints in +// planned. It returns nil when the service has no WebSocket endpoint. +func websocketClientFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasWebSocket { return nil } svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket JSON-RPC client", svc.Name()) + title := fmt.Sprintf("%s WebSocket JSON-RPC client", planned.name) - // Build imports list for WebSocket clients - imports := make([]*codegen.ImportSpec, 0, 15) + // These imports are shared by every generated WebSocket method stream. + imports := make([]*codegen.ImportSpec, 0, 11) imports = append(imports, &codegen.ImportSpec{Path: "bytes"}, &codegen.ImportSpec{Path: "context"}, @@ -29,38 +69,53 @@ func websocketClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.Servic &codegen.ImportSpec{Path: "fmt"}, &codegen.ImportSpec{Path: "io"}, &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strconv"}, &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "sync/atomic"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "client", imports), } - // Add common error handling types for all streams + // Generate the error types used by every method stream. sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-websocket-stream-error-types", Source: jsonrpcTemplates.Read(websocketStreamErrorTypesT), + Data: &websocketErrorTemplateData{ + Type: planned.clientNames.streamErrorType, + Connection: planned.clientNames.streamErrorConnection, + Protocol: planned.clientNames.streamErrorProtocol, + Parsing: planned.clientNames.streamErrorParsing, + Orphaned: planned.clientNames.streamErrorOrphaned, + Timeout: planned.clientNames.streamErrorTimeout, + Handler: planned.clientNames.streamErrorHandler, + }, }) - // Process only WebSocket endpoints and generate stream implementations only - for _, e := range data.Endpoints { - if !httpcodegen.IsWebSocketEndpoint(e) { + // Generate a method stream only for endpoints carried over WebSocket. + for _, e := range planned.endpoints { + if !isJSONRPCWebSocketEndpoint(e) { continue } - // Add stream implementation (endpoint methods are in client.go) + funcs := viewedResultFuncs(planned) + funcs["lowerInitial"] = lowerInitial + // client.go creates this method stream and websocket.go implements it. sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-websocket-client-stream", Source: jsonrpcTemplates.Read(websocketClientStreamT), - Data: e.ClientWebSocket, + Data: &websocketClientTemplateData{ + JSONRPCWebSocketData: e.ClientWebSocket, + Endpoint: e, + Pending: e.websocketPending, + Result: e.websocketResult, + Connection: planned.clientNames.websocketConnection, + RequestOwner: planned.clientNames.websocketRequestOwner, + ClosedError: planned.clientNames.websocketClosedError, + }, + FuncMap: funcs, }) } @@ -70,13 +125,15 @@ func websocketClientFile(svc *expr.HTTPServiceExpr, services *httpcodegen.Servic } } -// allErrors returns all errors for the given service. -func allErrors(data *httpcodegen.ServiceData) []*httpcodegen.ErrorData { +// allErrors returns each named service error once so the generated WebSocket +// server writes one branch for each error. +func allErrors(data httpcodegen.JSONRPCServiceSnapshot) []*httpcodegen.JSONRPCErrorData { seen := make(map[string]struct{}) - var errors []*httpcodegen.ErrorData + var errors []*httpcodegen.JSONRPCErrorData for _, e := range data.Endpoints { for _, gerr := range e.Errors { - for _, err := range gerr.Errors { + for index := range gerr.Errors { + err := &gerr.Errors[index] if _, ok := seen[err.Name]; ok { continue } diff --git a/jsonrpc/codegen/websocket_connection_runtime_test.go b/jsonrpc/codegen/websocket_connection_runtime_test.go new file mode 100644 index 0000000000..5e0bcd5daf --- /dev/null +++ b/jsonrpc/codegen/websocket_connection_runtime_test.go @@ -0,0 +1,699 @@ +// This file renders a JSON-RPC WebSocket client and runs its request, response, +// timeout, close, and concurrent-send tests with Go's race detector. +package codegen_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedWebSocketClientAndServer renders the Chat WebSocket packages +// and runs their request, response, failure, and close tests with Go's race +// detector. +func TestGeneratedWebSocketClientAndServer(t *testing.T) { + dir := renderWebSocketRuntimeModule(t) + clientDir := filepath.Join(dir, "jsonrpc", "chat", "client") + serverDir := filepath.Join(dir, "jsonrpc", "chat", "server") + require.NoError(t, os.WriteFile(filepath.Join(clientDir, "websocket_client_test.go"), []byte(websocketClientTest), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(serverDir, "websocket_server_test.go"), []byte(websocketServerTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "./jsonrpc/chat/client", "./jsonrpc/chat/server") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// renderWebSocketRuntimeModule writes the service, client, and server files +// needed by the generated tests. The temporary module uses this Goa checkout. +func renderWebSocketRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, websocketConnectionDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: plan, + }) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: plan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(plan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + goMod := fmt.Sprintf("module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", repository) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) + return moduleDir +} + +const websocketClientTest = `package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + chat "generated.local/gen/chat" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +type contextKey string + +type callbackResult struct { + contextValue any + err error +} + +type errorEvent struct { + type_ jsonrpc.StreamErrorType + contextValue any + err error + response *jsonrpc.RawResponse +} + +type routedResponse struct { + stream string + response *jsonrpc.RawResponse +} + +func TestMethodCloseWhileRecvWaitsForResponse(t *testing.T) { + client, conn, stop := newClientConnection(t, nil, serverConfig{}) + defer stop() + defer closeClient(t, client) + for range 100 { + streamCtx, cancel := context.WithCancel(context.Background()) + stream := &EchoClientStream{ + conn: conn, + owner: &websocketRequestOwner{}, + ctx: streamCtx, + cancel: cancel, + pendingReady: make(chan struct{}, 1), + } + pending := &echoClientStreamPendingRequest{ + resultChan: make(chan echoClientStreamStreamResult, 1), + } + id, err := conn.sendRequest(context.Background(), request("close-pending"), stream.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { + stream.completeResponse(ctx, pending, response, err) + }) + if err != nil { + t.Fatal(err) + } + pending.id = id + stream.enqueuePending(pending) + received := make(chan error, 1) + go func() { + _, err := stream.Recv() + received <- err + }() + if err := stream.Close(); err != nil { + t.Fatal(err) + } + if err := receive(t, received); err != errWebsocketMethodStreamClosed { + t.Fatalf("Recv after Close returned %v", err) + } + } +} + +func TestMethodCloseWhileRecvWaitsForFirstSend(t *testing.T) { + client, conn, stop := newClientConnection(t, nil, serverConfig{}) + defer stop() + defer closeClient(t, client) + for range 100 { + streamCtx, cancel := context.WithCancel(context.Background()) + stream := &EchoClientStream{ + conn: conn, + owner: &websocketRequestOwner{}, + ctx: streamCtx, + cancel: cancel, + pendingReady: make(chan struct{}, 1), + } + started := make(chan struct{}) + received := make(chan error, 1) + go func() { + close(started) + _, err := stream.Recv() + received <- err + }() + <-started + if err := stream.Close(); err != nil { + t.Fatal(err) + } + if err := receive(t, received); err != errWebsocketMethodStreamClosed { + t.Fatalf("Recv before Send returned %v after Close", err) + } + } +} + +func TestConcurrentMethodStreamsReceiveTheirResponses(t *testing.T) { + client, conn, stop := newClientConnection(t, nil, serverConfig{reverseResponses: true}) + defer stop() + defer closeClient(t, client) + + type sentRequest struct { + stream string + id string + err error + } + routed := make(chan routedResponse, 2) + sent := make(chan sentRequest, 2) + start := make(chan struct{}) + for _, stream := range []string{"alpha", "beta"} { + stream := stream + go func() { + <-start + id, err := conn.sendRequest(context.Background(), request(stream), &websocketRequestOwner{}, func(_ context.Context, response *jsonrpc.RawResponse, err error) { + if err != nil { + t.Errorf("complete %s: %v", stream, err) + return + } + routed <- routedResponse{stream: stream, response: response} + }) + sent <- sentRequest{stream: stream, id: id, err: err} + }() + } + close(start) + + ids := make(map[string]string, 2) + for range 2 { + request := receive(t, sent) + if request.err != nil { + t.Fatalf("send %s: %v", request.stream, request.err) + } + ids[request.stream] = request.id + } + if ids["alpha"] == ids["beta"] { + t.Fatalf("connection reused request ID %q", ids["alpha"]) + } + for range 2 { + response := receive(t, routed) + if got := jsonrpc.IDToString(response.response.ID); got != ids[response.stream] { + t.Errorf("%s response routed with ID %q, want %q", response.stream, got, ids[response.stream]) + } + if !strings.Contains(string(response.response.Result), response.stream) { + t.Errorf("%s callback received another method's result: %s", response.stream, response.response.Result) + } + } +} + +func TestClosedMethodStreamRejectsRequestsAndNotifications(t *testing.T) { + client, conn, stop := newClientConnection(t, nil, serverConfig{}) + defer stop() + defer closeClient(t, client) + owner := &websocketRequestOwner{} + ctx := context.WithValue(context.Background(), contextKey("request"), "closed-owner") + completed := make(chan callbackResult, 1) + _, err := conn.sendRequest(ctx, request("first"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { + if terminalErr := conn.terminalError(); terminalErr != nil { + t.Errorf("owner close made connection terminal: %v", terminalErr) + } + completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} + }) + if err != nil { + t.Fatal(err) + } + conn.closeOwner(owner) + result := receive(t, completed) + if result.contextValue != "closed-owner" || result.err == nil { + t.Fatalf("unexpected completion: %#v", result) + } + if _, err := conn.sendRequest(ctx, request("after-close"), owner, func(context.Context, *jsonrpc.RawResponse, error) {}); err == nil || !strings.Contains(err.Error(), "stream is closed") { + t.Fatalf("request after close error = %v", err) + } + if err := conn.sendNotification(ctx, request("after-close"), owner); err == nil || !strings.Contains(err.Error(), "stream is closed") { + t.Fatalf("notification after close error = %v", err) + } + conn.stateMu.Lock() + pending := len(conn.pending) + conn.stateMu.Unlock() + if pending != 0 { + t.Fatalf("closed owner retained %d pending requests", pending) + } +} + +func TestRequestTimeoutReturnsWithoutRecv(t *testing.T) { + events := make(chan errorEvent, 4) + var conn *websocketClientConn + client, gotConn, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { + if terminalErr := connTerminalError(conn); terminalErr != nil { + t.Errorf("timeout made connection terminal: %v", terminalErr) + } + events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} + }, serverConfig{}, jsonrpc.WithRequestTimeout(20*time.Millisecond)) + conn = gotConn + defer stop() + defer closeClient(t, client) + owner := &websocketRequestOwner{} + ctx := context.WithValue(context.Background(), contextKey("request"), "timeout-request") + completed := make(chan callbackResult, 1) + _, err := conn.sendRequest(ctx, request("timeout"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { + if terminalErr := conn.terminalError(); terminalErr != nil { + t.Errorf("timeout completion made connection terminal: %v", terminalErr) + } + completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} + }) + if err != nil { + t.Fatal(err) + } + result := receive(t, completed) + if result.contextValue != "timeout-request" || result.err == nil || !strings.Contains(result.err.Error(), "timed out") { + t.Fatalf("unexpected timeout completion: %#v", result) + } + event := receive(t, events) + if event.type_ != jsonrpc.StreamErrorTimeout || event.contextValue != "timeout-request" { + t.Fatalf("unexpected timeout event: %#v", event) + } + conn.stateMu.Lock() + pending := len(conn.pending) + conn.stateMu.Unlock() + if pending != 0 { + t.Fatalf("timeout retained %d pending requests", pending) + } +} + +func TestConnectionFailureUsesConnectionContext(t *testing.T) { + events := make(chan errorEvent, 8) + closeAfterRequest := make(chan struct{}) + var conn *websocketClientConn + client, gotConn, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { + if conn != nil { + if terminalErr := conn.terminalError(); terminalErr == nil { + t.Error("connection error callback ran before terminal state") + } + } + events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} + }, serverConfig{requestHook: closeAfterRequest}) + conn = gotConn + defer stop() + defer closeClient(t, client) + owner := &websocketRequestOwner{} + ctx := context.WithValue(context.Background(), contextKey("request"), "failed-request") + completed := make(chan callbackResult, 1) + _, err := conn.sendRequest(ctx, request("fail"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { + if terminalErr := conn.terminalError(); terminalErr == nil { + t.Error("failure completion ran before terminal state") + } + completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} + }) + if err != nil { + t.Fatal(err) + } + close(closeAfterRequest) + result := receive(t, completed) + if result.contextValue != "failed-request" || result.err == nil { + t.Fatalf("unexpected failure completion: %#v", result) + } + for { + event := receive(t, events) + if event.type_ == jsonrpc.StreamErrorConnection { + if event.contextValue != nil { + t.Fatalf("connection event inherited request context: %#v", event.contextValue) + } + break + } + } + if err := conn.sendNotification(context.Background(), request("failed"), &websocketRequestOwner{}); err == nil { + t.Fatal("terminal connection accepted a notification") + } +} + +func TestEchoRecvReturnsConnectionFailure(t *testing.T) { + closeAfterRequest := make(chan struct{}) + client, conn, stop := newClientConnection(t, nil, serverConfig{requestHook: closeAfterRequest}) + defer stop() + defer closeClient(t, client) + streamCtx, cancel := context.WithCancel(context.Background()) + stream := &EchoClientStream{ + conn: conn, + owner: &websocketRequestOwner{}, + ctx: streamCtx, + cancel: cancel, + pendingReady: make(chan struct{}, 1), + } + if err := stream.Send(&chat.EchoPayload{}); err != nil { + t.Fatal(err) + } + received := make(chan error, 1) + go func() { + _, err := stream.Recv() + received <- err + }() + close(closeAfterRequest) + err := receive(t, received) + if err == errWebsocketMethodStreamClosed { + t.Fatalf("Recv returned the method Close error after a socket failure: %v", err) + } + connectionErr := conn.terminalError() + if connectionErr == nil { + t.Fatal("connection has no error after the server closed the socket") + } + if err != connectionErr { + t.Fatalf("Recv error = %v, want the connection error %v", err, connectionErr) + } +} + +func TestServerNotificationsAndNullIDsReportCorrectErrors(t *testing.T) { + events := make(chan errorEvent, 8) + serverMessages := make(chan []any, 1) + serverMessages <- []any{ + map[string]any{"jsonrpc": "2.0", "method": "tick", "params": map[string]any{"value": 1}}, + map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32603, "message": "failed"}}, + } + client, _, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { + events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} + }, serverConfig{messages: serverMessages}) + defer stop() + defer closeClient(t, client) + notification := receive(t, events) + if notification.type_ != jsonrpc.StreamErrorNotification || !strings.Contains(notification.err.Error(), "tick") { + t.Fatalf("method notification misclassified: %#v", notification) + } + nullID := receive(t, events) + if nullID.type_ != jsonrpc.StreamErrorProtocol || nullID.response == nil || nullID.response.Error == nil { + t.Fatalf("null-ID error misclassified: %#v", nullID) + } +} + +func TestMethodCloseRejectsConcurrentRequests(t *testing.T) { + client, conn, stop := newClientConnection(t, nil, serverConfig{}) + defer stop() + defer closeClient(t, client) + owner := &websocketRequestOwner{} + var sends sync.WaitGroup + for i := 0; i < 32; i++ { + sends.Add(1) + go func(index int) { + defer sends.Done() + _, err := conn.sendRequest(context.Background(), request(fmt.Sprintf("race-%d", index)), owner, func(context.Context, *jsonrpc.RawResponse, error) {}) + if err != nil && !strings.Contains(err.Error(), "stream is closed") { + t.Errorf("concurrent send: %v", err) + } + }(i) + } + conn.closeOwner(owner) + sends.Wait() + if _, err := conn.sendRequest(context.Background(), request("after-race"), owner, func(context.Context, *jsonrpc.RawResponse, error) {}); err == nil { + t.Fatal("closed owner accepted request after concurrent sends") + } + conn.stateMu.Lock() + pending := len(conn.pending) + conn.stateMu.Unlock() + if pending != 0 { + t.Fatalf("owner close race retained %d requests", pending) + } +} + +type serverConfig struct { + requestHook <-chan struct{} + messages <-chan []any + reverseResponses bool +} + +func newClientConnection(t *testing.T, handler jsonrpc.StreamErrorHandler, config serverConfig, options ...jsonrpc.StreamConfigOption) (*Client, *websocketClientConn, func()) { + t.Helper() + streamOptions := append([]jsonrpc.StreamConfigOption(nil), options...) + if handler != nil { + streamOptions = append(streamOptions, jsonrpc.WithErrorHandler(handler)) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { + if err := ws.Close(); err != nil { + t.Errorf("close server WebSocket: %v", err) + } + }() + if config.messages != nil { + for _, message := range <-config.messages { + if err := ws.WriteJSON(message); err != nil { + return + } + } + } + if config.reverseResponses { + requests := make([]jsonrpc.RawRequest, 2) + for i := range requests { + if err := ws.ReadJSON(&requests[i]); err != nil { + return + } + } + for i := len(requests) - 1; i >= 0; i-- { + result := map[string]any{"method": requests[i].Method} + if err := ws.WriteJSON(jsonrpc.MakeSuccessResponse(requests[i].ID, result)); err != nil { + return + } + } + } + for { + var message any + if err := ws.ReadJSON(&message); err != nil { + return + } + if config.requestHook != nil { + <-config.requestHook + return + } + } + })) + host := strings.TrimPrefix(server.URL, "http://") + client := NewClient("http", host, http.DefaultClient, goahttp.RequestEncoder, goahttp.ResponseDecoder, false, websocket.DefaultDialer, nil, streamOptions...) + conn, err := client.getConn(context.Background()) + if err != nil { + server.Close() + t.Fatal(err) + } + return client, conn, server.Close +} + +func request(method string) *jsonrpc.Request { + return &jsonrpc.Request{JSONRPC: "2.0", Method: method} +} + +func receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case value := <-channel: + return value + case <-time.After(3 * time.Second): + var zero T + t.Fatal("timed out waiting for generated WebSocket lifecycle event") + return zero + } +} + +func connTerminalError(conn *websocketClientConn) error { + if conn == nil { + return nil + } + return conn.terminalError() +} + +func closeClient(t *testing.T, client *Client) { + t.Helper() + if err := client.Close(); err != nil { + t.Errorf("close client: %v", err) + } +} +` + +const websocketServerTest = `package server + +import ( + "bufio" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +type trackedConn struct { + net.Conn + closed chan struct{} + once sync.Once +} + +type trackingWriter struct { + http.ResponseWriter + connections chan<- *trackedConn +} + +func TestCloseSendsNormalCodeWithoutWaitingForDataWrite(t *testing.T) { + stream, peer, _, stop := newServerConnection(t) + defer stop() + peerResult := make(chan error, 1) + go func() { + _, _, err := peer.ReadMessage() + peerResult <- err + }() + stream.writeMu.Lock() + defer stream.writeMu.Unlock() + closeResult := make(chan error, 1) + go func() { + closeResult <- stream.Close() + }() + if err := receiveServer(t, closeResult); err != nil { + t.Fatal(err) + } + err := receiveServer(t, peerResult) + var closeErr *websocket.CloseError + if !errors.As(err, &closeErr) || closeErr.Code != websocket.CloseNormalClosure { + t.Fatalf("peer close error = %v, want code %d", err, websocket.CloseNormalClosure) + } +} + +func TestCloseClosesSocketWhenNormalMessageFails(t *testing.T) { + stream, peer, connection, stop := newServerConnection(t) + defer stop() + tcp, ok := peer.UnderlyingConn().(*net.TCPConn) + if !ok { + t.Fatalf("peer connection type = %T, want *net.TCPConn", peer.UnderlyingConn()) + } + if err := tcp.SetLinger(0); err != nil { + t.Fatal(err) + } + if err := peer.Close(); err != nil { + t.Fatal(err) + } + if err := stream.conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, _, err := stream.conn.ReadMessage(); err == nil { + t.Fatal("server read succeeded after the peer reset the connection") + } + err := stream.Close() + if err == nil || !strings.Contains(err.Error(), "write normal WebSocket close message") { + t.Fatalf("Close error = %v, want normal-close write failure", err) + } + select { + case <-connection.closed: + case <-time.After(3 * time.Second): + t.Fatal("Close did not close the server socket after the control write failed") + } +} + +func (c *trackedConn) Close() error { + c.once.Do(func() { + close(c.closed) + }) + return c.Conn.Close() +} + +func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("HTTP response writer does not support connection takeover") + } + connection, buffer, err := hijacker.Hijack() + if err != nil { + return nil, nil, err + } + tracked := &trackedConn{Conn: connection, closed: make(chan struct{})} + w.connections <- tracked + return tracked, buffer, nil +} + +func newServerConnection(t *testing.T) (*chatStream, *websocket.Conn, *trackedConn, func()) { + t.Helper() + serverConnections := make(chan *websocket.Conn, 1) + trackedConnections := make(chan *trackedConn, 1) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writer := &trackingWriter{ResponseWriter: w, connections: trackedConnections} + connection, err := (&websocket.Upgrader{}).Upgrade(writer, r, nil) + if err != nil { + t.Errorf("upgrade server connection: %v", err) + return + } + serverConnections <- connection + <-release + })) + peer, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + server.Close() + t.Fatal(err) + } + connection := receiveServer(t, serverConnections) + tracked := receiveServer(t, trackedConnections) + var once sync.Once + stop := func() { + once.Do(func() { + close(release) + if err := peer.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + t.Errorf("close peer WebSocket: %v", err) + } + server.Close() + }) + } + return &chatStream{conn: connection}, peer, tracked, stop +} + +func receiveServer[T any](t *testing.T, values <-chan T) T { + t.Helper() + select { + case value := <-values: + return value + case <-time.After(3 * time.Second): + var zero T + t.Fatal("timed out waiting for generated WebSocket server test") + return zero + } +} +` diff --git a/jsonrpc/codegen/websocket_connection_test.go b/jsonrpc/codegen/websocket_connection_test.go new file mode 100644 index 0000000000..6501da879e --- /dev/null +++ b/jsonrpc/codegen/websocket_connection_test.go @@ -0,0 +1,118 @@ +// This file verifies that every generated JSON-RPC method uses the same +// WebSocket, while one reader matches each response to the request with that ID. +package codegen_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// websocketConnectionDSL defines one bidirectional WebSocket method so these +// tests do not depend on unrelated HTTP, SSE, or unary JSON-RPC generation. +var websocketConnectionDSL = func() { + API("websocket-connection", func() { + JSONRPC(func() {}) + }) + Service("Chat", func() { + JSONRPC(func() { + Path("/ws") + }) + Method("echo", func() { + StreamingPayload(func() { + ID("id", String, "Request ID") + Attribute("msg", String) + }) + StreamingResult(func() { + ID("id", String, "Request ID") + Attribute("echo", String) + }) + JSONRPC(func() {}) + }) + }) +} + +// TestGeneratedWebSocketSharesOneConnection proves that one generated client +// reads and writes its shared WebSocket in one place. Each method stream keeps +// the requests that its Recv calls will return. +func TestGeneratedWebSocketSharesOneConnection(t *testing.T) { + root := expr.RunDSL(t, websocketConnectionDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: plan}) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: plan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + dir := t.TempDir() + for _, file := range append(jsonPlans[0].ClientFiles(), jsonPlans[0].ServerFiles()...) { + _, err := file.Render(dir) + require.NoError(t, err) + } + + client := readGeneratedFile(t, dir, "gen/jsonrpc/chat/client/client.go") + clientStream := readGeneratedFile(t, dir, "gen/jsonrpc/chat/client/websocket.go") + serverStream := readGeneratedFile(t, dir, "gen/jsonrpc/chat/server/websocket.go") + + require.Contains(t, client, "websocketClientConn struct") + require.Contains(t, client, "atomic.Uint64") + require.Contains(t, client, "pending map[string]*websocketPendingRequest") + require.Contains(t, client, "go conn.readResponses()") + require.Contains(t, client, "case owner.closed.Load():") + require.Contains(t, client, "time.AfterFunc(c.config.RequestTimeout") + require.Contains(t, client, "request.complete(request.ctx, nil, err)") + require.Contains(t, client, "func (c *websocketClientConn) closeSocket() error") + require.Contains(t, client, "var errWebsocketMethodStreamClosed = errors.New(") + require.NotContains(t, client, "websocket.PingMessage") + require.Equal(t, 1, strings.Count(client, "ReadJSON(")) + require.NotContains(t, clientStream, "ReadJSON(") + require.NotContains(t, clientStream, "idGenerator") + require.NotContains(t, clientStream, "writeMu") + require.Contains(t, clientStream, "*websocketClientConn") + require.Contains(t, clientStream, "s.conn.closeOwner(s.owner") + require.Contains(t, clientStream, "return errWebsocketMethodStreamClosed") + require.NotContains(t, clientStream, "EchoClientStreamPendingRequest") + require.NotContains(t, clientStream, "EchoClientStreamStreamResult") + require.Contains(t, clientStream, "echoClientStreamPendingRequest") + require.Contains(t, clientStream, "echoClientStreamStreamResult") + + require.Contains(t, serverStream, "writeMu sync.Mutex") + require.Contains(t, serverStream, "func (s *chatStream) writeJSON(") + require.Equal(t, 1, strings.Count(serverStream, "s.conn.WriteJSON(")) + require.Contains(t, serverStream, "websocket.FormatCloseMessage(websocket.CloseNormalClosure") + require.Contains(t, serverStream, "time.Now().Add(time.Second)") + require.Contains(t, serverStream, "closeErr := s.conn.Close()") + require.Contains(t, serverStream, "return errors.Join(controlErr, closeErr)") + require.NotContains(t, serverStream, "func (s *chatStream) Close() error {\n\ts.writeMu.Lock()") +} + +// readGeneratedFile returns generated source inspected by the checks above. +func readGeneratedFile(t *testing.T, dir, path string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, path)) + require.NoError(t, err) + return string(content) +} diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go index aca42dd362..a36e71d3a9 100644 --- a/jsonrpc/codegen/websocket_server.go +++ b/jsonrpc/codegen/websocket_server.go @@ -1,5 +1,5 @@ -// This file renders one JSON-RPC WebSocket server implementation and leaves -// service-specific import attachment to the owning file builder. +// This file renders the JSON-RPC WebSocket server for each service and adds +// the imports used by that service's methods. package codegen import ( @@ -7,25 +7,42 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// websocketServerFile returns the file implementing the JSON-RPC WebSocket server -// streaming implementation if any. It follows the exact same pattern as the encode/decode -// files: get the HTTP file and modify it for JSON-RPC. -func websocketServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { +type ( + // websocketServerTemplateData stores the service values and shared stream + // name used by one WebSocket server. + websocketServerTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // Stream is the WebSocket used by all methods in this server. + Stream *codegen.NameDeclaration + } +) + +// websocketServerFile returns the generated WebSocket server when the service +// has at least one WebSocket method. +func websocketServerFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasWebSocket { return nil } funcs := map[string]any{ - "lowerInitial": lowerInitial, - "allErrors": allErrors, - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, + "lowerInitial": lowerInitial, + "allErrors": allErrors, + "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, + "websocketServerStreamName": planned.websocketServerStreamName, + "websocketWrapperName": planned.websocketWrapperName, + } + for name, function := range viewedResultFuncs(planned) { + funcs[name] = function } svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) + renderData := &websocketServerTemplateData{ + JSONRPCServiceSnapshot: data, + Stream: planned.serverNames.websocketStream, + } + title := fmt.Sprintf("%s WebSocket server streaming", planned.name) imports := make([]*codegen.ImportSpec, 0, 14) imports = append(imports, &codegen.ImportSpec{Path: "context"}, @@ -41,38 +58,38 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.Servic codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + data.ServiceImport(), ) sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { Name: "jsonrpc-server-websocket-struct", Source: jsonrpcTemplates.Read(websocketServerStreamT), - Data: data, + Data: renderData, FuncMap: funcs, }, { Name: "jsonrpc-server-websocket-stream-wrapper", Source: jsonrpcTemplates.Read(websocketServerStreamWrapperT), - Data: data, + Data: renderData, FuncMap: funcs, }, { Name: "jsonrpc-server-websocket-send", Source: jsonrpcTemplates.Read(websocketServerSendT), - Data: data, + Data: renderData, FuncMap: funcs, }, { Name: "jsonrpc-server-websocket-recv", Source: jsonrpcTemplates.Read(websocketServerRecvT), - Data: data, + Data: renderData, FuncMap: funcs, }, { Name: "jsonrpc-server-websocket-close", Source: jsonrpcTemplates.Read(websocketServerCloseT), - Data: data, + Data: renderData, FuncMap: funcs, }, } @@ -82,3 +99,19 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *httpcodegen.Servic SectionTemplates: sections, } } + +// websocketServerStreamName returns the WebSocket type shared by all methods +// in this service. +func (s *servicePlan) websocketServerStreamName() string { + return s.serverNames.websocketStream.Name() +} + +// websocketWrapperName returns the type that gives one method access to its +// request ID and selected result view. +func (s *servicePlan) websocketWrapperName(method string) string { + names := s.endpointNames[method] + if names == nil || names.websocketWrapper == nil { + panic("JSON-RPC WebSocket wrapper requested for method " + method) + } + return names.websocketWrapper.Name() +} From c0690e32175f4d2f73f04b2d087afec55b409191 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sat, 22 Aug 2026 02:27:05 -0700 Subject: [PATCH 32/43] fix(codegen): preserve exact protobuf Go names --- Makefile | 5 +- codegen/generated_types_test.go | 23 ++ codegen/name_declaration.go | 12 +- grpc/codegen/protoc_names.go | 275 +++++++++++++++++++++++ grpc/codegen/protoc_names_test.go | 169 ++++++++++++++ grpc/codegen/testdata/protoc_names.proto | 78 +++++++ 6 files changed, 555 insertions(+), 7 deletions(-) create mode 100644 grpc/codegen/protoc_names.go create mode 100644 grpc/codegen/protoc_names_test.go create mode 100644 grpc/codegen/testdata/protoc_names.proto diff --git a/Makefile b/Makefile index c839d3adaf..94462f7536 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,8 @@ PROTOC_DEST=$(GOBIN_DIR)/$(PROTOC_BIN) # Only list test and build dependencies # Standard dependencies are installed via go get DEPEND=\ - google.golang.org/protobuf/cmd/protoc-gen-go@latest \ - google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 all: lint test integration-test @@ -177,4 +177,3 @@ release-plugins: git tag v$(MAJOR).$(MINOR).$(BUILD) && \ git push origin v$(MAJOR) && \ git push origin v$(MAJOR).$(MINOR).$(BUILD) - diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index 08cd7026cc..e3db9ca04e 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -89,6 +89,29 @@ func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { } } +// TestGeneratedPackagePreservesExactGoNames checks that names produced by +// another Go generator are stored without changing their spelling. +func TestGeneratedPackagePreservesExactGoNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + private := NewExactName(NameType, "api2HttpClient") + handler := NewExactName(NameFunction, "_API2_HTTPHandler") + require.NoError(t, types.DeclareName(private)) + require.NoError(t, types.DeclareName(handler)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "api2HttpClient", private.Name()) + require.Equal(t, "_API2_HTTPHandler", handler.Name()) +} + +// TestGeneratedPackageRejectsInvalidExactGoName checks that an exact name +// must already be a valid Go identifier. +func TestGeneratedPackageRejectsInvalidExactGoName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + err := types.DeclareName(NewExactName(NameType, "not a name")) + require.EqualError(t, err, `package name "not a name" is not a valid Go identifier`) +} + // TestDependentNameUsesFrozenBase verifies that companion declarations derive // their spelling from the exact final name selected for their base declaration. func TestDependentNameUsesFrozenBase(t *testing.T) { diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go index c2896c6790..7eaeddaad9 100644 --- a/codegen/name_declaration.go +++ b/codegen/name_declaration.go @@ -5,6 +5,7 @@ package codegen import ( "fmt" + "go/token" "reflect" "strings" ) @@ -67,12 +68,12 @@ const ( UnexportedName ) -// NewExactName creates an authored or external declaration whose exported Go -// identifier must not change. The owning generated package rejects collisions. -func NewExactName(kind PackageNameKind, preferred string) *NameDeclaration { +// NewExactName creates a declaration whose valid Go name must not change. The +// owning generated package rejects invalid names and collisions. +func NewExactName(kind PackageNameKind, name string) *NameDeclaration { return &NameDeclaration{ kind: kind, - preferred: Goify(preferred, true), + preferred: name, exact: true, } } @@ -161,6 +162,9 @@ func validateNameDeclaration(declaration *NameDeclaration) error { if declaration.preferredName() == "" { return fmt.Errorf("package name must not be empty") } + if declaration.exact && !token.IsIdentifier(declaration.preferred) { + return fmt.Errorf("package name %q is not a valid Go identifier", declaration.preferred) + } return nil } diff --git a/grpc/codegen/protoc_names.go b/grpc/codegen/protoc_names.go new file mode 100644 index 0000000000..d44789fe96 --- /dev/null +++ b/grpc/codegen/protoc_names.go @@ -0,0 +1,275 @@ +// This file reads the Go names that the supported protobuf tools assign to a +// compiled protobuf file. The gRPC planner uses these names when it records +// declarations that later files define or call. +package codegen + +import ( + "fmt" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +type ( + // protocNameRules reads names using one supported pair of protobuf tools. + protocNameRules struct{} + + // protocNames stores each Go name under the protobuf item and the way that + // generated code uses it. + protocNames struct { + values map[protocNameKey]string + } + + // protocNameKey identifies one Go name produced for a protobuf item. + protocNameKey struct { + descriptor string + role protocNameRole + } + + // protocNameRole identifies one Go declaration or field produced by the + // supported protobuf tools. + protocNameRole uint8 +) + +const ( + protocMessageName protocNameRole = iota + 1 + protocFieldName + protocEnumName + protocEnumValueName + protocOneofFieldName + protocOneofInterfaceName + protocOneofWrapperName + protocServiceClientName + protocServiceClientStructName + protocServiceClientConstructorName + protocServiceServerName + protocServiceUnimplementedServerName + protocServiceUnsafeServerName + protocServiceRegisterName + protocServiceDescriptorName + protocMethodName + protocMethodFullName + protocMethodHandlerName + protocMethodClientStreamName + protocMethodServerStreamName +) + +const protocNameVersionGo1_36GRPC1_6 = "protoc-gen-go-v1.36/protoc-gen-go-grpc-v1.6" + +// newProtocNameRules returns the name reader for a supported protobuf tool +// pair. An unknown value is rejected because its Go names may differ. +func newProtocNameRules(version string) (*protocNameRules, error) { + if version != protocNameVersionGo1_36GRPC1_6 { + return nil, fmt.Errorf("unsupported protobuf Go naming version %q", version) + } + return &protocNameRules{}, nil +} + +// file returns every Go name used for messages, fields, enumerations, oneofs, +// services, and methods in descriptor. +func (r *protocNameRules) file(descriptor *descriptorpb.FileDescriptorProto) (*protocNames, error) { + request := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{descriptor.GetName()}, + ProtoFile: []*descriptorpb.FileDescriptorProto{descriptor}, + } + plugin, err := (protogen.Options{}).New(request) + if err != nil { + return nil, fmt.Errorf("read protobuf Go names: %w", err) + } + if len(plugin.Files) != 1 || plugin.Files[0].Desc.Path() != descriptor.GetName() { + return nil, fmt.Errorf("read protobuf Go names: file %q was not returned", descriptor.GetName()) + } + + names := &protocNames{values: make(map[protocNameKey]string)} + file := plugin.Files[0] + for _, enum := range file.Enums { + if err := names.addEnum(enum); err != nil { + return nil, err + } + } + for _, message := range file.Messages { + if err := names.addMessage(message); err != nil { + return nil, err + } + } + for _, service := range file.Services { + if err := names.addService(service); err != nil { + return nil, err + } + } + return names, nil +} + +// lookup returns the Go name stored for descriptor and role. +func (n *protocNames) lookup(descriptor string, role protocNameRole) (string, bool) { + name, ok := n.values[protocNameKey{descriptor: descriptor, role: role}] + return name, ok +} + +// String returns the short role name used in test and error labels. +func (r protocNameRole) String() string { + switch r { + case protocMessageName: + return "message" + case protocFieldName: + return "field" + case protocEnumName: + return "enum" + case protocEnumValueName: + return "enum value" + case protocOneofFieldName: + return "oneof field" + case protocOneofInterfaceName: + return "oneof interface" + case protocOneofWrapperName: + return "oneof wrapper" + case protocServiceClientName: + return "service client" + case protocServiceClientStructName: + return "service client struct" + case protocServiceClientConstructorName: + return "service client constructor" + case protocServiceServerName: + return "service server" + case protocServiceUnimplementedServerName: + return "unimplemented service server" + case protocServiceUnsafeServerName: + return "unsafe service server" + case protocServiceRegisterName: + return "service register function" + case protocServiceDescriptorName: + return "service description" + case protocMethodName: + return "method" + case protocMethodFullName: + return "full method name" + case protocMethodHandlerName: + return "method handler" + case protocMethodClientStreamName: + return "client stream" + case protocMethodServerStreamName: + return "server stream" + default: + panic(fmt.Sprintf("unknown protobuf Go name role %d", r)) + } +} + +// addMessage stores one message, its nested declarations, fields, and oneofs. +func (n *protocNames) addMessage(message *protogen.Message) error { + if err := n.add(string(message.Desc.FullName()), protocMessageName, message.GoIdent.GoName); err != nil { + return err + } + for _, enum := range message.Enums { + if err := n.addEnum(enum); err != nil { + return err + } + } + for _, nested := range message.Messages { + if err := n.addMessage(nested); err != nil { + return err + } + } + for _, field := range message.Fields { + descriptor := string(field.Desc.FullName()) + if err := n.add(descriptor, protocFieldName, field.GoName); err != nil { + return err + } + if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() { + if err := n.add(descriptor, protocOneofWrapperName, field.GoIdent.GoName); err != nil { + return err + } + } + } + for _, oneof := range message.Oneofs { + if oneof.Desc.IsSynthetic() { + continue + } + descriptor := string(oneof.Desc.FullName()) + if err := n.add(descriptor, protocOneofFieldName, oneof.GoName); err != nil { + return err + } + if err := n.add(descriptor, protocOneofInterfaceName, "is"+oneof.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addEnum stores one enumeration and all of its values. +func (n *protocNames) addEnum(enum *protogen.Enum) error { + if err := n.add(string(enum.Desc.FullName()), protocEnumName, enum.GoIdent.GoName); err != nil { + return err + } + for _, value := range enum.Values { + if err := n.add(string(value.Desc.FullName()), protocEnumValueName, value.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addService stores the declarations written by protoc-gen-go-grpc v1.6 for +// one service and all of its methods. +func (n *protocNames) addService(service *protogen.Service) error { + descriptor := string(service.Desc.FullName()) + serviceName := service.GoName + declarations := []struct { + role protocNameRole + name string + }{ + {protocServiceClientName, serviceName + "Client"}, + {protocServiceClientStructName, protocGRPCUnexport(serviceName) + "Client"}, + {protocServiceClientConstructorName, "New" + serviceName + "Client"}, + {protocServiceServerName, serviceName + "Server"}, + {protocServiceUnimplementedServerName, "Unimplemented" + serviceName + "Server"}, + {protocServiceUnsafeServerName, "Unsafe" + serviceName + "Server"}, + {protocServiceRegisterName, "Register" + serviceName + "Server"}, + {protocServiceDescriptorName, serviceName + "_ServiceDesc"}, + } + for _, declaration := range declarations { + if err := n.add(descriptor, declaration.role, declaration.name); err != nil { + return err + } + } + for _, method := range service.Methods { + methodDescriptor := string(method.Desc.FullName()) + methodName := method.GoName + if err := n.add(methodDescriptor, protocMethodName, methodName); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodFullName, serviceName+"_"+methodName+"_FullMethodName"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodHandlerName, "_"+serviceName+"_"+methodName+"_Handler"); err != nil { + return err + } + if method.Desc.IsStreamingClient() || method.Desc.IsStreamingServer() { + if err := n.add(methodDescriptor, protocMethodClientStreamName, serviceName+"_"+methodName+"Client"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodServerStreamName, serviceName+"_"+methodName+"Server"); err != nil { + return err + } + } + } + return nil +} + +// add stores one name and rejects two values for the same protobuf item and +// role. +func (n *protocNames) add(descriptor string, role protocNameRole, name string) error { + key := protocNameKey{descriptor: descriptor, role: role} + if previous, ok := n.values[key]; ok { + return fmt.Errorf("protobuf item %q has two %s names, %q and %q", descriptor, role, previous, name) + } + n.values[key] = name + return nil +} + +// protocGRPCUnexport changes the first letter exactly as protoc-gen-go-grpc +// v1.6 does when it writes the private client type. +func protocGRPCUnexport(name string) string { + return strings.ToLower(name[:1]) + name[1:] +} diff --git a/grpc/codegen/protoc_names_test.go b/grpc/codegen/protoc_names_test.go new file mode 100644 index 0000000000..2061b0e621 --- /dev/null +++ b/grpc/codegen/protoc_names_test.go @@ -0,0 +1,169 @@ +// This file compares Goa's protobuf Go names with code generated by the +// supported protobuf tools. +package codegen + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +// TestProtocNameVersion rejects a name version that Goa does not support. +func TestProtocNameVersion(t *testing.T) { + _, err := newProtocNameRules("unknown") + require.EqualError(t, err, `unsupported protobuf Go naming version "unknown"`) +} + +// TestProtocDeclarationFamilies checks every generated name family that Goa +// uses in its gRPC client and server code. +func TestProtocDeclarationFamilies(t *testing.T) { + descriptor, declarations := generateProtocNameFixture(t) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + tests := []struct { + descriptor string + role protocNameRole + want string + }{ + {"goa.names.v1.api2_http_request", protocMessageName, "Api2HttpRequest"}, + {"goa.names.v1.api2_http_request.nested_api2", protocMessageName, "Api2HttpRequestNestedApi2"}, + {"goa.names.v1.wrapper_conflict._BranchValue", protocMessageName, "WrapperConflict_XBranchValue"}, + {"goa.names.v1.Explicit_HTTP2_Name", protocMessageName, "Explicit_HTTP2_Name"}, + {"goa.names.v1.api2_http_request.api_url", protocFieldName, "ApiUrl"}, + {"goa.names.v1.api2_http_request.api2_url", protocFieldName, "Api2Url"}, + {"goa.names.v1.api2_http_request.dns_2_server", protocFieldName, "Dns_2Server"}, + {"goa.names.v1.api2_http_request.x509_cert", protocFieldName, "X509Cert"}, + {"goa.names.v1.api2_http_request.reset", protocFieldName, "Reset_"}, + {"goa.names.v1.api2_http_request.string", protocFieldName, "String_"}, + {"goa.names.v1.api2_http_request.proto_message", protocFieldName, "ProtoMessage_"}, + {"goa.names.v1.api2_http_request.descriptor", protocFieldName, "Descriptor_"}, + {"goa.names.v1.Explicit_HTTP2_Name.Explicit_Field2_Name", protocFieldName, "Explicit_Field2_Name"}, + {"goa.names.v1.http_2_status", protocEnumName, "Http_2Status"}, + {"goa.names.v1.HTTP_2_STATUS_UNSPECIFIED", protocEnumValueName, "Http_2Status_HTTP_2_STATUS_UNSPECIFIED"}, + {"goa.names.v1.HTTP2_OK", protocEnumValueName, "Http_2Status_HTTP2_OK"}, + {"goa.names.v1.api2_http_request.nested_api2.state_2", protocEnumName, "Api2HttpRequestNestedApi2State_2"}, + {"goa.names.v1.api2_http_request.nested_api2.DNS2_READY", protocEnumValueName, "Api2HttpRequestNestedApi2_DNS2_READY"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofFieldName, "Result_2Kind"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofInterfaceName, "isApi2HttpRequest_Result_2Kind"}, + {"goa.names.v1.api2_http_request.http_2xx", protocOneofWrapperName, "Api2HttpRequest_Http_2Xx"}, + {"goa.names.v1.api2_http_request.api_url_value", protocOneofWrapperName, "Api2HttpRequest_ApiUrlValue"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofFieldName, "ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofInterfaceName, "isWrapperConflict_ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.branchValue", protocOneofWrapperName, "WrapperConflict_BranchValue"}, + {"goa.names.v1.wrapper_conflict.reset", protocOneofWrapperName, "WrapperConflict_Reset_"}, + {"goa.names.v1.api2_http_service", protocServiceClientName, "Api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientStructName, "api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientConstructorName, "NewApi2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceServerName, "Api2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnimplementedServerName, "UnimplementedApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnsafeServerName, "UnsafeApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceRegisterName, "RegisterApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceDescriptorName, "Api2HttpService_ServiceDesc"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodName, "GetUrl2"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodFullName, "Api2HttpService_GetUrl2_FullMethodName"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodHandlerName, "_Api2HttpService_GetUrl2_Handler"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodClientStreamName, "Api2HttpService_WatchDns2Client"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodServerStreamName, "Api2HttpService_WatchDns2Server"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodClientStreamName, "Api2HttpService_UploadApi2Client"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodServerStreamName, "Api2HttpService_UploadApi2Server"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodClientStreamName, "Api2HttpService_SyncX509Client"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodServerStreamName, "Api2HttpService_SyncX509Server"}, + } + + for _, test := range tests { + t.Run(test.descriptor+"/"+test.role.String(), func(t *testing.T) { + got, ok := names.lookup(test.descriptor, test.role) + require.True(t, ok, "name was not recorded") + require.Equal(t, test.want, got) + require.Contains(t, declarations, got, "the supported tools did not declare the predicted Go name") + }) + } +} + +// generateProtocNameFixture runs the supported tools and returns their input +// description and every Go name declared in their output. +func generateProtocNameFixture(t *testing.T) (*descriptorpb.FileDescriptorProto, map[string]struct{}) { + t.Helper() + directory := t.TempDir() + source, err := os.ReadFile(filepath.Join("testdata", "protoc_names.proto")) + require.NoError(t, err) + protoPath := filepath.Join(directory, "protoc_names.proto") + require.NoError(t, os.WriteFile(protoPath, source, 0o600)) + require.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) + + descriptorPath := filepath.Join(directory, "descriptor.pb") + args := append(defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)], + "--proto_path", directory, + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + return readProtocDescriptor(t, encoded), declaredGoNames( + t, + filepath.Join(directory, "protoc_names.pb.go"), + filepath.Join(directory, "protoc_names_grpc.pb.go"), + ) +} + +// readProtocDescriptorRequest converts a descriptor set into the request read +// by protobuf's public Go name code. +func readProtocDescriptor(t *testing.T, encoded []byte) *descriptorpb.FileDescriptorProto { + t.Helper() + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// declaredGoNames returns package names, fields, and receiver methods declared +// by the generated files. +func declaredGoNames(t *testing.T, paths ...string) map[string]struct{} { + t.Helper() + result := make(map[string]struct{}) + for _, path := range paths { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + require.NoError(t, err) + for _, declaration := range file.Decls { + switch declaration := declaration.(type) { + case *ast.GenDecl: + for _, specification := range declaration.Specs { + switch specification := specification.(type) { + case *ast.TypeSpec: + result[specification.Name.Name] = struct{}{} + ast.Inspect(specification.Type, func(node ast.Node) bool { + field, ok := node.(*ast.Field) + if ok { + for _, name := range field.Names { + result[name.Name] = struct{}{} + } + } + return true + }) + case *ast.ValueSpec: + for _, name := range specification.Names { + result[name.Name] = struct{}{} + } + } + } + case *ast.FuncDecl: + result[declaration.Name.Name] = struct{}{} + } + } + } + return result +} diff --git a/grpc/codegen/testdata/protoc_names.proto b/grpc/codegen/testdata/protoc_names.proto new file mode 100644 index 0000000000..3e4c038432 --- /dev/null +++ b/grpc/codegen/testdata/protoc_names.proto @@ -0,0 +1,78 @@ +// This schema records the Go names produced by Goa's supported protobuf tools. +syntax = "proto3"; + +package goa.names.v1; + +option go_package = "goa.design/goa/v3/grpc/codegen/testdata/protocnames;protocnames"; + +enum http_2_status { + HTTP_2_STATUS_UNSPECIFIED = 0; + HTTP2_OK = 1; + URL_2_READY = 2; +} + +message api2_http_request { + string api_url = 1; + string api2_url = 2; + string url2_id = 3; + string dns_2_server = 4; + string x509_cert = 5; + string type = 6; + string func = 7; + string var = 8; + string range = 9; + string reset = 10; + string string = 11; + string proto_message = 12; + string descriptor = 13; + string marshal = 14; + string unmarshal = 15; + string extension_range_array = 16; + string extension_map = 17; + string get_api_url = 18; + string message_ = 19; + string service_ = 20; + + oneof result_2_kind { + string http_2xx = 21; + nested_api2 api_url_value = 22; + bytes package_ = 23; + } + + message nested_api2 { + enum state_2 { + STATE_2_UNSPECIFIED = 0; + DNS2_READY = 1; + URL_2_READY = 2; + } + + state_2 state = 1; + } +} + +message wrapper_conflict { + message _BranchValue { + } + + string choice_value = 1; + + oneof choiceValue { + _BranchValue branchValue = 2; + string reset = 3; + } +} + +message Explicit_HTTP2_Name { + string Explicit_Field2_Name = 1; +} + +message stream_reply { + http_2_status status = 1; +} + +service api2_http_service { + rpc get_url2(api2_http_request) returns (stream_reply); + rpc watch_dns2(api2_http_request) returns (stream stream_reply); + rpc upload_api2(stream api2_http_request) returns (stream_reply); + rpc sync_x509(stream api2_http_request) returns (stream stream_reply); +} From 1bf465376a23cc868a0965b5fa8740eb6fb75a25 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sat, 22 Aug 2026 02:27:12 -0700 Subject: [PATCH 33/43] refactor(grpc): retain one transport plan --- codegen/generator/example.go | 8 +- codegen/generator/plan.go | 7 +- codegen/generator/transport.go | 34 ++-- grpc/codegen/plan.go | 270 ++++++++++++++++++++------- grpc/codegen/plan_test.go | 219 +++++++++++++++++++--- grpc/codegen/protobuf_test.go | 21 +++ grpc/codegen/service_data.go | 34 ++-- grpc/codegen/testing.go | 7 +- jsonrpc/codegen/kitchen_sink_test.go | 25 +-- 9 files changed, 470 insertions(+), 155 deletions(-) diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 140516c13d..f7151adac9 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -6,7 +6,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" - grpccodegen "goa.design/goa/v3/grpc/codegen" ) // exampleFiles returns example service, server, and client files described by @@ -61,12 +60,11 @@ func exampleFiles(plan *Plan) ([]*codegen.File, error) { } // GRPC - if len(r.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services, plan.grpc) - if fs := grpccodegen.ExampleServerFiles(grpcServices); len(fs) > 0 { + if grpcPlan := plan.grpc[r]; grpcPlan != nil { + if fs := grpcPlan.ExampleServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(grpcServices); len(fs) > 0 { + if fs := grpcPlan.ExampleCLIFiles(); len(fs) > 0 { files = append(files, fs...) } } diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go index 2bd59e8fe3..fc7a1a80ad 100644 --- a/codegen/generator/plan.go +++ b/codegen/generator/plan.go @@ -25,7 +25,7 @@ type ( http map[*expr.RootExpr]*httpcodegen.Plan jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan - grpc *grpccodegen.PreparedPlan + grpc map[*expr.RootExpr]*grpccodegen.Plan transportDone bool design *designSnapshot } @@ -84,6 +84,11 @@ func (p *Plan) link() error { return err } } + if plan := p.grpc[root]; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } } return nil } diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 80bfedf2cd..87dac948d2 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -17,7 +17,6 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { generation := plan.Generation() designRoots := serviceRoots(generation.Roots()) for _, r := range designRoots { - services := plan.Service(r).Services() // HTTP if httpPlan := plan.http[r]; httpPlan != nil { files = append(files, httpPlan.ServerFiles()...) @@ -29,14 +28,13 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { } // GRPC - if plan.grpc != nil { - grpcServices := grpccodegen.NewServicesData(services, plan.grpc) - files = append(files, grpccodegen.ProtoFiles(grpcServices)...) - files = append(files, grpccodegen.ServerFiles(grpcServices)...) - files = append(files, grpccodegen.ClientFiles(grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) + if grpcPlan := plan.grpc[r]; grpcPlan != nil { + files = append(files, grpcPlan.ProtoFiles()...) + files = append(files, grpcPlan.ServerFiles()...) + files = append(files, grpcPlan.ClientFiles()...) + files = append(files, grpcPlan.ServerTypeFiles()...) + files = append(files, grpcPlan.ClientTypeFiles()...) + files = append(files, grpcPlan.ClientCLIFiles()...) } // JSON-RPC @@ -77,15 +75,23 @@ func planTransportData(plan *Plan) error { hasGRPC = hasGRPC || len(root.API.GRPC.Services) > 0 } if hasGRPC { - inputs := make([]grpccodegen.PlanInput, len(roots)) - for index, root := range roots { - inputs[index] = grpccodegen.PlanInput{Root: root, Service: plan.Service(root)} + var inputs []grpccodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.GRPC.Services) == 0 { + continue + } + inputs = append(inputs, grpccodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) } - grpcPlan, err := grpccodegen.Plan(generation, inputs...) + grpcPlans, err := grpccodegen.NewPlans(generation, inputs...) if err != nil { return err } - plan.grpc = grpcPlan + plan.grpc = make(map[*expr.RootExpr]*grpccodegen.Plan, len(grpcPlans)) + for index, root := range plannedRoots { + plan.grpc[root] = grpcPlans[index] + } } plan.transportDone = true return nil diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go index ea6437ff38..dd068f962d 100644 --- a/grpc/codegen/plan.go +++ b/grpc/codegen/plan.go @@ -1,5 +1,5 @@ -// This file records gRPC imports, generated protobuf package names, and command -// functions before generated files are written. +// This file retains one gRPC design, its chosen Go names, and every generated +// file from planning through rendering. package codegen import ( @@ -22,10 +22,21 @@ type ( Service *service.Plan } - // PreparedPlan contains the command-line function names requested before Go - // assigns their final spellings. - PreparedPlan struct { - roots map[*expr.RootExpr]*grpcCLIPlan + // Plan retains one design root and every gRPC file built from it. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + cli *grpcCLIPlan + services *ServicesData + proto []*codegen.File + server []*codegen.File + client []*codegen.File + serverType []*codegen.File + clientType []*codegen.File + clientCLI []*codegen.File + example []*codegen.File + exampleCLI []*codegen.File } // grpcCLIPlan contains the command parser and payload function names for one @@ -36,18 +47,17 @@ type ( } ) -// Plan requests the import names and command-line functions used by the gRPC -// files for inputs. ClientCLIFiles reads the returned names after Goa has made -// every name unique within its Go package. -func Plan(generation *codegen.Generation, inputs ...PlanInput) (*PreparedPlan, error) { +// NewPlans reads every service design in generation and retains one plan for +// each input. It chooses shared package names before generation freezes. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { owned := make(map[*expr.RootExpr]struct{}) for _, candidate := range generation.Roots() { - if root, ok := candidate.(*expr.RootExpr); ok { + if root, ok := candidate.(*expr.RootExpr); ok && len(root.API.GRPC.Services) > 0 { owned[root] = struct{}{} } } if len(inputs) != len(owned) { - return nil, fmt.Errorf("gRPC planning requires all %d service roots, got %d", len(owned), len(inputs)) + return nil, fmt.Errorf("gRPC planning requires all %d gRPC roots, got %d", len(owned), len(inputs)) } seen := make(map[*expr.RootExpr]struct{}, len(inputs)) for _, input := range inputs { @@ -62,6 +72,115 @@ func Plan(generation *codegen.Generation, inputs ...PlanInput) (*PreparedPlan, e } seen[input.Root] = struct{}{} } + if err := requireGRPCImports(generation); err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + cliPlan, err := planGRPCCLI(generation, input) + if err != nil { + return nil, err + } + plans[index] = &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + cli: cliPlan, + } + } + return plans, nil +} + +// Generation returns the generation that owns this plan's package names. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} + +// Root returns the exact design supplied to NewPlans. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Service returns the exact service plan supplied to NewPlans. +func (p *Plan) Service() *service.Plan { + return p.service +} + +// Link builds the gRPC render data and files after all names are frozen. The +// service plan must already be linked. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("gRPC plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("gRPC plan is already linked") + } + services := newServicesData(p.service.Services(), p) + for _, grpcService := range p.root.API.GRPC.Services { + services.Get(grpcService.Name()) + } + p.services = services + p.proto = ProtoFiles(services) + p.server = ServerFiles(services) + p.client = ClientFiles(services) + p.serverType = ServerTypeFiles(services) + p.clientType = ClientTypeFiles(services) + p.clientCLI = ClientCLIFiles(services) + p.example = ExampleServerFiles(services) + p.exampleCLI = ExampleCLIFiles(services) + return nil +} + +// ProtoFiles returns the protobuf schemas built by Link. +func (p *Plan) ProtoFiles() []*codegen.File { + p.requireLinked() + return p.proto +} + +// ServerFiles returns the gRPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the gRPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server transport type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverType +} + +// ClientTypeFiles returns the client transport type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientType +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ExampleServerFiles returns the runnable gRPC server files built by Link. +func (p *Plan) ExampleServerFiles() []*codegen.File { + p.requireLinked() + return p.example +} + +// ExampleCLIFiles returns the runnable gRPC client files built by Link. +func (p *Plan) ExampleCLIFiles() []*codegen.File { + p.requireLinked() + return p.exampleCLI +} + +// requireGRPCImports records packages used by gRPC files before names freeze. +func requireGRPCImports(generation *codegen.Generation) error { imports := []*codegen.ImportSpec{ codegen.SimpleImport("context"), codegen.SimpleImport("encoding/json"), @@ -91,76 +210,85 @@ func Plan(generation *codegen.Generation, inputs ...PlanInput) (*PreparedPlan, e } for _, spec := range imports { if err := generation.RequireImport(spec); err != nil { - return nil, err + return err } } - plan := &PreparedPlan{roots: make(map[*expr.RootExpr]*grpcCLIPlan, len(inputs))} - for _, input := range inputs { - design := input.Root - rootPlan := &grpcCLIPlan{ - parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), - builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), - } - for _, service := range design.API.GRPC.Services { - pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) - packageName := strings.ToLower(codegen.Goify(service.Name(), false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client"))); err != nil { - return nil, err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server"))); err != nil { - return nil, err + return nil +} + +// planGRPCCLI chooses parser and payload builder names for one design. +func planGRPCCLI(generation *codegen.Generation, input PlanInput) (*grpcCLIPlan, error) { + design := input.Root + plan := &grpcCLIPlan{ + parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), + } + for _, grpcService := range design.API.GRPC.Services { + pathName := codegen.SnakeCase(codegen.Goify(grpcService.Name(), false)) + packageName := strings.ToLower(codegen.Goify(grpcService.Name(), false)) + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client"))); err != nil { + return nil, err + } + if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server"))); err != nil { + return nil, err + } + if err := generation.ReserveGeneratedImport(codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName))); err != nil { + return nil, err + } + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + for _, endpoint := range grpcService.GRPCEndpoints { + if endpoint.MethodExpr.Payload.Type == expr.Empty { + continue } - if err := generation.ReserveGeneratedImport(codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName))); err != nil { + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { return nil, err } - clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + declaration, err := cli.DeclarePayloadBuilder(clientPackage, "grpc", design.API.Name, grpcService.Name(), endpoint.Name(), "Build"+names.Method+"Payload") if err != nil { return nil, err } - for _, endpoint := range service.GRPCEndpoints { - if endpoint.MethodExpr.Payload.Type == expr.Empty { - continue - } - names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) - if err != nil { - return nil, err - } - declaration, err := cli.DeclarePayloadBuilder(clientPackage, "grpc", design.API.Name, service.Name(), endpoint.Name(), "Build"+names.Method+"Payload") - if err != nil { - return nil, err - } - rootPlan.builders[endpoint] = declaration - } + plan.builders[endpoint] = declaration + } + } + if len(design.API.GRPC.Services) == 0 { + return plan, nil + } + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "grpc", "cli", serverName))); err != nil { + return nil, err } - if len(design.API.GRPC.Services) > 0 { - for _, server := range design.API.Servers { - serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) - if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "grpc", "cli", serverName))); err != nil { - return nil, err - } - serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) - if err != nil { - return nil, err - } - var commands []cli.CommandDeclarationInput - for _, grpcService := range design.API.GRPC.Services { - if len(grpcService.GRPCEndpoints) == 0 { - continue - } - command := cli.CommandDeclarationInput{Service: grpcService.Name()} - for _, endpoint := range grpcService.GRPCEndpoints { - command.Methods = append(command.Methods, endpoint.Name()) - } - commands = append(commands, command) - } - parser, err := cli.DeclareParser(serverPackage, "grpc", design.API.Name, server.Name, commands) - if err != nil { - return nil, err - } - rootPlan.parsers[server] = parser + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + if err != nil { + return nil, err + } + var commands []cli.CommandDeclarationInput + for _, grpcService := range design.API.GRPC.Services { + if len(grpcService.GRPCEndpoints) == 0 { + continue } + command := cli.CommandDeclarationInput{Service: grpcService.Name()} + for _, endpoint := range grpcService.GRPCEndpoints { + command.Methods = append(command.Methods, endpoint.Name()) + } + commands = append(commands, command) + } + parser, err := cli.DeclareParser(serverPackage, "grpc", design.API.Name, server.Name, commands) + if err != nil { + return nil, err } - plan.roots[design] = rootPlan + plan.parsers[server] = parser } return plan, nil } + +// requireLinked stops file reads before Link stores the files. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("gRPC files requested before plan linking") + } +} diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go index c0208021e4..5f1abb466b 100644 --- a/grpc/codegen/plan_test.go +++ b/grpc/codegen/plan_test.go @@ -1,49 +1,210 @@ -// This file verifies gRPC planning reserves static and generated package -// imports before the generation catalog freezes. +// This file verifies one gRPC plan keeps the exact design, service plan, and +// generated files selected for a generation run. package codegen import ( - "path" + "fmt" + "sort" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) -// TestPlanReservesGeneratedGRPCPackages verifies that client, server, -// protobuf, and CLI packages consume exact frozen import records. -func TestPlanReservesGeneratedGRPCPackages(t *testing.T) { - root := expr.RunDSL(t, func() { - for _, name := range []string{"Foo", "Fooc", "Foosvr"} { - dsl.Service(name, func() { - dsl.Method("Read", func() { dsl.GRPC(func() {}) }) +// TestNewPlansKeepsExactInputs checks that each result keeps its input pair. +func TestNewPlansKeepsExactInputs(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, + PlanInput{Root: roots[1], Service: services[1]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.Same(t, generation, plans[0].Generation()) + require.Same(t, roots[1], plans[0].Root()) + require.Same(t, services[1], plans[0].Service()) + require.Same(t, roots[0], plans[1].Root()) + require.Same(t, services[0], plans[1].Service()) +} + +// TestNewPlansRequiresEveryRoot checks that a batch cannot omit a design. +func TestNewPlansRequiresEveryRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.EqualError(t, err, "gRPC planning requires all 2 gRPC roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot checks that a batch cannot repeat a design. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.EqualError(t, err, fmt.Sprintf("gRPC root %p is planned more than once", roots[0])) +} + +// TestNewPlansRejectsMismatchedServicePlan checks that input pairs must match. +func TestNewPlansRejectsMismatchedServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[1]}, + PlanInput{Root: roots[1], Service: services[0]}, + ) + require.EqualError(t, err, "gRPC plan input does not pair a design with its service plan") +} + +// TestPlanLinksOnceAfterFreeze checks the required link order. +func TestPlanLinksOnceAfterFreeze(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.EqualError(t, plans[0].Link(), "gRPC plan cannot link before generation freeze") + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "gRPC plan is already linked") +} + +// TestPlanReturnsStoredFiles checks that later reads reuse the linked files. +func TestPlanReturnsStoredFiles(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, example.Plan(generation)) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + want := grpcPlanFileSignatures(t, plans[0]) + roots[0].API.GRPC.Services = append(roots[0].API.GRPC.Services, &expr.GRPCServiceExpr{}) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) +} + +// TestNewPlansIsIndependentOfInputOrder checks that input order does not +// change files written to the same generated packages. +func TestNewPlansIsIndependentOfInputOrder(t *testing.T) { + forwardNames, forwardErr := collidingGRPCPlanResult(t, false) + reverseNames, reverseErr := collidingGRPCPlanResult(t, true) + require.Empty(t, forwardErr) + require.Empty(t, reverseErr) + require.Equal(t, forwardNames, reverseNames) +} + +// grpcPlanRoots creates independent designs with one unary gRPC method. +func grpcPlanRoots(t *testing.T, serviceNames ...string) []*expr.RootExpr { + t.Helper() + roots := make([]*expr.RootExpr, len(serviceNames)) + for index, serviceName := range serviceNames { + roots[index] = expr.RunDSL(t, func() { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) }) + }) + } + return roots +} + +// grpcServicePlans creates one generation and the service plan for each root. +func grpcServicePlans(t *testing.T, roots []*expr.RootExpr) (*codegen.Generation, []*service.Plan) { + t.Helper() + evaluated := make([]eval.Root, len(roots)) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + evaluated[index] = root + inputs[index] = service.PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), } - }) - generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) - require.NoError(t, err) - servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) require.NoError(t, err) - _, err = Plan(generation, PlanInput{Root: root, Service: servicePlan}) + plans, err := service.NewPlans(generation, inputs...) require.NoError(t, err) + return generation, plans +} + +// grpcPlanFileSignatures renders every stored file for a stable comparison. +func grpcPlanFileSignatures(t *testing.T, plan *Plan) []string { + t.Helper() + files := plan.ProtoFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ClientCLIFiles()...) + files = append(files, plan.ExampleServerFiles()...) + files = append(files, plan.ExampleCLIFiles()...) + signatures := make([]string, len(files)) + for index, file := range files { + signatures[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(signatures) + return signatures +} + +// collidingGRPCPlanResult returns stored files or the exact planning error for +// two designs that write the same gRPC and protobuf packages. +func collidingGRPCPlanResult(t *testing.T, reverse bool) ([]string, string) { + t.Helper() + makeRoot := func(serviceName, typeName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + choice := dsl.Type(typeName, func() { + dsl.Meta("struct:name:proto", "API2_Choice") + dsl.OneOf("state", func() { + dsl.Field(1, "api2URL", dsl.String) + dsl.Field(2, "reset", dsl.String) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Sync2URL", func() { + dsl.Payload(choice) + dsl.Result(choice) + dsl.StreamingPayload(choice) + dsl.StreamingResult(choice) + dsl.GRPC(func() {}) + }) + }) + }) + } + roots := []*expr.RootExpr{makeRoot("Foo Bar", "FirstChoice"), makeRoot("Foo-Bar", "SecondChoice")} + generation, services := grpcServicePlans(t, roots) + inputs := []PlanInput{{Root: roots[0], Service: services[0]}, {Root: roots[1], Service: services[1]}} + if reverse { + inputs[0], inputs[1] = inputs[1], inputs[0] + } + plans, err := NewPlans(generation, inputs...) + if err != nil { + return nil, err.Error() + } + require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) - require.NoError(t, servicePlan.Link()) - services := servicePlan.Services() - - client := services.PackageImport("generated.local/gen/grpc/foo/client") - server := services.PackageImport("generated.local/gen/grpc/foo/server") - protobuf := services.PackageImport("generated.local/gen/grpc/foo/pb") - cli := services.PackageImport(path.Join( - "generated.local/gen/grpc/cli", - codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), - )) - require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) - require.NotEqual(t, services.ServiceImport("Foosvr").Name, server.Name) - require.NotEmpty(t, protobuf.Name) - require.NotEmpty(t, cli.Name) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + var names []string + for _, plan := range plans { + if err := plan.Link(); err != nil { + return nil, err.Error() + } + names = append(names, grpcPlanFileSignatures(t, plan)...) + } + sort.Strings(names) + return names, "" } diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index 6799512d3d..a75fc8efbf 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -186,6 +186,27 @@ func TestHasAnyType(t *testing.T) { } } +// TestHasAnyTypeStopsAtRecursiveTypes checks that a cycle does not hide an Any +// field elsewhere in the same type. +func TestHasAnyTypeStopsAtRecursiveTypes(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + recursive.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{Type: recursive}, + }, + &expr.NamedAttributeExpr{ + Name: "data", + Attribute: &expr.AttributeExpr{Type: expr.Any}, + }, + }} + require.True(t, hasAnyType(recursive.Attribute())) + + object := expr.AsObject(recursive.Attribute().Type) + *object = (*object)[:1] + require.False(t, hasAnyType(recursive.Attribute())) +} + func TestProtoBufMessageDefJSONNameOption(t *testing.T) { attr := &expr.AttributeExpr{ Type: &expr.Object{ diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 84f273f8f7..670fd54581 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -452,16 +452,15 @@ const ( validateClient ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData, plan *PreparedPlan) *ServicesData { - cliPlan := plan.roots[services.Root] - if cliPlan == nil { - panic(fmt.Sprintf("gRPC command-line names are missing for design %q", services.Root.API.Name)) +// newServicesData creates the render data owned by one retained gRPC plan. +func newServicesData(services *service.ServicesData, plan *Plan) *ServicesData { + if services.Root != plan.root { + panic(fmt.Sprintf("gRPC service data does not belong to design %q", plan.root.API.Name)) } return &ServicesData{ ServicesData: services, GRPCServices: make(map[string]*ServiceData), - cliPlan: cliPlan, + cliPlan: plan.cli, } } @@ -1686,8 +1685,14 @@ func usesAnyType(endpoints []*expr.GRPCEndpointExpr, includeErrors bool) bool { return false } -// hasAnyType recursively checks if the given attribute uses the Any type. +// hasAnyType reports whether the attribute uses Any without following a named +// type more than once. func hasAnyType(att *expr.AttributeExpr) bool { + return hasAnyTypeR(att, make(map[expr.UserType]struct{})) +} + +// hasAnyTypeR walks arrays, maps, objects, unions, and named types. +func hasAnyTypeR(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { if att == nil { return false } @@ -1696,20 +1701,25 @@ func hasAnyType(att *expr.AttributeExpr) bool { } switch dt := att.Type.(type) { case expr.UserType: - return hasAnyType(dt.Attribute()) + origin := dt.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasAnyTypeR(dt.Attribute(), seen) case *expr.Object: for _, nat := range *dt { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } case *expr.Array: - return hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.ElemType, seen) case *expr.Map: - return hasAnyType(dt.KeyType) || hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.KeyType, seen) || hasAnyTypeR(dt.ElemType, seen) case *expr.Union: for _, nat := range dt.Values { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index d67d19cb65..af550f53a5 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -44,7 +44,7 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *Servic if err != nil { panic(err) } - grpcPlan, err := Plan(generation, PlanInput{Root: root, Service: servicePlan}) + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) if err != nil { panic(err) } @@ -57,7 +57,10 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *Servic if err := servicePlan.Link(); err != nil { panic(err) } - return NewServicesData(servicePlan.Services(), grpcPlan) + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return grpcPlans[0].services } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index f01f4b8644..7d82b2ebf5 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -18,7 +18,6 @@ import ( "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" - grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" "goa.design/goa/v3/jsonrpc/codegen/testdata" @@ -46,17 +45,14 @@ func TestJSONRPCKitchenSink(t *testing.T) { Root: root, Service: servicePlan, HTTP: jsonHTTPPlans[0], ApplicationHTTP: httpPlans[0], }) require.NoError(t, err) - grpcPlan, err := grpccodegen.Plan(generation, grpccodegen.PlanInput{Root: root, Service: servicePlan}) - require.NoError(t, err) require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, httpPlans[0].Link()) require.NoError(t, jsonHTTPPlans[0].Link()) require.NoError(t, jsonPlans[0].Link()) - services := servicePlan.Services() - tfiles := kitchenSinkTransportFiles(services, grpcPlan, httpPlans[0], jsonPlans[0]) - efiles := kitchenSinkExampleFiles(root, servicePlan, grpcPlan, httpPlans[0], jsonPlans[0]) + tfiles := kitchenSinkTransportFiles(httpPlans[0], jsonPlans[0]) + efiles := kitchenSinkExampleFiles(root, servicePlan, httpPlans[0], jsonPlans[0]) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -92,7 +88,7 @@ func TestJSONRPCKitchenSink(t *testing.T) { // kitchenSinkTransportFiles assembles every transport file through the public // subsystem APIs exercised by the golden fixture. -func kitchenSinkTransportFiles(services *service.ServicesData, grpcPlan *grpccodegen.PreparedPlan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { +func kitchenSinkTransportFiles(httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { files := httpPlan.ServerFiles() files = append(files, httpPlan.ClientFiles()...) files = append(files, httpPlan.ServerTypeFiles()...) @@ -100,14 +96,6 @@ func kitchenSinkTransportFiles(services *service.ServicesData, grpcPlan *grpccod files = append(files, httpPlan.PathFiles()...) files = append(files, httpPlan.ClientCLIFiles()...) - grpcServices := grpccodegen.NewServicesData(services, grpcPlan) - files = append(files, grpccodegen.ProtoFiles(grpcServices)...) - files = append(files, grpccodegen.ServerFiles(grpcServices)...) - files = append(files, grpccodegen.ClientFiles(grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(grpcServices)...) - files = append(files, jsonPlan.ServerFiles()...) files = append(files, jsonPlan.ClientFiles()...) files = append(files, jsonPlan.ServerTypeFiles()...) @@ -118,7 +106,7 @@ func kitchenSinkTransportFiles(services *service.ServicesData, grpcPlan *grpccod // kitchenSinkExampleFiles assembles example service and transport files // through their public subsystem APIs. -func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan, grpcPlan *grpccodegen.PreparedPlan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { +func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { services := plan.Services() files := service.ExampleServiceFiles(plan) files = append(files, service.ExampleInterceptorsFiles(plan)...) @@ -132,10 +120,5 @@ func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan, grpcPlan * files = append(files, jsonPlan.ExampleServerFiles()...) files = append(files, jsonPlan.ExampleCLIFiles()...) } - if len(root.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services, grpcPlan) - files = append(files, grpccodegen.ExampleServerFiles(grpcServices)...) - files = append(files, grpccodegen.ExampleCLIFiles(grpcServices)...) - } return files } From 5cbdda8fada8fc68ad9b2af51eca006c17be81e5 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 23 Aug 2026 20:19:14 -0700 Subject: [PATCH 34/43] fix(codegen): complete generated contract planning --- AGENTS.md | 36 +- codegen/ARCHITECTURE.md | 616 ++++++- codegen/cli/cli.go | 722 ++++++--- codegen/cli/cli_test.go | 499 ++++++ codegen/cli/symbols.go | 344 +++- codegen/cli/templates.go | 11 +- codegen/cli/templates/build_payload.go.tpl | 4 +- codegen/cli/templates/parse_flags.go.tpl | 4 +- .../cli/templates/parse_flags_planned.go.tpl | 74 + codegen/cli/templates/usage_commands.go.tpl | 4 +- codegen/cli/templates/usage_examples.go.tpl | 4 +- codegen/example/example_client.go | 174 +- codegen/example/example_client_test.go | 148 +- codegen/example/example_server.go | 319 ++-- codegen/example/example_server_test.go | 137 +- codegen/example/plan.go | 143 +- codegen/example/plan_test.go | 283 ++++ codegen/example/public_api_test.go | 15 + codegen/example/server_data.go | 379 +++-- codegen/example/server_data_test.go | 61 +- codegen/example/templates/client_end.go.tpl | 47 +- .../templates/client_endpoint_init.go.tpl | 25 +- codegen/example/templates/client_start.go.tpl | 2 +- codegen/example/templates/client_usage.go.tpl | 18 +- .../example/templates/client_var_init.go.tpl | 19 +- .../example/templates/server_endpoints.go.tpl | 14 +- .../example/templates/server_handler.go.tpl | 19 +- .../templates/server_interceptors.go.tpl | 10 +- .../example/templates/server_logger.go.tpl | 4 +- .../example/templates/server_services.go.tpl | 10 +- codegen/example/templates/server_start.go.tpl | 2 +- .../testdata/client-input-stream.golden | 101 ++ .../testdata/client-mixed-results.golden | 123 ++ .../example/testdata/client-no-server.golden | 41 +- .../testdata/client-server-stream.golden | 130 ++ ...erver-multiple-hosts-with-variables.golden | 52 +- ...client-single-server-multiple-hosts.golden | 39 +- ...e-server-single-host-with-variables.golden | 39 +- .../client-single-server-single-host.golden | 41 +- .../example/testdata/server-no-server.golden | 1 + .../server-same-api-service-name.golden | 1 + .../server-sercice-for-only-grpc.golden | 2 +- ...er-server-hosting-multiple-services.golden | 1 + ...erver-server-hosting-service-subset.golden | 1 + ...r-service-for-http-and-part-of-grpc.golden | 1 + .../server-service-name-with-spaces.golden | 1 + ...erver-multiple-hosts-with-variables.golden | 31 +- .../server-single-server-single-host.golden | 1 + codegen/funcs.go | 1 - codegen/funcs_test.go | 50 + codegen/generated_types.go | 317 ++-- codegen/generated_types_test.go | 204 ++- codegen/generation.go | 130 +- .../attached_jsonrpc_sse_integration_test.go | 112 ++ codegen/generator/command_isolation_test.go | 233 +++ codegen/generator/design_snapshot.go | 78 +- codegen/generator/design_snapshot_test.go | 42 +- codegen/generator/example.go | 102 +- .../example_cli_input_stream_compile_test.go | 73 + .../example_cli_result_runtime_test.go | 238 +++ .../example_handler_args_integration_test.go | 48 + .../generator/example_immutability_test.go | 52 + ...le_output_preservation_integration_test.go | 109 ++ codegen/generator/example_plan_test.go | 79 + codegen/generator/example_snapshot.go | 121 ++ codegen/generator/example_state_test.go | 6 +- codegen/generator/generate.go | 23 +- ...ate_grpc_cli_collision_integration_test.go | 68 + ...c_required_array_alias_integration_test.go | 42 + ...uired_union_validation_integration_test.go | 2 + ...rate_http_error_result_integration_test.go | 45 + ...p_multipart_validation_integration_test.go | 205 +++ ...p_required_array_alias_integration_test.go | 120 ++ ...erate_http_union_shape_integration_test.go | 22 +- .../generate_union_merge_integration_test.go | 2 +- ...ed_grpc_shared_package_integration_test.go | 128 ++ ...generated_service_path_integration_test.go | 125 ++ ...erated_transport_alias_integration_test.go | 83 +- codegen/generator/generation_test.go | 51 +- codegen/generator/generators.go | 71 +- codegen/generator/http_plan_test.go | 33 + .../http_sse_retry_integration_test.go | 120 ++ codegen/generator/lifecycle.go | 32 +- codegen/generator/openapi.go | 40 +- codegen/generator/openapi_replace_test.go | 86 + codegen/generator/plan.go | 149 +- codegen/generator/plugin.go | 125 +- .../plugin_public_integration_test.go | 381 +++++ codegen/generator/plugin_test.go | 420 ++++- .../public_api_compatibility_test.go | 22 + codegen/generator/purity_test.go | 35 +- codegen/generator/run_examples.go | 6 +- codegen/generator/service.go | 16 +- .../service_union_package_scope_test.go | 14 +- codegen/generator/test_helpers_test.go | 15 +- codegen/generator/transport.go | 23 +- codegen/generator/transport_plan_test.go | 38 + ...ansport_representation_integration_test.go | 266 +--- .../viewed_transport_runtime_sources_test.go | 453 +----- codegen/go_transform.go | 1113 ++++++++++--- codegen/go_transform_hooks.go | 89 +- codegen/go_transform_test.go | 948 ++++++++++- codegen/go_type_plan.go | 231 ++- codegen/go_type_plan_test.go | 83 +- codegen/header.go | 56 +- codegen/import_aliases.go | 120 +- codegen/import_aliases_test.go | 65 +- codegen/internal/pluginregistry/registry.go | 113 ++ codegen/name_declaration.go | 104 +- codegen/normalize.go | 27 +- codegen/plugin.go | 74 + codegen/plugin_test.go | 102 ++ codegen/protobuf.go | 73 + codegen/scope.go | 26 +- codegen/sections_test.go | 42 + codegen/service/client.go | 6 +- .../service/codegen_specialization_test.go | 277 ++++ codegen/service/conversion_plan.go | 100 +- .../service/conversion_plan_contract_test.go | 20 +- codegen/service/convert.go | 7 +- codegen/service/declaration_resolver.go | 115 +- codegen/service/declaration_resolver_test.go | 34 +- codegen/service/endpoint.go | 52 +- codegen/service/example_interceptors.go | 66 +- codegen/service/example_interceptors_test.go | 12 + codegen/service/example_svc.go | 34 +- codegen/service/example_svc_test.go | 21 + codegen/service/generated_emission.go | 89 +- codegen/service/generated_package.go | 379 +++-- codegen/service/imports.go | 227 ++- codegen/service/imports_test.go | 92 +- codegen/service/interceptor_data.go | 31 +- codegen/service/interceptors.go | 77 +- codegen/service/interceptors_test.go | 35 + .../jsonrpc_websocket_signature_test.go | 60 - codegen/service/method_data.go | 71 +- .../service/method_package_imports_test.go | 47 + codegen/service/method_payload_layout_test.go | 37 + codegen/service/plan.go | 275 ++-- codegen/service/plan_lifecycle.go | 132 +- .../service/render_name_compatibility_test.go | 106 ++ ...ained_expression_mutation_contract_test.go | 2 +- codegen/service/security_data.go | 4 +- codegen/service/service.go | 85 +- codegen/service/service_data.go | 255 +-- ...ice_declaration_condition_contract_test.go | 69 +- codegen/service/service_dedup_test.go | 35 - codegen/service/service_fact_plan.go | 58 +- codegen/service/service_link.go | 198 ++- .../service_name_collision_contract_test.go | 8 +- codegen/service/service_names.go | 117 +- codegen/service/service_names_test.go | 8 +- codegen/service/service_package_path.go | 96 ++ codegen/service/service_package_path_test.go | 154 ++ .../service_plan_compile_contract_test.go | 98 +- .../service_plan_render_contract_test.go | 2 +- codegen/service/service_test.go | 109 +- codegen/service/templates.go | 1 - .../client_interceptor_wrappers.go.tpl | 27 +- .../templates/client_interceptors.go.tpl | 2 +- codegen/service/templates/endpoint.go.tpl | 21 +- .../example_client_interceptor.go.tpl | 2 +- .../example_server_interceptor.go.tpl | 2 +- codegen/service/templates/interceptors.go.tpl | 189 +-- .../templates/interceptors_types.go.tpl | 67 +- .../templates/jsonrpc_handle_stream.go.tpl | 17 - .../server_interceptor_wrappers.go.tpl | 27 +- .../templates/server_interceptors.go.tpl | 2 +- codegen/service/templates/service.go.tpl | 150 +- .../templates/service_endpoint_method.go.tpl | 20 +- .../service_endpoint_stream_struct.go.tpl | 4 - .../service/templates/type_validate.go.tpl | 14 +- codegen/service/templates/union_type.go.tpl | 70 +- codegen/service/test_helpers_test.go | 7 + .../testdata/dedup_event_marker_dsls.go | 28 - codegen/service/testdata/endpoint_dsls.go | 26 + .../api_interceptor_service_client.golden | 2 +- .../api_interceptor_service_server.golden | 2 +- .../chained_interceptor_service_client.golden | 6 +- .../chained_interceptor_service_server.golden | 6 +- .../client_interceptor_service_client.golden | 2 +- ...ultiple_interceptors_service_client.golden | 4 +- ...ultiple_interceptors_service_server.golden | 4 +- ...rvices_interceptors_service2_client.golden | 4 +- ...rvices_interceptors_service2_server.golden | 4 +- ...ervices_interceptors_service_client.golden | 4 +- ...ervices_interceptors_service_server.golden | 4 +- ..._interceptor_by_name_service_server.golden | 2 +- .../server_interceptor_service_server.golden | 2 +- ...service-mixed-results-with-views.go.golden | 25 + .../example_service-mixed-results.go.golden | 24 + .../golden/pkg_path_array_foo.go.golden | 2 +- .../golden/pkg_path_dupes_foo.go.golden | 6 +- .../golden/pkg_path_multiple_bar.go.golden | 3 +- .../golden/pkg_path_multiple_baz.go.golden | 3 +- .../pkg_path_payload_attribute_foo.go.golden | 2 +- .../golden/pkg_path_recursive_foo.go.golden | 2 +- ...pkg_path_recursive_recursive_foo.go.golden | 3 +- .../pkg_path_shared_roles_service.go.golden | 54 + .../pkg_path_shared_roles_shared.go.golden | 5 + .../golden/pkg_path_single_foo.go.golden | 2 +- .../service_service-multi-union.go.golden | 22 +- ...e_service-repeated-inline-errors.go.golden | 45 + ..._service-result-with-one-of-type.go.golden | 42 +- ...ce_service-union-alias-cross-pkg.go.golden | 22 +- .../golden/service_service-union.go.golden | 37 +- ...nal-payload_interceptor_wrappers.go.golden | 11 + ...nal-payload_service_interceptors.go.golden | 86 + ...ead-payload_interceptor_wrappers.go.golden | 12 + ...ead-payload_service_interceptors.go.golden | 86 + ...read-payload_client_interceptors.go.golden | 2 +- ...ead-payload_interceptor_wrappers.go.golden | 14 +- ...ead-payload_service_interceptors.go.golden | 71 +- ...-read-result_client_interceptors.go.golden | 2 +- ...read-result_interceptor_wrappers.go.golden | 14 +- ...read-result_service_interceptors.go.golden | 69 +- ...rite-payload_client_interceptors.go.golden | 2 +- ...ite-payload_interceptor_wrappers.go.golden | 14 +- ...ite-payload_service_interceptors.go.golden | 71 +- ...write-result_client_interceptors.go.golden | 2 +- ...rite-result_interceptor_wrappers.go.golden | 14 +- ...rite-result_service_interceptors.go.golden | 69 +- ...rite-payload_client_interceptors.go.golden | 2 +- ...ite-payload_interceptor_wrappers.go.golden | 14 +- ...ite-payload_service_interceptors.go.golden | 71 +- ...write-result_client_interceptors.go.golden | 2 +- ...rite-result_interceptor_wrappers.go.golden | 14 +- ...rite-result_service_interceptors.go.golden | 69 +- ...-interceptor_client_interceptors.go.golden | 16 + ...interceptor_interceptor_wrappers.go.golden | 21 + ...interceptor_service_interceptors.go.golden | 71 + ...ient-payload_client_interceptors.go.golden | 16 + ...ent-payload_interceptor_wrappers.go.golden | 23 + ...ent-payload_service_interceptors.go.golden | 123 ++ ...ient-payload_client_interceptors.go.golden | 86 + ...ent-payload_interceptor_wrappers.go.golden | 22 + ...ent-payload_service_interceptors.go.golden | 63 + ...interceptors_client_interceptors.go.golden | 16 + ...nterceptors_interceptor_wrappers.go.golden | 104 ++ ...nterceptors_service_interceptors.go.golden | 105 ++ ...interceptors_client_interceptors.go.golden | 100 +- ...nterceptors_interceptor_wrappers.go.golden | 28 +- ...nterceptors_service_interceptors.go.golden | 100 +- ...interceptor_interceptor_wrappers.go.golden | 14 +- ...interceptor_service_interceptors.go.golden | 77 +- ...-interceptor_client_interceptors.go.golden | 53 +- ...interceptor_interceptor_wrappers.go.golden | 7 +- ...interceptor_interceptor_wrappers.go.golden | 7 +- ...interceptor_service_interceptors.go.golden | 53 +- ...interceptor_interceptor_wrappers.go.golden | 14 +- ...interceptor_service_interceptors.go.golden | 77 +- ...ming-payload_client_interceptors.go.golden | 2 +- ...ing-payload_interceptor_wrappers.go.golden | 27 +- ...ing-payload_service_interceptors.go.golden | 113 +- ...ead-payload_interceptor_wrappers.go.golden | 7 +- ...ead-payload_service_interceptors.go.golden | 73 +- ...read-result_interceptor_wrappers.go.golden | 7 +- ...read-result_service_interceptors.go.golden | 61 +- ...aming-result_client_interceptors.go.golden | 2 +- ...ming-result_interceptor_wrappers.go.golden | 13 +- ...ming-result_service_interceptors.go.golden | 79 +- ...interceptors_client_interceptors.go.golden | 2 +- ...nterceptors_interceptor_wrappers.go.golden | 26 +- ...nterceptors_service_interceptors.go.golden | 95 +- codegen/service/testdata/interceptors_dsls.go | 137 ++ codegen/service/testdata/service_dsls.go | 26 + codegen/service/testdata/views_code.go | 59 +- ...ransform_helper_operation_contract_test.go | 10 +- codegen/service/type_plan.go | 81 +- codegen/service/view_data.go | 100 +- codegen/service/view_validation_plan.go | 48 +- codegen/service/views.go | 6 +- codegen/service/views_test.go | 103 +- codegen/templates/transform_go_array.go.tpl | 16 +- codegen/templates/transform_go_union.go.tpl | 15 +- codegen/templates/validation/array.go.tpl | 7 +- codegen/templates/validation/enum.go.tpl | 4 +- .../templates/validation/excl_min_max.go.tpl | 4 +- codegen/templates/validation/format.go.tpl | 4 +- codegen/templates/validation/length.go.tpl | 6 +- codegen/templates/validation/min_max.go.tpl | 4 +- codegen/templates/validation/pattern.go.tpl | 4 +- codegen/templates/validation/required.go.tpl | 4 +- codegen/templates/validation/union.go.tpl | 4 +- codegen/templates/validation/user.go.tpl | 4 +- ...fault_defaults-to-defaults-types.go.golden | 9 +- ..._UnionSomeType to UnionSomeType2.go.golden | 5 +- .../go_transform_union_nil_branch.go.golden | 78 + .../golden/validation_alias-type.go.golden | 4 - .../validation_chain-holder-pointer.go.golden | 12 - ...validation_chain-holder-required.go.golden | 8 - .../golden/validation_float-pointer.go.golden | 2 - .../validation_float-required.go.golden | 2 - .../validation_float-use-default.go.golden | 2 - .../validation_integer-pointer.go.golden | 2 - .../validation_integer-required.go.golden | 2 - .../validation_integer-use-default.go.golden | 2 - .../validation_string-pointer.go.golden | 4 - codegen/transformer.go | 168 +- codegen/transformer_test.go | 70 + codegen/types.go | 12 +- codegen/union.go | 18 +- codegen/validation.go | 192 ++- codegen/validation_plan.go | 267 ++-- codegen/validation_plan_test.go | 336 +++- codegen/validation_protobuf_union_test.go | 6 +- codegen/validation_test.go | 97 +- .../2026-08-20-generated-package-ownership.md | 10 +- dsl/attribute.go | 4 +- dsl/http.go | 14 +- dsl/jsonrpc.go | 84 +- dsl/meta.go | 9 +- dsl/payload.go | 8 +- dsl/result_type.go | 2 +- expr/attached_service.go | 239 +++ expr/attached_service_test.go | 224 +++ expr/attribute.go | 42 +- expr/attribute_test.go | 76 + expr/dup.go | 79 +- expr/dup_test.go | 57 + expr/error_contract.go | 37 +- expr/example.go | 5 +- expr/example_identity.go | 108 +- expr/grpc_endpoint.go | 16 +- expr/grpc_endpoint_test.go | 16 + expr/grpc_error.go | 14 +- expr/grpc_service.go | 9 +- expr/http_authored_attribute_test.go | 71 + expr/http_body_types.go | 24 +- expr/http_endpoint.go | 97 +- expr/http_endpoint_test.go | 142 ++ expr/http_error.go | 6 +- expr/http_file_server.go | 2 +- expr/http_response.go | 2 +- expr/http_service.go | 104 +- expr/http_service_test.go | 168 -- expr/interceptor.go | 8 +- expr/interceptor_test.go | 8 + expr/jsonrpc_stream_contract_test.go | 127 +- expr/jsonrpc_validation_test.go | 215 --- expr/method.go | 29 +- expr/method_test.go | 47 +- expr/random.go | 321 ++-- expr/random_factory_test.go | 15 + expr/result_type.go | 21 +- expr/root.go | 7 +- expr/security.go | 13 + expr/service.go | 91 +- expr/service_test.go | 121 +- expr/streaming_response_mapping_test.go | 5 +- expr/testdata/endpoint_dsls.go | 43 + expr/testdata/mixed_jsonrpc_transports.go | 151 -- expr/transport_error_contract_test.go | 43 + expr/types.go | 5 +- expr/user_type.go | 37 +- grpc/codegen/client.go | 43 +- grpc/codegen/client_cli.go | 126 +- grpc/codegen/client_cli_test.go | 39 +- grpc/codegen/client_test.go | 6 +- grpc/codegen/client_types_test.go | 138 +- grpc/codegen/compatibility.go | 59 + grpc/codegen/compatibility_test.go | 39 + grpc/codegen/example_cli.go | 125 +- grpc/codegen/example_cli_test.go | 10 +- grpc/codegen/example_server.go | 44 +- grpc/codegen/example_server_test.go | 14 +- grpc/codegen/idempotency_test.go | 6 +- grpc/codegen/import_plan.go | 388 +++++ grpc/codegen/metadata_specialization_test.go | 107 ++ .../oneof_anonymous_user_union_test.go | 2 +- grpc/codegen/parse_endpoint_test.go | 2 +- grpc/codegen/plan.go | 273 ++-- grpc/codegen/plan_retention_test.go | 210 +++ grpc/codegen/plan_service_data_test.go | 70 + grpc/codegen/plan_test.go | 110 +- grpc/codegen/planned_name_collision_test.go | 133 ++ grpc/codegen/proto.go | 62 +- grpc/codegen/proto_hooks.go | 152 +- .../proto_hooks_specialization_test.go | 119 ++ grpc/codegen/proto_test.go | 10 +- grpc/codegen/protobuf.go | 212 +-- grpc/codegen/protobuf_catalog.go | 636 ++++---- grpc/codegen/protobuf_descriptor_plan_test.go | 349 ++++ grpc/codegen/protobuf_plan.go | 921 +++++++++++ grpc/codegen/protobuf_plan_order_test.go | 216 +++ grpc/codegen/protobuf_test.go | 46 - grpc/codegen/protobuf_tools.go | 169 ++ grpc/codegen/protobuf_tools_test.go | 217 +++ grpc/codegen/protobuf_transform.go | 88 +- grpc/codegen/protobuf_transform_test.go | 19 +- grpc/codegen/protoc_names.go | 2 +- grpc/codegen/protoc_names_test.go | 5 +- grpc/codegen/released_streaming_name_test.go | 39 + .../codegen/required_union_validation_test.go | 41 +- grpc/codegen/server.go | 91 +- .../server_protobuf_method_name_test.go | 97 ++ grpc/codegen/server_test.go | 9 +- grpc/codegen/server_types_test.go | 5 +- grpc/codegen/service_data.go | 1330 +++++++++++----- grpc/codegen/service_data_traversal_test.go | 231 ++- grpc/codegen/service_imports.go | 17 +- .../service_metadata_reference_test.go | 6 +- grpc/codegen/service_plan.go | 594 +++++++ grpc/codegen/service_plan_imports_test.go | 69 + grpc/codegen/streaming_errors_test.go | 27 +- grpc/codegen/streaming_test.go | 22 +- grpc/codegen/symbols.go | 884 +++++++++++ grpc/codegen/templates.go | 4 +- .../templates/client_endpoint_init.go.tpl | 14 +- grpc/codegen/templates/client_init.go.tpl | 6 +- grpc/codegen/templates/client_struct.go.tpl | 6 +- grpc/codegen/templates/do_grpc_cli.go.tpl | 69 +- .../templates/grpc_handler_init.go.tpl | 6 +- grpc/codegen/templates/grpc_service.go.tpl | 2 +- grpc/codegen/templates/parse_endpoint.go.tpl | 30 +- .../partial/convert_type_to_string.go.tpl | 25 - .../partial/string_conversion.go.tpl | 25 - .../partial/type_to_string_expression.go.tpl | 25 + .../templates/remote_method_builder.go.tpl | 12 +- grpc/codegen/templates/request_decoder.go.tpl | 36 +- grpc/codegen/templates/request_encoder.go.tpl | 26 +- .../codegen/templates/response_decoder.go.tpl | 28 +- .../codegen/templates/response_encoder.go.tpl | 40 +- .../codegen/templates/server_grpc_init.go.tpl | 6 +- .../templates/server_grpc_interface.go.tpl | 16 +- .../templates/server_grpc_register.go.tpl | 12 +- .../templates/server_grpc_start.go.tpl | 2 +- grpc/codegen/templates/server_init.go.tpl | 8 +- .../templates/server_struct_type.go.tpl | 6 +- grpc/codegen/templates/stream_close.go.tpl | 2 +- grpc/codegen/templates/stream_recv.go.tpl | 48 +- grpc/codegen/templates/stream_send.go.tpl | 45 +- grpc/codegen/templates/stream_set_view.go.tpl | 5 +- .../templates/stream_struct_type.go.tpl | 12 +- .../codegen/templates/transform_helper.go.tpl | 4 +- grpc/codegen/templates/type_init.go.tpl | 2 +- grpc/codegen/templates/validate.go.tpl | 4 +- .../client-bidirectional-streaming.golden | 40 + .../testdata/client-client-streaming.golden | 40 + .../testdata/client-interceptors.golden | 31 +- .../testdata/client-no-server-pkgpath.golden | 27 +- grpc/codegen/testdata/client-no-server.golden | 27 +- ...r-hosting-multiple-services-pkgpath.golden | 32 +- ...nt-server-hosting-multiple-services.golden | 32 +- ...rver-hosting-service-subset-pkgpath.golden | 27 +- ...lient-server-hosting-service-subset.golden | 27 +- .../testdata/client-server-streaming.golden | 46 + grpc/codegen/testdata/dsls.go | 63 +- ...endpoint-endpoint-with-interceptors.golden | 6 +- ...ent_cli_payload-with-validations.go.golden | 16 +- ...nt_types_client-alias-validation.go.golden | 12 +- ...idirectional-streaming-same-type.go.golden | 7 + ...ient_types_client-default-fields.go.golden | 4 +- ...s_client-payload-with-alias-type.go.golden | 12 +- ...lient-payload-with-duplicate-use.go.golden | 5 +- ...client-payload-with-nested-types.go.golden | 68 +- ...client-required-union-validation.go.golden | 65 + ...t_types_client-result-collection.go.golden | 45 +- ...client-result-with-explicit-view.go.golden | 17 + ...t_types_client-result-with-views.go.golden | 29 + ...ient-streaming-result-with-views.go.golden | 29 + ...ient-struct-field-name-meta-type.go.golden | 8 +- ...nt_types_client-struct-meta-type.go.golden | 8 +- .../client_types_client-with-errors.go.golden | 28 +- .../golden/planned_name_collisions.go.golden | 271 ++++ ...otobuf-type_defaults-to-defaults.go.golden | 3 +- ...embedded-oneof-to-embedded-oneof.go.golden | 6 +- ...ervice-type_defaults-to-defaults.go.golden | 6 +- ...embedded-oneof-to-embedded-oneof.go.golden | 8 +- ...ixed_view_collection_constructor.go.golden | 57 + ..._streaming_response_constructors.go.golden | 79 + ...y-payload-with-streaming-payload.go.golden | 24 + ...st-encoder-payload-with-metadata.go.golden | 3 +- ...payload-with-security-attributes.go.golden | 12 +- ...st-encoder-payload-with-validate.go.golden | 3 +- ...-decoder-bidirectional-streaming.go.golden | 7 - ...sponse-decoder-result-collection.go.golden | 8 +- ...ecoder-result-with-explicit-view.go.golden | 8 +- ...sponse-decoder-result-with-views.go.golden | 8 +- ...rver-streaming-result-with-views.go.golden | 7 - ...sponse-encoder-result-collection.go.golden | 10 +- ...ncoder-result-with-explicit-view.go.golden | 2 +- ...nse-encoder-result-with-metadata.go.golden | 6 +- ...nse-encoder-result-with-validate.go.golden | 6 +- ...sponse-encoder-result-with-views.go.golden | 10 +- ...er_types_server-alias-validation.go.golden | 10 +- ...rver_types_server-default-fields.go.golden | 8 +- ...ver_types_server-elem-validation.go.golden | 10 +- ...s_server-payload-with-alias-type.go.golden | 12 +- ...payload-with-custom-type-package.go.golden | 11 +- ...erver-payload-with-duplicate-use.go.golden | 27 +- ...er-payload-with-mixed-attributes.go.golden | 19 +- ...server-payload-with-nested-types.go.golden | 76 +- ...server-required-union-validation.go.golden | 65 + ...r_types_server-result-collection.go.golden | 38 +- ...server-result-with-explicit-view.go.golden | 9 + ...r_types_server-result-with-views.go.golden | 20 + ...rver-streaming-result-with-views.go.golden | 20 + ...rver-struct-field-name-meta-type.go.golden | 8 +- ...er_types_server-struct-meta-type.go.golden | 8 +- .../server_types_server-with-errors.go.golden | 29 +- ..._result_dynamic_response_encoder.go.golden | 21 + ...iewed_result_dynamic_stream_send.go.golden | 36 + ...ed_result_fixed_response_encoder.go.golden | 13 + grpc/codegen/testdata/request_encoder_code.go | 128 -- .../codegen/testdata/response_encoder_code.go | 118 -- grpc/codegen/testdata/server-no-server.golden | 7 +- ...er-server-hosting-multiple-services.golden | 8 +- ...erver-server-hosting-service-subset.golden | 7 +- grpc/codegen/testdata/streaming_code.go | 63 +- grpc/codegen/testing.go | 40 +- grpc/codegen/types.go | 59 +- grpc/codegen/view_specialization_test.go | 127 ++ http/client.go | 2 +- http/codegen/client.go | 35 +- http/codegen/client_body_types_test.go | 8 +- http/codegen/client_cli.go | 123 +- http/codegen/client_cli_test.go | 50 + http/codegen/client_decode_test.go | 12 +- http/codegen/client_encode_test.go | 20 + .../client_query_float_runtime_test.go | 141 ++ .../client_response_body_runtime_test.go | 311 ++++ http/codegen/compatibility.go | 72 + http/codegen/cookie_security_test.go | 6 +- http/codegen/error_body_description_test.go | 44 + http/codegen/example_cli.go | 121 +- http/codegen/example_cli_test.go | 28 +- http/codegen/example_server.go | 200 ++- http/codegen/example_server_test.go | 87 +- http/codegen/handler_test.go | 1 - http/codegen/jsonrpc_data.go | 58 +- http/codegen/multipart_test.go | 6 +- http/codegen/oneof_http_codegen_test.go | 4 +- http/codegen/openapi.go | 124 +- http/codegen/openapi/docs.go | 16 +- http/codegen/openapi/error_example.go | 51 + http/codegen/openapi/json_schema.go | 628 ++------ http/codegen/openapi/json_schema_dup_test.go | 105 ++ .../codegen/openapi/json_schema_union_test.go | 47 - http/codegen/openapi/response_projection.go | 47 + .../openapi/v2/build_isolation_test.go | 125 ++ http/codegen/openapi/v2/builder.go | 137 +- http/codegen/openapi/v2/builder_test.go | 110 +- .../openapi/v2/description_ownership_test.go | 52 + http/codegen/openapi/v2/files.go | 21 +- http/codegen/openapi/v2/files_test.go | 20 +- http/codegen/openapi/v2/json_schema.go | 306 ++++ .../openapi/v2/json_schema_union_test.go | 116 ++ http/codegen/openapi/v2/openapi.go | 2 + http/codegen/openapi/v2/public_api_test.go | 100 ++ .../TestSections/error-examples_file0.golden | 472 ++++++ .../TestSections/error-examples_file1.golden | 369 +++++ ...sed-response-collection-names_file0.golden | 144 ++ ...sed-response-collection-names_file1.golden | 94 ++ .../shared-error-description_file0.golden | 92 ++ .../shared-error-description_file1.golden | 62 + .../TestSections/with-any_file0.golden | 16 +- .../TestSections/with-any_file1.golden | 16 +- .../TestSections/with-spaces_file0.golden | 12 +- .../TestSections/with-spaces_file1.golden | 8 +- .../TestValidations/array_file0.golden | 20 +- .../TestValidations/array_file1.golden | 16 +- http/codegen/openapi/v3/builder.go | 141 +- http/codegen/openapi/v3/builder_test.go | 207 ++- .../openapi/v3/description_ownership_test.go | 98 ++ http/codegen/openapi/v3/example.go | 12 +- http/codegen/openapi/v3/example_test.go | 33 + http/codegen/openapi/v3/files.go | 22 +- http/codegen/openapi/v3/files_test.go | 73 +- http/codegen/openapi/v3/parameters.go | 26 +- http/codegen/openapi/v3/parameters_test.go | 3 + http/codegen/openapi/v3/public_api_test.go | 95 ++ http/codegen/openapi/v3/response.go | 45 +- .../testdata/golden/alias-type_file0.golden | 27 +- .../testdata/golden/alias-type_file1.golden | 27 +- .../v3/testdata/golden/array_file0.golden | 112 +- .../v3/testdata/golden/array_file1.golden | 56 +- .../golden/error-examples_file0.golden | 80 + .../golden/error-examples_file1.golden | 59 + .../v3/testdata/golden/headers_file0.golden | 8 +- .../v3/testdata/golden/headers_file1.golden | 8 +- .../golden/not-generate-host_file0.golden | 4 +- .../golden/not-generate-host_file1.golden | 4 +- .../golden/not-generate-server_file0.golden | 4 +- .../golden/not-generate-server_file1.golden | 4 +- ...h-multiple-explicit-wildcards_file0.golden | 8 +- ...h-multiple-explicit-wildcards_file1.golden | 8 +- .../path-with-multiple-wildcards_file0.golden | 8 +- .../path-with-multiple-wildcards_file1.golden | 8 +- .../golden/path-with-wildcards_file0.golden | 4 +- .../golden/path-with-wildcards_file1.golden | 4 +- ...sed-response-collection-names_file0.golden | 175 ++ ...sed-response-collection-names_file1.golden | 100 ++ .../server-host-with-variables_file0.golden | 3 +- .../server-host-with-variables_file1.golden | 1 + .../shared-error-description_file0.golden | 95 ++ .../shared-error-description_file1.golden | 58 + .../golden/sse-all-fields_file0.golden | 6 +- .../golden/sse-all-fields_file1.golden | 6 +- .../golden/sse-mixed-results_file0.golden | 12 +- .../golden/sse-mixed-results_file1.golden | 12 +- .../testdata/golden/sse-string_file0.golden | 4 +- .../testdata/golden/sse-string_file1.golden | 4 +- .../golden/type-extension_file0.golden | 8 +- .../golden/type-extension_file1.golden | 8 +- .../golden/v3.2/alias-type_file0.golden | 25 +- .../golden/v3.2/alias-type_file1.golden | 25 +- .../server-host-with-variables_file0.golden | 3 +- .../server-host-with-variables_file1.golden | 1 + .../shared-error-description_file0.golden | 96 ++ .../shared-error-description_file1.golden | 59 + .../golden/v3.2/sse-all-fields_file0.golden | 13 +- .../golden/v3.2/sse-all-fields_file1.golden | 12 +- .../golden/v3.2/sse-data-field_file0.golden | 9 +- .../golden/v3.2/sse-data-field_file1.golden | 8 +- .../v3.2/sse-mixed-results_file0.golden | 16 +- .../v3.2/sse-mixed-results_file1.golden | 16 +- .../golden/v3.2/sse-object_file0.golden | 16 +- .../golden/v3.2/sse-object_file1.golden | 16 +- .../golden/v3.2/sse-request-id_file0.golden | 12 +- .../golden/v3.2/sse-request-id_file1.golden | 12 +- .../golden/v3.2/sse-string_file0.golden | 2 +- .../golden/v3.2/sse-string_file1.golden | 2 +- .../golden/v3.2/websocket_file0.golden | 10 +- .../golden/v3.2/websocket_file1.golden | 10 +- .../golden/v3.2/with-tags_file0.golden | 4 +- .../golden/v3.2/with-tags_file1.golden | 4 +- .../v3/testdata/golden/websocket_file0.golden | 10 +- .../v3/testdata/golden/websocket_file1.golden | 10 +- .../v3/testdata/golden/with-any_file0.golden | 16 +- .../v3/testdata/golden/with-any_file1.golden | 16 +- .../testdata/golden/with-spaces_file0.golden | 60 +- .../testdata/golden/with-spaces_file1.golden | 36 +- .../v3/testdata/golden/with-tags_file0.golden | 4 +- .../v3/testdata/golden/with-tags_file1.golden | 4 +- http/codegen/openapi/v3/types.go | 148 +- http/codegen/openapi/v3/types_test.go | 10 +- http/codegen/openapi/values.go | 139 ++ http/codegen/openapi/values_test.go | 86 + .../codegen/openapi_disabled_examples_test.go | 5 +- .../openapi_order_independence_test.go | 10 +- http/codegen/openapi_plan_test.go | 142 ++ http/codegen/openapi_test.go | 12 +- http/codegen/plan.go | 1195 +++++++++++--- http/codegen/plan_extensions_test.go | 180 +++ http/codegen/plan_service_test.go | 60 + http/codegen/plan_test.go | 253 ++- http/codegen/plan_test_helpers_test.go | 21 +- http/codegen/planned_name_collision_test.go | 294 ++++ .../codegen/planned_service_name_uses_test.go | 208 +++ http/codegen/plugin_api_compatibility_test.go | 183 +++ http/codegen/plugin_api_test_helpers_test.go | 108 ++ http/codegen/released_streaming_name_test.go | 33 + http/codegen/server.go | 18 +- http/codegen/server_decode_test.go | 4 + http/codegen/server_extensions_test.go | 74 + http/codegen/server_handler_test.go | 1 - http/codegen/server_init_test.go | 1 - http/codegen/server_mount_test.go | 1 - http/codegen/server_types_test.go | 3 +- http/codegen/service_data.go | 1414 ++++++++++++----- .../service_data_union_nilability_test.go | 18 +- http/codegen/service_imports.go | 120 +- http/codegen/sse.go | 262 ++- http/codegen/sse_client.go | 30 +- http/codegen/sse_client_test.go | 59 + http/codegen/sse_mixed_result_runtime_test.go | 197 +++ http/codegen/sse_mixed_results_test.go | 71 +- .../sse_primitive_wire_runtime_test.go | 248 +++ http/codegen/sse_server_test.go | 56 + http/codegen/streaming_test.go | 47 +- http/codegen/symbols.go | 16 +- http/codegen/templates.go | 1 + http/codegen/templates/cli_end.go.tpl | 54 +- http/codegen/templates/cli_start.go.tpl | 4 +- http/codegen/templates/cli_usage.go.tpl | 4 - .../codegen/templates/client_body_init.go.tpl | 2 +- .../templates/client_endpoint_init.go.tpl | 17 +- http/codegen/templates/client_sse.go.tpl | 108 +- .../codegen/templates/client_type_init.go.tpl | 2 +- .../dummy_multipart_request_decoder.go.tpl | 4 +- http/codegen/templates/file_server.go.tpl | 3 + .../multipart_request_decoder.go.tpl | 19 +- .../multipart_request_decoder_type.go.tpl | 2 +- http/codegen/templates/parse_endpoint.go.tpl | 46 +- .../partial/client_type_conversion.go.tpl | 28 +- .../partial/client_type_expression.go.tpl | 23 + .../templates/partial/request_elements.go.tpl | 2 +- .../codegen/templates/partial/response.go.tpl | 4 +- .../templates/partial/single_response.go.tpl | 9 +- .../templates/partial/sse_format.go.tpl | 38 +- .../templates/partial/sse_parse.go.tpl | 95 +- .../partial/websocket_upgrade.go.tpl | 2 +- http/codegen/templates/request_builder.go.tpl | 2 +- http/codegen/templates/request_decoder.go.tpl | 23 +- http/codegen/templates/request_encoder.go.tpl | 12 +- .../codegen/templates/response_decoder.go.tpl | 38 +- .../codegen/templates/server_body_init.go.tpl | 2 +- .../codegen/templates/server_configure.go.tpl | 13 +- http/codegen/templates/server_handler.go.tpl | 3 + .../templates/server_handler_init.go.tpl | 12 +- http/codegen/templates/server_init.go.tpl | 9 +- .../templates/server_method_names.go.tpl | 2 +- http/codegen/templates/server_mount.go.tpl | 7 +- http/codegen/templates/server_sse.go.tpl | 91 +- http/codegen/templates/server_start.go.tpl | 2 +- .../codegen/templates/server_type_init.go.tpl | 2 +- http/codegen/templates/type_decl.go.tpl | 2 +- http/codegen/templates/union_type.go.tpl | 70 +- http/codegen/templates/validate.go.tpl | 14 +- http/codegen/templates/websocket_recv.go.tpl | 26 +- http/codegen/templates/websocket_send.go.tpl | 47 +- .../templates/websocket_struct_type.go.tpl | 4 + http/codegen/testdata/error_response_dsls.go | 17 + .../golden/client-mixed-results.golden | 36 + .../testdata/golden/client-no-server.golden | 16 +- ...nt-server-hosting-multiple-services.golden | 21 +- ...lient-server-hosting-service-subset.golden | 16 +- .../golden/client-streaming-input-only.golden | 45 + .../client-streaming-multiple-services.golden | 28 +- .../testdata/golden/client-streaming.golden | 21 +- ...t_body_type_decl_body-user-inner.go.golden | 2 +- ...dy-primitive-array-user-validate.go.golden | 10 +- ...nit_body-streaming-aliased-array.go.golden | 4 +- ...t_body_type_init_body-user-inner.go.golden | 2 +- ...e_init_result-body-inline-object.go.golden | 6 +- ...esult-explicit-body-object-views.go.golden | 8 +- ...init_result-explicit-body-object.go.golden | 6 +- ...t_result-explicit-body-primitive.go.golden | 6 +- ...t_result-explicit-body-user-type.go.golden | 6 +- .../golden/client_cli_multi-build.go.golden | 2 +- ...lient_cli_param-validation-build.go.golden | 4 +- ...ient_cli_payload-array-user-type.go.golden | 4 +- ...client_cli_payload-map-user-type.go.golden | 6 +- ...ecode_body-result-multiple-views.go.golden | 23 +- ...empty-body-result-multiple-views.go.golden | 23 +- .../golden/client_decode_empty-body.go.golden | 21 +- ...decode_empty-error-response-body.go.golden | 21 +- ..._empty-server-response-with-tags.go.golden | 21 +- ...e_explicit-body-primitive-result.go.golden | 23 +- ..._explicit-body-result-collection.go.golden | 25 +- ...licit-body-result-multiple-views.go.golden | 23 +- ...ent_decode_header-array-validate.go.golden | 21 +- .../client_decode_header-array.go.golden | 21 +- ...ode_header-string-array-validate.go.golden | 21 +- ...lient_decode_header-string-array.go.golden | 21 +- ...nt_decode_header-string-implicit.go.golden | 21 +- ...decode_required-primitive-arrays.go.golden | 48 + ...skip-response-body-encode-decode.go.golden | 36 + ...decode_tag-result-multiple-views.go.golden | 25 +- ...ode_validate-error-response-type.go.golden | 21 +- ...e_with-headers-dsl-viewed-result.go.golden | 21 +- .../client_decode_with-headers-dsl.go.golden | 21 +- ...dy-primitive-array-user-validate.go.golden | 2 +- ...ode_query-array-float32-validate.go.golden | 2 +- ...lient_encode_query-array-float32.go.golden | 2 +- ...ode_query-array-float64-validate.go.golden | 2 +- ...lient_encode_query-array-float64.go.golden | 2 +- ...uery-array-nested-alias-validate.go.golden | 2 +- ...lient_encode_query-bool-validate.go.golden | 2 +- .../golden/client_encode_query-bool.go.golden | 2 +- ...nt_encode_query-float32-validate.go.golden | 2 +- .../client_encode_query-float32.go.golden | 2 +- ...nt_encode_query-float64-validate.go.golden | 2 +- .../client_encode_query-float64.go.golden | 2 +- ..._encode_query-int-alias-validate.go.golden | 6 +- .../client_encode_query-int-alias.go.golden | 6 +- ...client_encode_query-int-validate.go.golden | 2 +- .../golden/client_encode_query-int.go.golden | 2 +- ...ient_encode_query-int32-validate.go.golden | 2 +- .../client_encode_query-int32.go.golden | 2 +- ...ient_encode_query-int64-validate.go.golden | 2 +- .../client_encode_query-int64.go.golden | 2 +- ..._encode_query-map-alias-validate.go.golden | 2 +- .../client_encode_query-map-alias.go.golden | 2 +- ...lient_encode_query-uint-validate.go.golden | 2 +- .../golden/client_encode_query-uint.go.golden | 2 +- ...ent_encode_query-uint32-validate.go.golden | 2 +- .../client_encode_query-uint32.go.golden | 2 +- ...ent_encode_query-uint64-validate.go.golden | 2 +- .../client_encode_query-uint64.go.golden | 2 +- ..._encode_skip-request-body-header.go.golden | 16 + ...endpoint_response_body_lifecycle.go.golden | 79 + ...rvices-same-payload-and-result_0.go.golden | 4 +- ...rvices-same-payload-and-result_1.go.golden | 4 +- ...types_client-mixed-payload-attrs.go.golden | 18 +- ...methods-with-array-type-payloads.go.golden | 36 +- ...nt_types_client-multiple-methods.go.golden | 25 +- ...client-required-primitive-arrays.go.golden | 76 + ...ypes_client-result-type-validate.go.golden | 4 +- ...treaming-payload-required-fields.go.golden | 8 +- ...pes_client-with-error-custom-pkg.go.golden | 2 +- ...es_client-with-result-collection.go.golden | 71 +- ...nt_types_client-with-result-view.go.golden | 18 +- ...ned_jsonrpc_validator_collisions.go.golden | 49 + .../golden/planned_name_collisions.go.golden | 184 +++ .../planned_service_name_uses.go.golden | 370 +++++ .../planned_union_name_collisions.go.golden | 152 ++ ...aming_response_collection_client.go.golden | 5 + ...aming_response_collection_server.go.golden | 21 + .../golden/server-multipart-array.golden | 22 + .../golden/server-multipart-map.golden | 19 + .../golden/server-multipart-object.golden | 22 + ...xtend-primitive-field-array-user.go.golden | 2 +- ...dy-extend-primitive-field-string.go.golden | 2 +- ...e_decode-body-path-user-validate.go.golden | 2 +- ...ver_decode_decode-body-path-user.go.golden | 2 +- ...dy-primitive-array-user-required.go.golden | 6 +- ...dy-primitive-array-user-validate.go.golden | 6 +- ...mitive-field-array-user-validate.go.golden | 2 +- ...-body-primitive-field-array-user.go.golden | 2 +- ...de-body-query-path-user-validate.go.golden | 2 +- ...code_decode-body-query-path-user.go.golden | 2 +- ..._decode-body-query-user-validate.go.golden | 2 +- ...er_decode_decode-body-query-user.go.golden | 2 +- ...e-body-required-primitive-arrays.go.golden | 29 + ...er_decode_decode-body-union-user.go.golden | 2 +- .../server_decode_decode-body-union.go.golden | 2 +- ...r_decode_decode-body-user-nested.go.golden | 2 +- ...decode_decode-body-user-required.go.golden | 2 +- ...decode_decode-body-user-validate.go.golden | 2 +- .../server_decode_decode-body-user.go.golden | 2 +- .../server_decode_decode-deep-user.go.golden | 12 +- ...r_decode_decode-map-query-object.go.golden | 2 +- ...decode-multipart-body-array-type.go.golden | 21 +- ...e_decode-multipart-body-map-type.go.golden | 11 +- ..._decode-multipart-body-primitive.go.golden | 11 +- ..._decode-multipart-body-user-type.go.golden | 15 +- ...decode-multipart-body-validation.go.golden | 29 + ...code_decode-multipart-with-param.go.golden | 65 + ...ultipart-with-params-and-headers.go.golden | 79 + ...-result-collection-explicit-view.go.golden | 2 +- ...result-collection-multiple-views.go.golden | 4 +- ..._explicit-body-result-collection.go.golden | 2 +- ...al_array-alias-extended_section0.go.golden | 6 +- ...al_array-alias-extended_section1.go.golden | 8 +- ...mbedded-custom-pkg-type_section0.go.golden | 6 +- ...mbedded-custom-pkg-type_section1.go.golden | 7 +- ...al_extension-with-alias_section0.go.golden | 8 +- ...al_extension-with-alias_section1.go.golden | 6 +- ...al_extension-with-alias_section2.go.golden | 10 +- ...al_extension-with-alias_section3.go.golden | 10 +- ...al_extension-with-alias_section4.go.golden | 8 +- ...erver_extensions_endpoint_helper.go.golden | 12 + .../server_extensions_escaping.go.golden | 22 + .../server_extensions_file_helper.go.golden | 6 + .../golden/server_extensions_init.go.golden | 37 + .../golden/server_extensions_mount.go.golden | 14 + ...erver_extensions_redirect_helper.go.golden | 5 + ...tipart_multipart-body-array-type.go.golden | 2 +- ...ltipart_multipart-body-user-type.go.golden | 2 +- ...tipart_multipart-body-validation.go.golden | 4 + ...server-multipart-body-array-type.go.golden | 6 +- ...t_server-multipart-body-map-type.go.golden | 6 +- ..._server-multipart-body-primitive.go.golden | 6 +- ..._server-multipart-body-user-type.go.golden | 6 +- ...server-multipart-body-validation.go.golden | 18 + ...part_server-multipart-with-param.go.golden | 44 +- ...ultipart-with-params-and-headers.go.golden | 58 +- ...oad_types_body-inline-array-user.go.golden | 6 +- ...yload_types_body-inline-map-user.go.golden | 10 +- ...types_body-inline-recursive-user.go.golden | 6 +- ...ad_types_body-path-user-validate.go.golden | 7 +- ...ver_payload_types_body-path-user.go.golden | 4 +- ...es_body-query-path-user-validate.go.golden | 4 +- ...yload_types_body-query-path-user.go.golden | 6 +- ...s_body-query-user-union-validate.go.golden | 6 +- ...load_types_body-query-user-union.go.golden | 6 +- ...d_types_body-query-user-validate.go.golden | 7 +- ...er_payload_types_body-query-user.go.golden | 4 +- .../server_payload_types_body-union.go.golden | 4 +- ...ad_types_body-user-inner-default.go.golden | 9 +- ...er_payload_types_body-user-inner.go.golden | 6 +- ...types_server-mixed-payload-attrs.go.golden | 39 +- ...ypes_server-multipart-validation.go.golden | 64 + ...er_types_server-multiple-methods.go.golden | 37 +- ...ver-payload-with-validated-alias.go.golden | 8 +- ...server-required-primitive-arrays.go.golden | 75 + ...treaming-payload-required-fields.go.golden | 25 +- ...pes_server-with-error-custom-pkg.go.golden | 2 +- ...lection-sibling-user-type-fields.go.golden | 29 +- ...es_server-with-result-collection.go.golden | 18 +- ...h-result-nested-user-type-fields.go.golden | 18 +- ...-result-sibling-user-type-fields.go.golden | 12 +- ...er_types_server-with-result-view.go.golden | 10 +- .../testdata/golden/sse-all-fields.golden | 54 +- http/codegen/testdata/golden/sse-bool.golden | 50 +- .../golden/sse-client-all-fields.golden | 28 +- .../testdata/golden/sse-client-bool.golden | 19 +- .../golden/sse-client-data-field.golden | 13 +- .../golden/sse-client-data-id-field.golden | 15 +- .../testdata/golden/sse-client-int.golden | 10 +- .../testdata/golden/sse-client-object.golden | 10 +- .../golden/sse-client-request-id.golden | 10 +- .../testdata/golden/sse-client-string.golden | 10 +- .../testdata/golden/sse-data-field.golden | 58 +- .../testdata/golden/sse-data-id-field.golden | 58 +- http/codegen/testdata/golden/sse-int.golden | 48 +- .../codegen/testdata/golden/sse-object.golden | 50 +- .../testdata/golden/sse-request-id.golden | 48 +- .../codegen/testdata/golden/sse-string.golden | 48 +- ...form_helper_bidirectional-client.go.golden | 41 + ...sform_helper_shared-declarations.go.golden | 23 + ...form_helper_sibling-declarations.go.golden | 13 + ...irectional-streaming-complex-client.golden | 2 +- ...ctional-streaming-with-views-client.golden | 15 +- ...-bidirectional-streaming-with-views.golden | 23 +- ...bsocket-server-streaming-with-views.golden | 34 +- http/codegen/testdata/openapi_dsls.go | 132 +- http/codegen/testdata/payload_dsls.go | 25 +- http/codegen/testdata/required_array_dsls.go | 32 + .../testdata/result_decode_functions.go | 936 ----------- .../testdata/shared_error_description_dsl.go | 63 + http/codegen/testdata/sse_dsls.go | 36 +- http/codegen/testdata/streaming_code.go | 206 +-- http/codegen/testdata/streaming_dsls.go | 52 +- http/codegen/transform_helper_test.go | 609 ++++++- http/codegen/typedef.go | 2 +- http/codegen/types.go | 43 +- http/codegen/validation_path_test.go | 198 +++ http/codegen/viewed_sse_test.go | 58 +- http/codegen/websocket.go | 87 +- http/codegen/websocket_golden_test.go | 8 +- http/codegen/wire_catalog.go | 1130 +++++++++++-- http/codegen/wire_catalog_test.go | 205 ++- jsonrpc/ARCHITECTURE.md | 242 +-- jsonrpc/README.md | 1070 ++----------- jsonrpc/codegen/client.go | 100 +- jsonrpc/codegen/kitchen_sink_test.go | 71 +- jsonrpc/codegen/package_import_alias_test.go | 52 + jsonrpc/codegen/plan.go | 367 ++--- jsonrpc/codegen/plan_service_test.go | 90 ++ jsonrpc/codegen/plan_test.go | 97 +- jsonrpc/codegen/server.go | 36 +- jsonrpc/codegen/server_error_contract_test.go | 31 +- .../codegen/server_protocol_runtime_test.go | 431 +++++ jsonrpc/codegen/single_endpoint_test.go | 38 - jsonrpc/codegen/sse.go | 13 +- jsonrpc/codegen/templates.go | 13 - .../templates/client_endpoint_init.go.tpl | 52 +- jsonrpc/codegen/templates/client_init.go.tpl | 15 - .../codegen/templates/client_struct.go.tpl | 15 - .../templates/mixed_server_handler.go.tpl | 153 +- .../templates/partial/single_response.go.tpl | 9 +- .../codegen/templates/response_decoder.go.tpl | 29 +- .../templates/server_encode_error.go.tpl | 12 +- .../codegen/templates/server_handler.go.tpl | 107 +- .../templates/server_handler_init.go.tpl | 152 +- jsonrpc/codegen/templates/server_init.go.tpl | 28 +- jsonrpc/codegen/templates/server_mount.go.tpl | 10 +- .../codegen/templates/server_struct.go.tpl | 15 - .../templates/sse_client_stream.go.tpl | 137 +- .../templates/sse_server_handler.go.tpl | 38 +- .../templates/sse_server_stream.go.tpl | 158 +- .../templates/sse_server_stream_base.go.tpl | 11 +- .../viewed_result_body_decode.go.tpl | 2 +- .../templates/viewed_result_decode.go.tpl | 16 +- .../templates/viewed_result_encode.go.tpl | 4 +- .../templates/websocket_client_conn.go.tpl | 495 ------ .../templates/websocket_client_stream.go.tpl | 281 ---- .../templates/websocket_server_close.go.tpl | 16 - .../templates/websocket_server_handler.go.tpl | 28 - .../templates/websocket_server_recv.go.tpl | 114 -- .../templates/websocket_server_send.go.tpl | 92 -- .../templates/websocket_server_stream.go.tpl | 21 - .../websocket_server_stream_wrapper.go.tpl | 49 - .../websocket_stream_error_types.go.tpl | 13 - .../testdata/golden/jsonrpc-sse-object.golden | 86 +- .../testdata/golden/jsonrpc-sse-string.golden | 78 +- .../golden/kitchen_sink/chat.go.golden | 52 - .../cmd/kitchen_sink-cli/http.go.golden | 25 +- .../cmd/kitchen_sink-cli/jsonrpc.go.golden | 52 +- .../cmd/kitchen_sink-cli/main.go.golden | 88 +- .../cmd/kitchen_sink/http.go.golden | 12 +- .../cmd/kitchen_sink/main.go.golden | 9 +- .../golden/kitchen_sink/feed.go.golden | 17 +- .../health/client/encode_decode.go.golden | 22 +- .../http/mixed/client/encode_decode.go.golden | 22 +- .../gen/jsonrpc/calc/client/client.go.golden | 1 - .../calc/client/encode_decode.go.golden | 76 +- .../gen/jsonrpc/calc/client/types.go.golden | 4 +- .../gen/jsonrpc/calc/server/server.go.golden | 234 ++- .../gen/jsonrpc/calc/server/types.go.golden | 2 +- .../gen/jsonrpc/chat/client/cli.go.golden | 34 - .../gen/jsonrpc/chat/client/client.go.golden | 583 ------- .../chat/client/encode_decode.go.golden | 115 -- .../gen/jsonrpc/chat/client/paths.go.golden | 13 - .../gen/jsonrpc/chat/client/types.go.golden | 49 - .../jsonrpc/chat/client/websocket.go.golden | 258 --- .../chat/server/encode_decode.go.golden | 47 - .../gen/jsonrpc/chat/server/paths.go.golden | 13 - .../gen/jsonrpc/chat/server/server.go.golden | 140 -- .../gen/jsonrpc/chat/server/types.go.golden | 58 - .../jsonrpc/chat/server/websocket.go.golden | 208 --- .../jsonrpc/cli/kitchen_sink/cli.go.golden | 86 +- .../gen/jsonrpc/feed/client/cli.go.golden | 18 + .../gen/jsonrpc/feed/client/client.go.golden | 39 +- .../feed/client/encode_decode.go.golden | 83 +- .../gen/jsonrpc/feed/client/paths.go.golden | 5 + .../gen/jsonrpc/feed/client/stream.go.golden | 106 +- .../gen/jsonrpc/feed/client/types.go.golden | 16 + .../feed/server/encode_decode.go.golden | 31 + .../gen/jsonrpc/feed/server/paths.go.golden | 5 + .../gen/jsonrpc/feed/server/server.go.golden | 376 ++++- .../gen/jsonrpc/feed/server/sse.go.golden | 92 +- .../gen/jsonrpc/feed/server/types.go.golden | 25 + .../gen/jsonrpc/mixed/client/client.go.golden | 1 - .../mixed/client/encode_decode.go.golden | 26 +- .../gen/jsonrpc/mixed/server/server.go.golden | 160 +- .../golden/kitchen_sink/manifest.golden | 12 - .../viewed_result_variable_decoder.go.golden | 85 + .../viewed_result_variable_encoder.go.golden | 48 + ...iewed_result_variable_sse_client.go.golden | 210 +++ ...iewed_result_variable_sse_server.go.golden | 51 + ...wed_result_variable_unary_client.go.golden | 46 + ...wed_result_variable_unary_server.go.golden | 45 + .../testdata/jsonrpc_kitchen_sink_dsls.go | 32 +- jsonrpc/codegen/viewed_result.go | 12 +- jsonrpc/codegen/viewed_result_golden_test.go | 146 ++ .../viewed_result_runtime_regression_test.go | 665 +++++++- jsonrpc/codegen/websocket_client.go | 146 -- .../websocket_connection_runtime_test.go | 699 -------- jsonrpc/codegen/websocket_connection_test.go | 118 -- jsonrpc/codegen/websocket_server.go | 117 -- jsonrpc/doc.go | 2 +- jsonrpc/integration_tests/README.md | 103 +- .../framework/codegen_data.go | 38 +- .../integration_tests/framework/constants.go | 10 +- .../integration_tests/framework/executor.go | 126 +- .../framework/framework_test.go | 44 +- .../integration_tests/framework/generator.go | 128 +- .../integration_tests/framework/options.go | 11 +- jsonrpc/integration_tests/framework/runner.go | 5 +- .../framework/templates/dsl/method.go.tpl | 7 +- .../framework/templates/dsl/type.go.tpl | 6 +- .../framework/templates/impl/service.go.tpl | 38 +- .../framework/templates/partial/method.go.tpl | 11 +- .../templates/partial/method_signature.go.tpl | 14 +- .../templates/partial/streaming_sse.go.tpl | 84 +- .../partial/streaming_websocket.go.tpl | 522 ------ .../framework/templates/partial/type.go.tpl | 5 +- jsonrpc/integration_tests/framework/types.go | 60 +- jsonrpc/integration_tests/go.mod | 1 - jsonrpc/integration_tests/go.sum | 2 - .../integration_tests/harness/cli_client.go | 18 +- jsonrpc/integration_tests/harness/client.go | 188 --- .../scenarios/scenarios.yaml | 812 +--------- jsonrpc/types.go | 82 +- jsonrpc/websocket_config.go | 193 --- 1049 files changed, 48326 insertions(+), 22481 deletions(-) create mode 100644 codegen/cli/cli_test.go create mode 100644 codegen/cli/templates/parse_flags_planned.go.tpl create mode 100644 codegen/example/plan_test.go create mode 100644 codegen/example/public_api_test.go create mode 100644 codegen/example/testdata/client-input-stream.golden create mode 100644 codegen/example/testdata/client-mixed-results.golden create mode 100644 codegen/example/testdata/client-server-stream.golden create mode 100644 codegen/generator/attached_jsonrpc_sse_integration_test.go create mode 100644 codegen/generator/command_isolation_test.go create mode 100644 codegen/generator/example_cli_input_stream_compile_test.go create mode 100644 codegen/generator/example_cli_result_runtime_test.go create mode 100644 codegen/generator/example_handler_args_integration_test.go create mode 100644 codegen/generator/example_immutability_test.go create mode 100644 codegen/generator/example_output_preservation_integration_test.go create mode 100644 codegen/generator/example_plan_test.go create mode 100644 codegen/generator/example_snapshot.go create mode 100644 codegen/generator/generate_grpc_cli_collision_integration_test.go create mode 100644 codegen/generator/generate_grpc_required_array_alias_integration_test.go create mode 100644 codegen/generator/generate_http_error_result_integration_test.go create mode 100644 codegen/generator/generate_http_multipart_validation_integration_test.go create mode 100644 codegen/generator/generate_http_required_array_alias_integration_test.go create mode 100644 codegen/generator/generated_grpc_shared_package_integration_test.go create mode 100644 codegen/generator/generated_service_path_integration_test.go create mode 100644 codegen/generator/http_plan_test.go create mode 100644 codegen/generator/http_sse_retry_integration_test.go create mode 100644 codegen/generator/openapi_replace_test.go create mode 100644 codegen/generator/plugin_public_integration_test.go create mode 100644 codegen/generator/public_api_compatibility_test.go create mode 100644 codegen/generator/transport_plan_test.go create mode 100644 codegen/internal/pluginregistry/registry.go create mode 100644 codegen/plugin.go create mode 100644 codegen/plugin_test.go create mode 100644 codegen/protobuf.go create mode 100644 codegen/service/codegen_specialization_test.go delete mode 100644 codegen/service/jsonrpc_websocket_signature_test.go create mode 100644 codegen/service/method_package_imports_test.go create mode 100644 codegen/service/method_payload_layout_test.go create mode 100644 codegen/service/render_name_compatibility_test.go delete mode 100644 codegen/service/service_dedup_test.go create mode 100644 codegen/service/service_package_path.go create mode 100644 codegen/service/service_package_path_test.go delete mode 100644 codegen/service/templates/jsonrpc_handle_stream.go.tpl delete mode 100644 codegen/service/testdata/dedup_event_marker_dsls.go create mode 100644 codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden create mode 100644 codegen/service/testdata/golden/example_service-mixed-results.go.golden create mode 100644 codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden create mode 100644 codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden create mode 100644 codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden create mode 100644 codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden create mode 100644 codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden create mode 100644 codegen/testdata/golden/go_transform_union_nil_branch.go.golden create mode 100644 expr/attached_service.go create mode 100644 expr/attached_service_test.go create mode 100644 expr/http_authored_attribute_test.go delete mode 100644 expr/http_service_test.go delete mode 100644 expr/jsonrpc_validation_test.go delete mode 100644 expr/testdata/mixed_jsonrpc_transports.go create mode 100644 grpc/codegen/compatibility.go create mode 100644 grpc/codegen/compatibility_test.go create mode 100644 grpc/codegen/import_plan.go create mode 100644 grpc/codegen/metadata_specialization_test.go create mode 100644 grpc/codegen/plan_retention_test.go create mode 100644 grpc/codegen/plan_service_data_test.go create mode 100644 grpc/codegen/planned_name_collision_test.go create mode 100644 grpc/codegen/proto_hooks_specialization_test.go create mode 100644 grpc/codegen/protobuf_descriptor_plan_test.go create mode 100644 grpc/codegen/protobuf_plan.go create mode 100644 grpc/codegen/protobuf_plan_order_test.go create mode 100644 grpc/codegen/protobuf_tools.go create mode 100644 grpc/codegen/protobuf_tools_test.go create mode 100644 grpc/codegen/released_streaming_name_test.go create mode 100644 grpc/codegen/server_protobuf_method_name_test.go create mode 100644 grpc/codegen/service_plan.go create mode 100644 grpc/codegen/service_plan_imports_test.go create mode 100644 grpc/codegen/symbols.go delete mode 100644 grpc/codegen/templates/partial/convert_type_to_string.go.tpl delete mode 100644 grpc/codegen/templates/partial/string_conversion.go.tpl create mode 100644 grpc/codegen/templates/partial/type_to_string_expression.go.tpl create mode 100644 grpc/codegen/testdata/client-bidirectional-streaming.golden create mode 100644 grpc/codegen/testdata/client-client-streaming.golden create mode 100644 grpc/codegen/testdata/client-server-streaming.golden create mode 100644 grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden create mode 100644 grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden create mode 100644 grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden create mode 100644 grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden create mode 100644 grpc/codegen/testdata/golden/planned_name_collisions.go.golden create mode 100644 grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden create mode 100644 grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden create mode 100644 grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden create mode 100644 grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden create mode 100644 grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden create mode 100644 grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden create mode 100644 grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden create mode 100644 grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden create mode 100644 grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden create mode 100644 grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden delete mode 100644 grpc/codegen/testdata/request_encoder_code.go delete mode 100644 grpc/codegen/testdata/response_encoder_code.go create mode 100644 grpc/codegen/view_specialization_test.go create mode 100644 http/codegen/client_query_float_runtime_test.go create mode 100644 http/codegen/client_response_body_runtime_test.go create mode 100644 http/codegen/compatibility.go create mode 100644 http/codegen/error_body_description_test.go create mode 100644 http/codegen/openapi/error_example.go create mode 100644 http/codegen/openapi/json_schema_dup_test.go delete mode 100644 http/codegen/openapi/json_schema_union_test.go create mode 100644 http/codegen/openapi/response_projection.go create mode 100644 http/codegen/openapi/v2/build_isolation_test.go create mode 100644 http/codegen/openapi/v2/description_ownership_test.go create mode 100644 http/codegen/openapi/v2/json_schema.go create mode 100644 http/codegen/openapi/v2/json_schema_union_test.go create mode 100644 http/codegen/openapi/v2/public_api_test.go create mode 100644 http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden create mode 100644 http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden create mode 100644 http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden create mode 100644 http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden create mode 100644 http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden create mode 100644 http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden create mode 100644 http/codegen/openapi/v3/description_ownership_test.go create mode 100644 http/codegen/openapi/v3/example_test.go create mode 100644 http/codegen/openapi/v3/public_api_test.go create mode 100644 http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden create mode 100644 http/codegen/openapi/values.go create mode 100644 http/codegen/openapi/values_test.go create mode 100644 http/codegen/openapi_plan_test.go create mode 100644 http/codegen/plan_extensions_test.go create mode 100644 http/codegen/plan_service_test.go create mode 100644 http/codegen/planned_name_collision_test.go create mode 100644 http/codegen/planned_service_name_uses_test.go create mode 100644 http/codegen/plugin_api_compatibility_test.go create mode 100644 http/codegen/plugin_api_test_helpers_test.go create mode 100644 http/codegen/released_streaming_name_test.go create mode 100644 http/codegen/server_extensions_test.go create mode 100644 http/codegen/sse_mixed_result_runtime_test.go create mode 100644 http/codegen/sse_primitive_wire_runtime_test.go create mode 100644 http/codegen/templates/partial/client_type_expression.go.tpl create mode 100644 http/codegen/testdata/golden/client-mixed-results.golden create mode 100644 http/codegen/testdata/golden/client-streaming-input-only.golden create mode 100644 http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden create mode 100644 http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden create mode 100644 http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden create mode 100644 http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden create mode 100644 http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden create mode 100644 http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden create mode 100644 http/codegen/testdata/golden/planned_name_collisions.go.golden create mode 100644 http/codegen/testdata/golden/planned_service_name_uses.go.golden create mode 100644 http/codegen/testdata/golden/planned_union_name_collisions.go.golden create mode 100644 http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden create mode 100644 http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden create mode 100644 http/codegen/testdata/golden/server-multipart-array.golden create mode 100644 http/codegen/testdata/golden/server-multipart-map.golden create mode 100644 http/codegen/testdata/golden/server-multipart-object.golden create mode 100644 http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden create mode 100644 http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden create mode 100644 http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden create mode 100644 http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_escaping.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_file_helper.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_init.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_mount.go.golden create mode 100644 http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden create mode 100644 http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden create mode 100644 http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden create mode 100644 http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden create mode 100644 http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden create mode 100644 http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden create mode 100644 http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden create mode 100644 http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden create mode 100644 http/codegen/testdata/required_array_dsls.go delete mode 100644 http/codegen/testdata/result_decode_functions.go create mode 100644 http/codegen/testdata/shared_error_description_dsl.go create mode 100644 http/codegen/validation_path_test.go create mode 100644 jsonrpc/codegen/package_import_alias_test.go create mode 100644 jsonrpc/codegen/plan_service_test.go create mode 100644 jsonrpc/codegen/server_protocol_runtime_test.go delete mode 100644 jsonrpc/codegen/templates/websocket_client_conn.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_client_stream.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_close.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_handler.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_recv.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_send.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_stream.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl delete mode 100644 jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden delete mode 100644 jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden create mode 100644 jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden create mode 100644 jsonrpc/codegen/viewed_result_golden_test.go delete mode 100644 jsonrpc/codegen/websocket_client.go delete mode 100644 jsonrpc/codegen/websocket_connection_runtime_test.go delete mode 100644 jsonrpc/codegen/websocket_connection_test.go delete mode 100644 jsonrpc/codegen/websocket_server.go delete mode 100644 jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl delete mode 100644 jsonrpc/websocket_config.go diff --git a/AGENTS.md b/AGENTS.md index 75a1eee6a6..3279e38a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,25 +55,35 @@ No commented-out code—delete dead code. ### Codegen Implementation +- **Critical — finish work during generation**: Use the complete design, generation plan, + and Go templates to decide everything that is known before the generated + program runs. Select branches, names, types, imports, field paths, helper + calls, and emitted files while generating source. Templates must write only + the selected code. Do not make generated programs inspect generated type + shapes, parse generator-made names, carry generator mode flags, or execute + branches whose answer was already known. Runtime code should contain only + logic that depends on actual runtime values. When a runtime value is truly + required, keep that input narrow and specialize all surrounding code during + generation. - **Use NameScope helpers** for type references: `GoTypeRef`, `GoFullTypeRef`, `GoTypeName`. Never concatenate strings for types. - Let Goa decide pointer/value semantics. Do not force `pointer=true` except in transport validation. - **Keep helper visibility minimal**: If logic is shared only inside one codegen area, keep it package-private or move it under an `internal` package. Do not export helpers from a parent package just to share them across sibling generators. - **Avoid pass-through wrappers**: When two helper functions differ only by forwarding arguments or hard-coding `nil`, collapse them into a single implementation instead of adding an extra layer. -- **Generated packages own names**: When declarations from multiple services - or plugins compile into one Go package, the generation context plans and - freezes that package's `NameScope` and canonical declaration records before - rendering. Definitions and HTTP/gRPC/JSON-RPC references must consume the - same package-owned record; independently primed service or plugin scopes and - declarations added after freeze are invalid. -- **Keep identity typed and explicit**: Do not encode declaration kind, package, - scope, or lifetime in decorated names or synthetic string map keys. Do not - change an expression's `Hash` semantics to satisfy code generation; pass an - explicit code-generation identity at the naming site. +- **Generated packages own names**: When several services or plugins write to + one Go package, collect every package-level name before rendering and then + make those names final. A declaration and every HTTP, gRPC, or JSON-RPC use + of it must read the same name record. Do not give each service or plugin a + separate name scope for the same package, and do not add declarations after + names become final. +- **Keep identity typed and explicit**: Do not hide a declaration's kind, + package, or use in a decorated name or a made-up string map key. Do not + change an expression's `Hash` behavior to solve a generation problem. Pass a + typed identifier where the generated declaration is named. - **Trace the complete lifecycle**: Before changing relocated types, union naming, generation roots, plugins, or file merging, follow the declaration - from the one evaluated design root through service analysis, package - ownership, service emission, HTTP and gRPC references, post-generation - plugins, and final path merging. A service-only rendering test is not enough. + from the evaluated design through service analysis, its generated Go + package, the emitted service code, HTTP and gRPC uses, plugin changes, and + the final file merge. A service-only rendering test is not enough. See [`codegen/ARCHITECTURE.md`](codegen/ARCHITECTURE.md). ### Documentation diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 4fcc4b7a4b..126512c648 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -31,8 +31,9 @@ records. Rendering reads that analysis; it never reconstructs it. The `goa` command compiles and runs a temporary generator for one evaluated design. One run follows this order: -1. Resolve the command and instantiate fresh core generator and plugin objects - from immutable registered factories. +1. Resolve the command and create fresh core generator and factory-plugin + objects. Copy plugins registered through the released callback API into the + same run. 2. Evaluate and validate the design roots. 3. Run preparation plugins, which may add or change expressions. Construct one `codegen.Generation` with exclusive access to those evaluated roots. Its @@ -46,17 +47,20 @@ design. One run follows this order: copied or made immutable. 5. Build one typed `generator.Plan`. It creates and retains the core service plan for each root, then the selected HTTP, gRPC, JSON-RPC, OpenAPI, and - example plans that consume those exact service plans. Plugin planning - receives the same typed plan. + example plans that consume those exact service plans. Factory plugins + receive the same plan when they declare their output. 6. Each subsystem completes collection, sorts declarations by stable typed identity, and declares every package-level symbol in its actual output package. The generation then freezes package names and import qualifiers. 7. Link each retained subsystem plan once. Linking converts recorded design - facts and frozen declaration references into immutable template data. It - cannot discover a declaration, reserve a name or import, mutate an - expression, or create another analysis graph. -8. Core generators render their retained subsystem plans. Plugins render using - the same `generator.Plan` and exact core service plans. + facts and final declaration references into template data. All generated + names are fixed, but later plugin callbacks may still make the permitted + edits to ordinary section values. Linking cannot discover a declaration, + reserve a name or import, mutate an expression, or create another analysis + graph. +8. Core generators and factory plugins render from the same `generator.Plan` + and exact core service plans. Released callbacks receive their original + generated package, roots, and current file list instead. 9. Merge contributions with the same canonical output path and render files. Collection must be complete before freeze. Stable ordering makes preferred-name @@ -86,15 +90,18 @@ func RegisterPluginFirst(name, command string, factory PluginFactory) func RegisterPluginLast(name, command string, factory PluginFactory) ``` -These APIs belong to `codegen/generator`, which owns command orchestration. -The `codegen` package owns generated declarations and files; it does not own a -process-global plugin lifecycle. Core generator factories follow the same -fresh-instance rule. +These factory APIs belong to `codegen/generator`, which runs generation +commands. Goa also keeps the released four-argument registration functions in +`codegen`. Those functions store callback pairs in an internal registry; the +generator copies them into the same run when generation starts. Plugin authors +cannot inspect the registry or run callbacks themselves. Core generator +factories follow the same fresh-instance rule. -Plugin names are non-empty and unique within one command across the First, -normal, and Last groups. Registration rejects unknown commands and stops after -the first run snapshots the registry. This makes alphabetical order a complete -ordering rule instead of relying on mutable registration sequence. +Factory plugin names are non-empty and unique within one command across the +First, normal, and Last groups. Released callback registrations may repeat a +name, as Goa v3 allowed; equal names keep registration order. Both APIs reject +unknown commands and stop accepting registrations when generation first +starts. A factory may close over immutable configuration. Per-run roots, plans, files, caches, and errors belong to the returned object. Concurrent and repeated @@ -143,11 +150,11 @@ wire files, but it does not rebuild HTTP analysis. Render functions accept the retained subsystem plan, not a `Generation`, generated module path, expression root, or reconstructed `ServicesData`. -The plan stores collected design facts, immutable linked render data, and -canonical declaration pointers. It does not store callbacks that repeat -analysis. `NewServicesData`, `Genfunc`, -the replaceable `Generators` variable, `renderOnly`, and the callback plugin -registry are transition mechanisms to delete. +The plan stores collected design facts, linked render data, and final +declaration pointers. It does not store callbacks that repeat +analysis. `NewServicesData`, `Genfunc`, the replaceable `Generators` variable, +`renderOnly`, and the released functions that ran plugin callbacks are old +entry points that the retained plan replaces. ## Generated package ownership @@ -167,8 +174,8 @@ union, branch, HTTP wire, protobuf, validator, and helper records contain or reference `NameDeclaration`; they do not carry another independently mutable name. -Package-level declarations include less obvious symbols: union discriminator -constants and constructors, endpoint constructors, error and +Package-level declarations include less obvious symbols: constants that record +a union's selected branch, union constructors, endpoint constructors, error and result constructors, validation functions, conversion functions, stream interfaces and helpers, HTTP body constructors, protobuf oneof wrappers, client and server constructors, and package variables emitted by templates. @@ -176,6 +183,23 @@ Local variables, parameters, struct fields, and method names remain owned by their lexical render scope because they cannot collide with package-level declarations. +Service package paths are assigned once across every prepared design root in a +generation run. Equal authored service names share one generated package even +when they come from different APIs. Different names that reduce to the same Go +package name receive stable numeric suffixes. Natural package names are +reserved first, so an authored service whose normal package is `read_value2` +keeps that path while another collision advances to `read_value3`. The linked +service data exposes the final directory as `PathName`. HTTP, gRPC, JSON-RPC, +MCP, examples, and command-line generators read that retained path and its +saved import; they never rebuild a package path from the service name. + +Generated command-line parsers also plan names at the scope that Go checks: +the complete `ParseEndpoint` function. The parser reserves imported package +names first, then parameters and fixed local variables, then each command's +flag variables and conversion variables. Templates receive those exact names +and write the selected conversion directly. They do not search generated text, +replace variable names, or decide a conversion from a type name at runtime. + ### Exact and preferred symbols An exact symbol is part of an authored or external contract. Two distinct @@ -230,11 +254,14 @@ Expression identity answers a design question. Declaration identity answers whether two generated package-level symbols are the same emitted contract. They are deliberately separate. -`UserType.Origin()` identifies one authored declaration across exact compiler -copies. Recursion walkers use Origin only to detect a cycle in the current -graph traversal. A cycle set answers “have I entered this declaration on this -path?” It never proves that two emitted wire declarations, validators, or -helpers are interchangeable. +`UserType.Origin()` identifies the first declaration in one family of exact +copies. A generated transport type may start a new family, and renaming a type +deliberately starts another one. Goa uses this identity while binding types, +normalizing designs, planning service and transport declarations, creating +protobuf messages, transforming values, validating fields, building views, +and generating examples. It keeps unrelated same-named types separate, but it +does not by itself prove that two generated declarations have the same fields +or behavior. An emitted declaration identity contains every fact that changes its generated source: owning package and role, source provenance, wire shape, validation, @@ -290,6 +317,46 @@ When multiple prepared roots contribute to one generated package, the core plan collects all their declarations before the package freezes and emits the package once. A root not present in the Generation snapshot is rejected. +## Value conversion plans + +`TransformPlan` records one conversion from a source Go value to a target Go +value. Creating the plan copies both type graphs, finds every recursive helper +function the conversion will call, and records the choices made by hooks that +change the type shape. Later edits to the design types or hook value cannot +change the planned conversion. Two generated copies remain separate even when +they came from the same authored type; only an edge back to the exact copied +value closes a recursive cycle. + +Planning hooks may return a different source or target attribute for one +conversion step, but they must not edit either graph they receive. The planner +checks this immediately after each hook returns, including changes to nested +defaults and metadata, and rejects the plan when a hook mutates its input. +This makes the returned choice explicit without letting one hook change what a +later hook or the renderer sees. + +The caller declares every helper function in the package that will contain it, +binds those declarations and the final source and target type resolvers, and +then renders the conversion. Planning and rendering therefore use the same +function declarations and final package qualifiers. `GoTransformWithAttrs` +keeps its released interface, but now performs these same steps internally and +returns the released helper data after rendering once. + +`Helpers` returns detached type descriptions together with plan-owned helper +IDs. A caller may inspect or change those descriptions while choosing a +function declaration, but cannot change the type graph used for rendering. +Rendering also rejects a hook that changes the retained graph. When one plan is +rendered again with the same source variable, target variable, and assignment +choice, it returns a copy of the first result instead of calling hooks again. +This lets one conversion plan serve repeated template requests without letting +hook state change previously generated code. + +Most conversions can discover their helper calls from the source and target +types. A custom union renderer that calls helpers itself must also implement +`TransformHooks.PlanUnionHelpers`. That hook records the exact branch pairs for +which the renderer will request helpers. This keeps union rendering +specialized: generated code contains only the branch conversions selected +during generation and performs no runtime lookup by branch name or package. + ## HTTP and JSON-RPC plans Each actual HTTP client or server output package owns a retained wire plan. It @@ -303,6 +370,21 @@ bodies do not carry service package metadata. JSON-RPC consumes the exact HTTP plan for the files and codecs it shares, then adds its own package declarations to typed JSON-RPC plans. It does not create a second HTTP catalog. +Every HTTP and JSON-RPC output file records the service, views, and authored +package paths it will use before package names become final. Linking resolves +only those saved paths to their final qualifiers; it does not inspect design +expressions again or rebuild a service path from its name. Public plan snapshots +return detached nested values, so changing a snapshot cannot change a later +read or rendered file. + +A method with separate ordinary and streamed results plans the streamed SSE +body independently. When the retained service value and HTTP body have the +same Go layout, the client assigns the decoded value directly. When their Go +layouts differ, the client validates the decoded body and uses the exact saved +conversion. An empty streamed result emits its explicit zero-value return. +These choices are made while generating source; the generated client does not +inspect types or select a conversion at runtime. + Every validator and helper reference stores its canonical declaration. A call site's traversal context may select which declaration it needs, but it never selects or changes that declaration's name. @@ -350,11 +432,53 @@ plugin that adds a service, method, type, or transport mapping attaches it to a registered root during preparation. Core normalization then observes it before planning. -Plugin planning receives `*generator.Plan`. It may declare plugin-owned output -through the same Generation, and it consumes core declarations through the -exact retained service plan. Plugin rendering receives the same plan after -freeze. It may add files and sections, but it cannot create another root, -re-run service or transport analysis, reserve a name, or change an expression. +After adding services and any new user types to that root, the preparation +plugin calls `(*expr.RootExpr).EvaluateAttachedServices`. This checks that the +new expressions belong to the same root, prepares and validates all of them, +and finishes none of them when any one is invalid. It is the public operation +for adding evaluated services; plugins must not run individual expression +steps or use the package-global root. + +Factory plugin planning receives `*generator.Plan`. It may declare plugin-owned +output through the same Generation, and it consumes core declarations through +the exact retained service plan. Factory plugin rendering receives the same +plan after names are final. It may add files and sections, but it cannot create +another root, re-run service or transport analysis, reserve a name, or change +an expression. Released callbacks keep their original arguments and do not +gain a planning phase. + +An HTTP plugin calls `Plan.HTTP(root)` with the exact prepared service root it +received. The method returns the ordinary HTTP plan for that root. It returns +false for a different root value and for a root that only has JSON-RPC methods. +During `Plugin.Plan`, the plugin may declare an exported server handler wrapper +for an exact HTTP service, an unexported handler wrapper for one exact HTTP +endpoint, or an extra server mount for an exact HTTP service in that plan. Goa +submits those function names to the service's generated server package, so +collisions are settled with Goa's own names before source is written. + +A declared handler wrapper has the shape `func(http.Handler) http.Handler`. +Goa writes direct nested calls inside each exported endpoint and file mount +helper. Direct callers of those helpers therefore receive the same wrapping as +callers of the service's `Mount` function. Endpoint handlers, file handlers, +and redirects defined by the design are covered, and the first declared +wrapper is the outermost. The service's `Mount` function passes each handler to +its helper unchanged, so wrappers run exactly once. +The linked HTTP service data retains that exact declaration list and copies it +into each generated endpoint and file mount helper, so plugin output can match +the declaration by pointer even for a service that contains only files. +An endpoint handler wrapper also has the shape +`func(http.Handler) http.Handler`, but Goa writes it only in that endpoint's +exported mount helper. Service wrappers surround endpoint wrappers. File mount +helpers receive service wrappers only. This lets a plugin add behavior to one +designed endpoint without changing another endpoint, a file route, or an extra +plugin mount. +An extra server mount has the shape `func(goahttp.Muxer)` and supplies the +method label, HTTP verb, and path that Goa adds to the generated server's +`Mounts` list. Goa calls extra mounts after routes from the design, in +declaration order. Extra mounts are separate calls and are not passed through +the declared handler wrappers. Both declarations must happen before generation +freezes; later calls, JSON-RPC plans, foreign services, missing route fields, +and changes to the linked declarations are generation errors. MCP generation therefore attaches its generated service expressions during prepare and later consumes `Plan.Service(root)`. Agent tool specifications use @@ -363,6 +487,14 @@ transport specs are distinct packages with distinct declaration owners. No plugin coordinates through a process-global map, a latest result, a decorated hash, `PlanKey`, or render order. +When a plugin needs a generated payload field, it asks the retained service +plan for `MethodPayloadLayout`. The returned `GoTypePlan` contains the exact Go +field spelling and pointer choice already selected by service generation. For +example, a design field sent as JSON `cursor` may be generated as +`OriginalCursor` because of field metadata; the plugin keeps `cursor` on the +wire and emits `payload.OriginalCursor`. It must not call `Goify`, inspect the +JSON name, or repeat the service pointer rules. + ## File assembly `SectionTemplate.Name` labels a section for diagnostics. It is not declaration @@ -374,16 +506,414 @@ of disappearing silently. ## Compatibility and operations -This architecture intentionally breaks external generators and plugins that -register callback instances, replace `Generators`, call `NewServicesData`, or -render from roots and generated module paths. They must register factories, -retain typed plans, and render those plans. +Goa keeps the released callback registration API alongside the retained-plan +API. Both registration styles enter the same generation run, use the same +prepared designs and current file list, and run in the released first, normal, +and last order. The released API still accepts the same name more than once for +one command. Registrations with the same position and name run in registration +order, matching released Goa v3. Factory plugin names remain unique, and Goa rejects +a released registration whose command and name match a factory registration so +the same plugin cannot run twice through two APIs. Goa does not restore the +released functions that ran plugins; the generator remains the only code that +executes them. + +This compatibility is deliberately limited. A released preparation callback +may change a design before Goa chooses names. A released generation callback +may edit ordinary values and remove or reorder files, sections, or list +entries. A nil file entry is ignored for every list size. Released Goa ignored +nil among several files but accidentally panicked when nil was the only file. +Goa's own templates read declarations chosen during planning rather than the +released string copies, so changing only a released name field does not rename +Goa's code. The public strings remain final name snapshots for existing plugin +templates. A plugin may deliberately replace declaration fields, sections, +templates, source, or file finalizers; as in released Goa, that plugin owns the +correctness of the resulting source. + +An ordinary callback error is returned unchanged and stops the run. If the +same callback also changes a prepared design after planning, Goa reports that +forbidden change instead. The changed root remains visible after the failed +run, so hiding it behind the callback error could corrupt a later run. + +A plugin that adds package declarations or chooses Go names must use a +`generator.PluginFactory` and declare that work through `Plugin.Plan` before +names become final. Rebuilding Goa's private service or transport records is +not supported; plugins should render their own typed values instead. Upgrade +those plugins with Goa, then regenerate the whole generated tree from an empty +output directory. + +A gRPC plugin that renders viewed-result conversions must use +`ResponseData.ServerConverts` and `ClientConverts`, or +`StreamData.SendConverts` and `RecvConverts`, as appropriate. The singular +conversion fields remain available for existing templates, but they describe +the default or fixed view only. Reusing one singular conversion for every view +can read a field that the selected view deliberately omitted. + +The remaining breaks are limited to generation and generated source except +where the runtime changes are listed below. There is no persisted-data +migration or staged generator mode. Already compiled programs do not start +using the new generator merely because the Goa module is updated. + +### Generator library migration + +The following table lists the preserved, removed, or signature-changed +exported APIs in this change. “No direct replacement” means callers must stop +performing that step; the run or the owning retained plan now performs it once. + +| Package | Old API | New API or required change | +| --- | --- | --- | +| `codegen` and `codegen/generator` | `codegen.RegisterPlugin`, `RegisterPluginFirst`, and `RegisterPluginLast` accepted prepare and generate callbacks | These four-argument functions remain available. Goa runs their callbacks in the same run as plugins registered through `codegen/generator`. Registration now panics for an empty name, an unknown command, a nil generate callback, or a call made after generation has started. Use the factory API when a plugin must plan new package declarations or read service and transport plans from the current run. | +| `codegen` | `PrepareFunc`, `GenerateFunc`, `RunPluginsPrepare`, and `RunPlugins` | `PrepareFunc` and `GenerateFunc` remain available for registration. The two run functions have no replacement because `generator.Generate` owns the complete plugin lifecycle. | +| `codegen/generator` | `Genfunc`, the replaceable `Generators` variable, and the exported `Example`, `Service`, `Transport`, and `OpenAPI` functions | `Genfunc` remains as an exported function type so existing declarations compile, but the generator command no longer accepts or calls it. The variable and four core functions have no replacement because calling or replacing them would split one generation into separate name plans. Register a plugin factory when adding output; the command chooses and runs the core generators. | +| `codegen` | `NormalizeRoot` | No direct replacement. `codegen.NewGeneration` performs normalization once and records the exact generated method types. | +| `codegen` | `AddServiceMetaTypeImports` | No direct replacement. The owning service or transport plan records the imports used by each output file. | +| `codegen` | `NewAttributeContextForConversion` | No direct replacement. Build and bind a `TransformPlan`; its source and target contexts retain the correct package owners. | +| `codegen` | Custom `Attributor` implementations needed only `Scoper`, `Name`, `Ref`, and `Field` | Implement `Package`, `Enter`, `IsSumType`, and `ValidatorCall` too. These methods make package ownership and exact validator calls explicit. | +| `codegen` | `AttributeContext.DefaultPkg` and `SamePackageConversion` | Removed. Package lookup belongs to the `Attributor`; enter the source or target attribute instead of setting a default package or a same-package mode. `ArrayElementPointer` is new and is only for a wire array that must distinguish JSON `null` from a primitive zero value. | +| `codegen` | `TransformHooks.HelperNameAttrs` | Removed. Bind each recursive helper through `TransformPlan.Helpers` and `BindHelperDeclaration`; do not derive a helper name from a second attribute walk. A custom `TransformUnion` that calls `TransformHelperName` must also implement `PlanUnionHelpers` so planning can declare those functions before rendering. | +| `codegen` | `WrapDirective.InitTypeName` | Set `WrapDirective.Target` to the wrapper attribute. Rendering resolves its already planned Go type name. | +| `codegen` | `TransformFunctionData` contained only `Name`, parameter and result references, and code | It now also identifies the planned helper with `ID` and `Declaration`. `Name` remains as a deprecated copy of `Declaration.Name()` for every rendered helper. Unkeyed struct literals must be updated. | +| `codegen/cli` | `BuildCommandData(data)`, `EndpointParserFile(..., data, parseSection)`, `UsageCommands(data)`, `UsageExamples(data)`, and `FlagsCode(data)` | These released signatures and section data remain available. Goa's HTTP and gRPC planners call private planned variants that carry final import, declaration, and local-variable names. | +| `codegen/cli` | `BuildFunctionData.Name`, `ActualParams`, and `FormalParams` | These fields remain and contain the final generated name and parameter lists. New planning code may also read `BuildFunctionData.Declaration`. | +| `codegen/cli` | `NewFlagData` accepted a type name; `FieldLoadCode` accepted type and validation source strings; `FlagArgData.TypeName` and `Validate`; positional `FlagData` literals | The released functions and fields remain available and keep their string-based behavior. Goa's transport planners use one opaque `FlagPlan`, created by `NewFlagPlan`, so validation is written against the exact parsed value without rewriting generated text. Supplying both `Validate` and `Plan` is rejected. Use named fields when constructing `FlagData` because it now contains private planning state. | +| `codegen/example` | Global `Servers`, `ServersData`, `ServersData.Get`, and `APIPkg` | No direct replacement. Create `example.Plan` with `example.NewPlan`, then get its copied `example.Root`. Package names come from the associated service plan. The pure `RootPath(genpkg)` helper remains available. | +| `codegen/example` | `CLIFiles(genpkg, root)` and `ServerFiles(genpkg, root, services)` | Call `CLIFiles(root)` and `ServerFiles(root, services)` with the `*example.Root` returned by the retained example plan. | +| `codegen/example` | `VariableData.VarName`; `HandlerArg.Endpoint` and `Service` contained generated local variable names | `VariableData.VarName` is removed. `HandlerArg.Service` contains the design service name, `Endpoint` reports whether the endpoint collection is needed, and `Variable` contains the final local variable name after the example plan is linked. `Data` also reports whether the server uses HTTP or JSON-RPC. | +| `codegen/service` | `NewServicesData` | Create one `codegen.Generation`, then call `service.NewPlans` for the complete root batch. Use `service.NewPlan` only when the generation has exactly one service root. After freeze and `Link`, use `Plan.Services`. | +| `codegen/service` | `ClientFile`, `EndpointFile`, `ConvertFiles`, `InterceptorsFiles`, and `ViewsFile`; `Files(genpkg, service, services, userTypePkgs)` | No direct per-file replacement. Call `service.Files(plans...)` after every supplied plan is linked and handle its `([]*codegen.File, error)` result. The plans decide the complete file set. | +| `codegen/service` | `SetUserTypeImports`, `AddServiceDataMetaTypeImports`, and `AddUserTypeImports` | No direct replacement. Imports are retained per file by the service plan. | +| `codegen/service` | `ExampleServiceFiles(genpkg, root, services)` and `ExampleInterceptorsFiles(genpkg, root, services)` | Pass the linked `*service.Plan` to `ExampleServiceFiles(plan)` or `ExampleInterceptorsFiles(plan)`. | +| `codegen/service` | Public render-data fields `Data.UserTypeImports`; `MethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; `EndpointMethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; and `StreamData.SendAndCloseName`, `SendAndCloseDesc`, `SendAndCloseWithContextName`, and `SendAndCloseWithContextDesc` | Removed. Factory plugins inspect the HTTP or JSON-RPC plan's endpoint data. Released service-template plugins must update templates that read these fields. There is no JSON-RPC WebSocket replacement because design validation rejects that transport combination. `EndpointsData.VarName`, `ClientVarName`, and `ServiceVarName` and `EndpointMethodData.ClientVarName` and `ServiceVarName` remain as deprecated copies of their final declarations. `Data.ViewsPkg` and `ProjectedTypeData.ViewsPkg` remain available when Goa generates a views package. `ErrorInitData.Name`, `InitData.Name`, and `ValidateData.Name` also remain as deprecated copies of real planned declarations. A custom error has no generated service constructor, so both its constructor declaration and compatibility name are empty. `ValidateData` contains function calls now, so it cannot be compared with `==` or used as a map key. | +| `codegen/service` interceptor section data | Interceptor wrapper sections exposed `map[string]any` values with keys such as `Method`, `Service`, and `Interceptors` | The sections now use private typed data built for the exact method and call kind. Plugins that replace only a section's source must update to the current section contract; plugins cannot type-assert the old map. This lets generated accessors use exact payload, result, and stream types without runtime method checks. | +| Generated service interceptors | An interceptor method accepted `info *NameInfo`, where `NameInfo` was an exported struct with private fields | The method accepts `info NameInfo`, where `NameInfo` is an interface with the same public accessor methods. Update handwritten interceptor signatures by removing `*`. Goa now writes a private implementation for each service method and call kind, so payload and stream accessors use the exact generated types without inspecting the method at runtime. | +| `expr` and `dsl` | `APIExpr.ExampleGenerator`; `dsl.Randomizer(expr.Randomizer)`; `NewRandom` | Store an immutable `APIExpr.RandomizerFactory`. Pass `NewFakerRandomizerFactory` or `NewDeterministicRandomizerFactory` to the DSL. For direct example generation, call `NewExampleGenerator(factory).At(identity)`. The standalone `NewFakerRandomizer` and `NewDeterministicRandomizer` constructors remain available. | +| `expr` | Exported concrete `FakerRandomizer` and `DeterministicRandomizer`; the embedded `ExampleGenerator.Randomizer`; `ExampleGenerator.Derived`, `Rebased`, `Field`, `PreviouslySeen`, and `HaveSeen` | The two standalone randomizer types and their released constructors remain available. Generation uses `RandomizerFactory` so every typed example identity receives a fresh value sequence. The embedded stream and recursion methods are removed; select a public typed `ExampleIdentity`, then descend with `Member`, `ArrayElement`, `MapKey`, `MapValue`, or `UnionMember`. | +| `expr` | Custom implementations of `UserType` | Add `Origin() UserType`. A copy returns the first declaration in its current family. An independently created or intentionally renamed type returns itself. Goa uses this identity to recognize copies without treating unrelated types with the same name as one type. | +| `expr` | Repeated calls to `(*AttributeExpr).Validate` in one process could skip errors reported by an earlier call | Every call now validates the supplied expression and reports its errors. Direct users must not rely on a previous call hiding a later validation failure. | +| `expr` | A non-pointer `ResultTypeExpr` value satisfied `UserType` through its embedded `*UserTypeExpr` | Use `*ResultTypeExpr`. Renaming a result must also clear the result's stored copy origin, so `Rename` now belongs to the pointer and a value no longer satisfies `UserType`. Ordinary result expressions were already created and passed as pointers. | +| `expr` | Preparation plugins added services and called expression steps themselves | After adding the services and any new user types to their owning root, call `(*RootExpr).EvaluateAttachedServices`. It prepares and validates the complete added set before finishing any of it. | +| `expr` | Positional struct literals for `ResultTypeExpr`, `SchemeExpr`, `ServiceExpr`, and `UserTypeExpr` | Use literals with named fields or the package constructors. These structs now contain private identity fields, so code outside `expr` cannot initialize every field by position. | +| `expr` | `UnionToObject` | No direct replacement. HTTP and gRPC plans retain their own wire representation for a union. | +| `codegen` | Comparing `TransformAttrs` values or using them as map keys | `TransformAttrs` now stores maps and function-planning state, so it is no longer comparable. Pass pointers or compare the specific public fields that matter to the caller. Positional literals also need to become named-field literals. | +| `grpc/codegen` | `NewServicesData(serviceData)` | Create `grpc/codegen.Plan` values with `NewPlans`. `ServicesData.GRPCServices` and `ServicesData.Get` remain available, but callers should read the instance built by the plan instead of rebuilding gRPC analysis. | +| `grpc/codegen` | `ClientFiles(genpkg, data)`, `ClientCLIFiles(genpkg, data)`, `ProtoFiles(genpkg, data)`, `ServerFiles(genpkg, data)`, `ServerTypeFiles(genpkg, data)`, and `ClientTypeFiles(genpkg, data)` | These released signatures remain available. They render the files already recorded by `data` and panic when `genpkg` differs from `data.GenPkg()`. New plugin code should prefer the corresponding linked `Plan` methods. | +| `grpc/codegen` | `ExampleCLIFiles` and `ExampleServerFiles` | Create an `ExamplePlan` with `NewExamplePlan`; call its `CLIFiles` and `ServerFiles` methods. | +| `grpc/codegen` | `EndpointData.ClientMethodName`, `MetadataData.Map`, `MetadataData.MapStringSlice`, and `ValidationData.Name` | All four remain as deprecated copies of final generated data. Valid designs now reject map-shaped gRPC metadata, so `Map` and `MapStringSlice` are false after successful generation. `InitArgData` and `MetadataData` now contain a validation function, so they cannot be compared with `==` or used as map keys. | +| gRPC generation tools | `protoc-gen-go` and `protoc-gen-go-grpc` were discovered by `protoc`; the Makefile installed their latest releases | Install `protoc-gen-go v1.36.12` and `protoc-gen-go-grpc 1.6.2`. Goa resolves those programs before planning and rejects another reported version or an attempt to replace them through `Meta("protoc:cmd")`. The exact pair is the tool contract currently covered by generated-module tests; version text alone is not a general proof that another binary would produce different or compatible declarations. | +| `http/codegen` | `NewServicesData` and `NewJSONRPCServicesData` | Create HTTP plans with `NewPlans` or JSON-RPC HTTP plans with `NewJSONRPCPlans`. Both require the exact `*service.Plan` for the root. | +| `http/codegen` | `ClientFiles`, `ClientEncodeDecodeFile`, `ClientCLIFiles`, `ServerFiles`, `ServerEncodeDecodeFile`, `ServerTypeFiles`, `ClientTypeFiles`, `PathFiles`, and `WebsocketClientFile` | These released signatures remain available. They return files already built by the retained plan and reject a generated package argument that differs from the supplied `ServicesData`. New plugin code should prefer the linked HTTP plan methods. | +| `http/codegen` | `ExampleCLI`, `ExampleCLIFiles`, `ExampleServer`, and `ExampleServerFiles` | Create an HTTP `ExamplePlan` and call its `CLIFiles`, `ServerFiles`, or `CombinedServerFiles` methods. | +| `http/codegen` | `OpenAPIFiles(root)` | Call `NewOpenAPIPlan(root, exampleGenerator)`, then `Files`. | +| `http/codegen` | `CreateHTTPServices` testing helper | No direct replacement. Tests must construct, freeze, and link service and HTTP plans like production. | +| `http/codegen` | `SSEData.DataFieldTypeRef`; `ServiceData.ServerTypeNames`, `ClientTypeNames`, and `UnionTypes` | `DataFieldTypeRef` remains as a deprecated copy of `SSEData.Data.TypeRef` for an explicitly mapped data field. The three service-wide type lists are removed; read the generated type declarations supplied by the linked HTTP plan. `AttributeData` now contains a validation function, so it cannot be compared with `==` or used as a map key. | +| `jsonrpc/codegen` | `ClientFiles`, `ServerFiles`, and `ExampleServerFiles` | Create and link a JSON-RPC plan; call its `ClientFiles` and `ServerFiles` methods. Use `NewExamplePlan` for example files. | +| `jsonrpc/codegen` | `CreateJSONRPCServices` testing helper | Use `CreateJSONRPCPlan` when a test needs the linked production plan. | +| `jsonrpc` | Positional literals for `RawRequest` | Use named fields. `RawRequest` now records whether JSON-RPC request validation failed so the server can return Invalid Request instead of treating the value as a notification. Existing named-field literals continue to compile. | +| `jsonrpc` | WebSocket `StreamConfig`, `StreamConfigOption`, `StreamErrorType`, `StreamErrorHandler`, `StreamErrorConnection`, `StreamErrorProtocol`, `StreamErrorParsing`, `StreamErrorOrphaned`, `StreamErrorTimeout`, `StreamErrorNotification`, `NewStreamConfig`, `WithRequestTimeout`, `WithConnectionTimeout`, `WithCloseTimeout`, `WithResultChannelBuffer`, `WithWebSocketBuffers`, `WithRetryConfig`, `WithCompression`, `WithPingInterval`, `WithErrorHandler`, and `(*StreamConfig).Validate` | No replacement. JSON-RPC WebSocket generation was removed. JSON-RPC supports unary HTTP calls and server streams through explicit server-sent events. | +| `codegen/service` | `UnionTypeData.Declaration` | Use `TypeDeclaration` for the generated value type and `KindDeclaration` for the type that records the selected branch. Each `UnionFieldData` now has `KindDeclaration` and `ConstructorDeclaration` for its generated constant and constructor. The old `Name`, `KindName`, `KindConst`, and `Constructor` strings remain final snapshots for existing plugin templates. | +| `http/codegen/openapi` | Process-global `Definitions`; `APISchema`, `GenerateServiceDefinition`, `ResultTypeRef`, `ResultTypeRefWithPrefix`, `TypeRef`, `TypeRefWithPrefix`, `GenerateResultTypeDefinition`, `GenerateTypeDefinition`, `GenerateTypeDefinitionWithName`, `TypeSchema`, `TypeSchemaWithPrefix`, `AttributeTypeSchema`, and `AttributeTypeSchemaWithPrefix` | The global definition cache and its mutating helpers have no replacement. For OpenAPI 2 attribute schemas, use `v2.BuildAttributeSchema(api, attribute, exampleGenerator)`; otherwise build a complete v2 or v3 document so definitions remain local to that build. | +| `http/codegen/openapi/v2` | `NewV2(root, host)` and `Files(root, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewV2WithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `http/codegen/openapi/v3` | `New(root, version)` and `Files(root, version, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewWithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `codegen`, `codegen/cli`, `codegen/example`, `codegen/service`, `expr`, `grpc/codegen`, and `http/codegen` | Positional literals for changed render-data structs | Use named fields or, preferably, the owning plan constructor. The exact affected types are listed below. Some now contain private state and cannot be fully constructed outside their package. | + +The following exported structs gained or replaced fields, so an unkeyed literal +that compiled with Goa v3 must be changed to named fields: + +- `codegen`: `AttributeContext`, `TransformAttrs`, `TransformFunctionData`, + `WrapDirective`. +- `codegen/cli`: `BuildFunctionData`, `CommandData`, `FlagArgData`, `FlagData`, + `InterceptorData`, `SubcommandData`. +- `codegen/example`: `Data`, `HandlerArg`. +- `codegen/service`: `EndpointMethodData`, `EndpointsData`, `ErrorInitData`, + `InitData`, `InterceptorData`, `MethodData`, `MethodInterceptorData`, + `ProjectedTypeData`, `ServicesData`, `StreamInterceptorData`, `UnionFieldData`, + `UnionTypeData`, `UserTypeData`, `ValidateData`, `ViewData`, and + `ViewedResultTypeData`. `UnionTypeData` replaces `Declaration` with + `TypeDeclaration` and `KindDeclaration`; `UnionFieldData` adds + `KindDeclaration` and `ConstructorDeclaration`. +- `expr`: `APIExpr`, `ResultTypeExpr`, `SchemeExpr`, `ServiceExpr`, and + `UserTypeExpr`. +- `grpc/codegen`: `EndpointData`, `InitArgData`, `InitData`, + `LegacyDecodeData`, `MetadataData`, `RequestData`, `ResponseData`, + `ServiceData`, `ServicesData`, `StreamData`, and `ValidationData`. +- `http/codegen`: `AttributeData`, `CookieData`, `Element`, `EndpointData`, + `FileServerData`, `HeaderData`, `InitArgData`, `InitData`, `JSONRPCBodyData`, + `MultipartData`, `ParamData`, `PayloadData`, `ResponseData`, `ServiceData`, + `ServicesData`, `SSEData`, `TypeData`, and `WebSocketData`. + +`TransformAttrs`, `codegen/cli.FlagArgData`, `service.ValidateData`, +`grpc/codegen.InitArgData`, `grpc/codegen.MetadataData`, +`grpc/codegen.StreamData`, and `http/codegen.AttributeData` now contain maps, +slices, or functions. They can no longer be compared with `==` or used as map +keys. + +Several exported template-data structures now carry `*codegen.NameDeclaration` +records so every use reads the exact name chosen during planning. Goa keeps +public name fields when they can be copied from one real declaration after +names are final. This includes HTTP names, service constructors and validators, +CLI payload builders, and gRPC client methods and validators. For HTTP data, +plugins may edit ordinary render values and remove or reorder entries. +Goa's templates read the declaration field for each generated name rather than +its released string copy. A planning plugin may create a simple template value +for its own declaration, such as an HTTP constructor or gRPC constructor or +validator. The returned file must be in the generated package that reserved +that declaration. + +The preserved HTTP name fields are `ServiceData.ServerStruct`, +`MountPointStruct`, `ServerInit`, `MountServer`, and `ClientStruct`; +`EndpointData.MountHandler`, `HandlerInit`, `RequestDecoder`, +`ResponseEncoder`, `ErrorEncoder`, `ClientStruct`, `RequestEncoder`, +`ResponseDecoder`, and `BuildStreamPayload`; `MultipartData.FuncName` and +`InitName`; `SSEData.StructName`; `FileServerData.MountHandler`; +`WebSocketData.VarName`; `InitData.Name`; and `TypeData.VarName`, +`ValidatorName`, and `NestedValidatorName`. Existing templates that read these +fields continue to work. Copied JSON-RPC service data also keeps +`ServerStruct`, `ServerInit`, `MountServer`, and `ClientStruct`. Copied +JSON-RPC endpoint data keeps `HandlerInit`, `ClientStruct`, `RequestEncoder`, +`RequestDecoder`, and `ResponseDecoder`. Each string contains the final name +from its declaration. A plugin that creates a new package-level declaration +must use the factory API and declare it during `Plugin.Plan`. +Typed service, endpoint, and file values copied from Goa keep the wrappers and +extra mounts saved with them. Plugins may still remove the files or sections +that render those values. +Plugins should use their own template values for plugin-owned types rather than +constructing Goa transport data. + +HTTP package definitions and uses read the same planned declarations. This +includes WebSocket stream types, request builders and conversion functions, +body types, and their public and nested validators. The released `VarName`, +`Name`, `ValidatorName`, and `NestedValidatorName` strings mirror those +declarations, but Goa's templates do not use those copies as declaration +identity. A primitive or inline composite Go type such as `string` or +`[]string` has no package declaration; +the plan records that complete type expression instead. JSON-RPC receives a +copy of the same body declaration when one exists. Request builders and body +conversion functions are declared before names freeze, including constructors +for inline request bodies, so another generator claiming the preferred name +changes both the generated definition and every call. + +HTTP client command sections also keep the released `MultipartFuncName` and +`BuildStreamPayload` strings. They copy the matching declarations after names +are final, while Goa's templates read the declaration records directly. + +gRPC preserves the same name snapshots. `ServiceData.ServerStruct`, +`ClientStruct`, `ServerInit`, and `ClientInit`; `EndpointData.ServerStruct`, +`ClientStruct`, `ClientBuild`, `ClientEncode`, `ClientDecode`, `ServerHandler`, +`ServerDecode`, and `ServerEncode`; `StreamData.VarName`; and +`LegacyDecodeData.FuncName` each contain a final planned name. Goa's own gRPC +templates read the declaration saved for each role. Plugin templates may +continue to read the public snapshots, and plugin-owned source remains the +plugin's responsibility. + +Other affected data includes `service.EndpointsData`, `EndpointMethodData`, +`ErrorInitData`, view and interceptor data; gRPC service, method, request, +response, and transform data; and CLI parser and payload-builder data. Call +`Name()` only after the generation is frozen. Existing unkeyed literals for +changed exported data must become keyed literals or, preferably, be replaced +with the owning plan constructor. `NameScope.Unique` and a previously unseen +`HashedUnique` call now panic after that scope freezes. Use `NameScope.Fork` +only for private render-local helpers; package declarations must be collected +through their `GeneratedPackage`. + +### Protobuf tools + +Every gRPC generation run now requires these exact programs on `PATH`: + +```text +protoc-gen-go v1.36.12 +protoc-gen-go-grpc 1.6.2 +``` + +Install them with: + +```sh +go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 +``` -Generated Go names may change where prior suffix ownership depended on -traversal or reconstruction. Goa, goa-ai, and applications must regenerate -together. There is no runtime fallback, persisted-data migration, or staged -dual mode. A normalized output-path collision now fails during planning rather -than overwriting or combining unrelated packages. +Goa resolves each program to an absolute path and verifies its `--version` +output before planning protobuf names. `Meta("protoc:cmd", ...)` may still +select the protobuf compiler or add compiler arguments, but it may not replace +either required Go plugin with `--plugin`. A missing program, another version, +or a plugin override now stops generation before files are written. + +### Regenerated application source + +Regenerate all Goa-owned files together. Do not copy a subset of a new `gen` +tree over an old one: generated declarations and their callers use the same +frozen name records and are not designed to compile across generations. +`goa example` deliberately keeps existing starter files, so separately update +handwritten starter code and any application code that imports generated +transport packages. + +Generated command starters now run an endpoint and write its result instead of +returning `(goa.Endpoint, any, error)` to their caller. The private `doHTTP`, +`doGRPC`, and `doJSONRPC` functions accept a context and output writer, print +unary or streamed values, and return endpoint, stream receive, output, and +connection-close errors. Regenerate or update the whole command directory +together; an existing kept `main.go` cannot call a newly generated private +transport function with the old signature. HTTP and gRPC input-only or +bidirectional stream commands now return a clear unsupported-input error before +parsing an endpoint or opening a connection. + +An HTTP method that defines both an ordinary `Result` and a `StreamingResult` +uses the ordinary result for a normal HTTP response and the stream for its SSE +response. A regenerated example service now returns that ordinary result from +its method and uses the default result view when the service owns view +selection. Existing kept service starters for such a method must be updated; +regenerating `gen` alone does not rewrite them. + +Most generated declarations that have one clear released name keep that name, +including HTTP request and response types, validators, body constructors, +handler constructors, and mount functions. Names still change when preserving +the old spelling would preserve a false conflict or require two declarations +for one generated operation. A service method named `FooEndpoint` becomes +`Foo` when `Foo` no longer exists in that package. When two gRPC methods perform +the same conversion, their two method-based constructors become one constructor +named from the source and target types. Real name collisions receive stable +numeric suffixes instead of suffixes determined by discovery order. These are +Go source breaks for handwritten code that names the affected declarations. +They do not change the wire format by themselves. + +A relocated service type written to a package selected with `struct:pkg:path` +now uses the lowercase final path segment as its Go package clause. For +example, a path ending in `APIKeyService` now declares `package +apikeyservice`. The older mixed-case spelling could differ from the package +name used by the generated import. Handwritten imports that rely on the old +implicit qualifier must use the new lowercase name or add an explicit import +alias. Generated imports already carry the planned qualifier, and the file +path and exported type names do not change. + +Union declarations requested in another generated package now live in that +package's `unions.go` file. Their Go import path and declaration names remain +the same unless a real package collision requires a suffix. Plugins and scripts +that select generated files by filename must stop assuming a union is written +beside the service that first used it. + +Generated interceptor implementations must also change their method +signatures. An argument such as `*LoggingInfo` is now the read-only +`LoggingInfo` interface. Goa supplies a private implementation for the exact +service method and for an endpoint call, stream send, or stream receive. +Handwritten interceptors should accept the interface and continue calling its +accessor methods. These known values are no longer stored in a public struct at +runtime. + +A handwritten multipart request decoder now fills the generated HTTP request +body instead of the service payload. For example, +`func(*multipart.Reader, **service.UploadPayload) error` becomes +`func(*multipart.Reader, *UploadRequestBody) error`. Goa validates that body +and then builds the service payload, just as it does for JSON requests. Array +and map bodies use pointers to the complete generated body value. Regenerate +first, then update each handwritten decoder to its new generated parameter +type. The multipart bytes on the network do not change. + +Some exported helpers disappear when Goa can prove that they do no work. For +example, Goa no longer writes an empty `ValidateUserTypeView` function. Code +that called an empty generated validator must remove that call. JSON-RPC +server-stream methods no longer receive a unary `DecodeResponse` +function because their generated endpoint returns a client stream and never +calls that decoder. Generated gRPC conversion constructors may also be renamed +or combined when several methods perform the same conversion. Handwritten code +should normally enter through the generated client, server, or endpoint +constructors instead of calling transport conversion helpers directly. + +Generated gRPC response encoders with mapped metadata now read the actual +result variable and use code specialized for the metadata type. Some older +combinations generated references to nonexistent `res` or `p` variables and +did not compile. Scalar text remains equivalent, but bytes now use their string +contents instead of Go's slice display: `[]byte{65, 66}` changes from +`"[65 66]"` to `"AB"`. Floating-point values use the exact width selected by +the design. A metadata consumer that compared the old text must update. For a +fixed-view result, generated gRPC encoders and decoders use the view selected in +the design instead of trusting a runtime `goa-view` metadata value. Valid peers +already send the designed view and keep the same result body. + +For caller-selected gRPC result views, Goa now generates one protobuf +conversion for each view. The server writes only the fields in the selected +view, and the client uses the matching constructor instead of applying the +default-view constructor to every response. A dynamic gRPC server stream sends +the selected view in its initial `goa-view` metadata before its first message; +the generated client reads that value before decoding the first message. The +protobuf schema does not change. Regenerate both sides of a dynamic viewed +stream together because an older server does not send this metadata and an +older client always decodes the default view. Regenerate both sides of any +viewed method whose selected view omits default-view fields: older generated +conversions can read an omitted field and fail. Fixed-view methods otherwise +keep their existing view choice. + +One generated command-line flag changes only for a design that used `domain` +for both a server variable and a URL variable. The URL variable is now +`-url-domain`; the old starter registered `-domain` twice and did not run. + +Generated examples also change because each example now uses the exact design +declaration that owns it rather than a value consumed earlier in a shared +random stream. Design-authored example inputs are unchanged, but generated +OpenAPI documents, CLI examples, array lengths, and decoding-error examples may +change because Goa now uses the authored value for that declaration. + +OpenAPI 3 documents may place generated examples under `examples.default` +instead of the single `example` field. OpenAPI 3.2 server-sent-event schemas +mark the event data field as required only when the selected stream field is +required. Snapshot consumers should regenerate and review the documents; the +service's accepted requests and returned results do not change from this +documentation-only difference. + +OpenAPI server variables now include the descriptions written in the design. +Reusable viewed-result schemas use the designed result type name in their +description instead of the generated HTTP response-body type name. These are +documentation text changes only. + +When examples are disabled, OpenAPI generation now omits examples written in +the design as well as examples computed by Goa. Security definitions used only +by excluded services or methods are also omitted. Each server variable now +uses only its own allowed values; an older document could accidentally copy +allowed values from the preceding variable. These changes affect generated +documents, not the service wire format. + +When several methods use one error type, the shared OpenAPI schema now keeps +the reusable type's description. Each OpenAPI 3 operation keeps the description +written for that method's error response. This changes generated API +documentation only; it does not change response data or status codes. + +A viewed-result constructor now returns a nonnil value carrying an unknown +requested view. Generated boundary validation can therefore report the precise +invalid view instead of receiving nil or panicking first. Valid view values and +their projected bodies are unchanged. + +Generated gRPC starters now print the designed method names directly instead of +asking the running gRPC server which methods it registered. Goa-designed +methods keep correct startup logs. A plugin that registers additional gRPC +methods at runtime must print its own log lines; those methods are not part of +Goa's generated service plan. + +A normalized output-path collision, conflicting same-file package or import, +conflicting keep-existing-file setting, or path that is absolute, escapes the +generation root, contains a backslash, or differs only by filesystem case now +fails during planning. Previously, one file could overwrite another or the +merge could produce invalid Go. Same-path contributions now keep every body +section even when two sections have the same diagnostic label, and every file +finalizer runs in contributor order. A plugin that relied on a duplicate label +to suppress output or on only the first finalizer running must remove that +assumption. + +### Wire and runtime compatibility + +| Change | Mixed old and new programs | Rollback effect | +| --- | --- | --- | +| Required Goa `OneOf` validation | Generated service validators and HTTP and gRPC boundaries now reject a union with no selected branch. They also reject a selected message, bytes, or `Any` wrapper whose branch value is nil. The protobuf encoding is unchanged. Valid branches, including a selected empty message, work across versions. | Rolling back re-allows invalid union values; it requires no data migration. | +| `ArrayOfRequired` for primitive values and primitive aliases in JSON bodies | Valid JSON is unchanged. Incoming JSON representations use pointer elements: server request bodies and client response bodies use values such as `[]*string`, then convert them to service value slices such as `[]string`. Outgoing client request bodies and server response bodies remain value slices. Incoming `[null]` is now rejected. Handwritten code that constructs incoming transport body values must supply pointers. gRPC repeated scalar fields and service arrays remain value slices. | Rolling back accepts `null` again; it requires no data migration. | +| Exclusive maximum validation | Generated primitive validators now reject values equal to or above an exclusive maximum. Older validators accidentally repeated the exclusive minimum check and could accept those values. | Rolling back accepts values that violate the designed maximum; it requires no data migration. | +| Caller-selected JSON-RPC result views | A successful response is `{ "jsonrpc": "2.0", "id": ..., "result": { "view": "detailed", "body": ... } }`. The envelope is the method value inside JSON-RPC's standard `result` member. It is generated only when the caller chooses among views. The `body` member is omitted when every selected field is carried in HTTP headers or cookies. The old result was the projected body alone; unary HTTP responses carried the view in the `goa-view` header, while stream messages had no reliable place for it. An old client and new server, or a new client and old server, are not compatible for these methods. Results without views and results whose view is fixed in the design keep their old body shape. The envelope is used consistently for unary and server-sent-event results. A configured response decoder now receives an HTTP response with status 200 instead of the previous zero status. | Deploy or roll back every client and server for a caller-selected view together. There is no dual decoder. A custom response decoder that inspected the synthetic status must accept 200. This generic Goa envelope is valid JSON-RPC, but a method that belongs to another protocol layered on JSON-RPC must still use that protocol's required result schema. | +| HTTP server-sent events | Event-write and flush failures are now returned instead of ignored, and clients decode retry values into the exact optional integer type. Primitive and primitive-alias data use raw SSE text; an optional nil primitive omits the data line, while a present empty string writes an empty data line. The old optional-pointer server accidentally wrote JSON strings or `null`, so a new client reads the JSON quotes or `null` as part of the raw value; a new server remains readable by the old raw-text client. OpenAPI 3.2 now marks optional mapped data as optional and describes primitive data as raw text rather than JSON. For viewed results, the server writes one `goa-view` header, chooses the default when empty, rejects an unknown view before writing, and rejects changing the view after the first event. | Regenerate both sides for a stream with optional primitive data. Regenerate an OpenAPI 3.2 document if tooling relies on the event item schema. Other non-viewed event data keeps its shape, but code may now observe write errors and retry values. Do not mix versions for a variable-view stream. Invalid or partly written streams now fail instead of being accepted or followed by a second HTTP error response. | +| Viewed HTTP streams | HTTP SSE and WebSocket servers now reject an unknown requested view before encoding an event instead of writing a nil or `null` body. Valid fixed and selected views keep their existing body shape. | A new server can reject an invalid view that an old server accepted. No valid request needs a coordinated rollout. | +| JSON-RPC server-sent-event lifecycle | Each server `Send` accepts the streaming result directly and writes one JSON-RPC notification. When the service method returns, the transport writes one terminal response for a request with an ID: `result: null` for success or a JSON-RPC error for a returned error. A request without an ID receives no terminal response. Client `Recv(ctx)` becomes `Recv()` plus `RecvWithContext(ctx)`. Stream constructors return an interface that also implements the service client stream. After notifications, the client returns `io.EOF` or the terminal error. The client now rejects an unknown server-sent-event name or a notification for another JSON-RPC method instead of silently skipping it. Body read and close failures are returned instead of discarded. Unary request reads and batch response delimiter writes now report failures through the server error handler. An unknown JSON-RPC error code now includes the received `error.data` in its invalid-response error. | Generated service implementations must use the standard typed `Send`, `SendWithContext`, and `Close` methods. Client callers must use the new receive methods or service stream interface. The old JSON-RPC-only `StreamEvent`, `SendAndClose`, `SendError`, request ID, marker event, concrete client stream, and WebSocket service APIs are removed. Regenerate clients and servers together. Custom peers must stop sending unknown event names or notifications for another method. Valid Goa peers already satisfy this rule. | +| JSON-RPC request and response rules | A structurally valid request without `id` remains a notification and receives no response. An invalid request object receives Invalid Request with `id: null`, including over server-sent events. A present empty-string or null `id` receives a response with that exact value. A success with no value includes `"result": null`. Leading JSON whitespace no longer changes an array into a single request, `[]` returns one Invalid Request response, and valid and invalid batch members are handled independently. A batch cannot start a server stream: a streaming call with an ID receives Method Not Found with “Method is not available in a batch request,” and its notification form is ignored. In a server that has both ordinary and streaming methods, no `Accept` header or `*/*` permits each method's designed response format. A unary method requires an acceptable JSON media type, a streaming method requires an acceptable server-sent-event media type, and an unacceptable format returns HTTP 406. Media type spelling is case-insensitive and `q=0` rejects that format. Invalid method arguments map to Invalid Params; an undeclared service failure maps to Internal Error. | Valid requests with ordinary IDs keep the same response. Send every streaming call separately as a server-sent-event request instead of including it in a batch. Clients that incorrectly treated an empty or null ID as a notification will now receive a response. Clients that expected no error for an invalid object, one Parse Error for a mixed batch, an omitted `result`, an empty body for `[]`, or selection of a format the method cannot return must follow JSON-RPC 2.0 and HTTP Accept rules. A custom caller of `RawRequest.UnmarshalJSON` must inspect `Invalid`: structurally invalid JSON-RPC objects and IDs other than strings, numbers, or null now set that field without returning a Go JSON decoding error. No data migration is needed. | +| HTTP and JSON-RPC response bodies | Generated clients close response bodies they fully consume and return read and close failures as decoding errors. When decoding and closing both fail, the returned error preserves both. A successful method that deliberately returns the raw body leaves it open for the caller. | Successful decoded responses are unchanged. Failure handling may now return a nonnil error, or `decoding_error` instead of a raw or request error, where older code discarded or mislabeled a read or close failure. | +| Nested HTTP validation paths | Generated validation errors now keep the complete field and array-index path while entering nested generated types instead of restarting the path at the nested value. | Invalid inputs may produce more precise error field names. Valid inputs and wire data are unchanged. | +| HTTP float query text | Generated clients now use Go's shortest round-trip text for float32 and float64 query values. Ordinary values are unchanged, while very large or small values may use exponent form, such as `1e+100`, instead of a long decimal expansion. Servers decode the same number. | Systems that sign, cache, or compare the exact URL text must accept the compact form. Rolling back returns to the longer spelling. | +| gRPC metadata text | Generated metadata conversions are specialized for the designed type. Bytes now use their string contents, so `[]byte{65, 66}` is sent as `"AB"` instead of `"[65 66]"`. Floating-point values use the designed width. Other scalar text remains equivalent. | Regenerate both sides when a metadata consumer parses the old byte-slice display or depends on the old floating-point spelling. Rolling back restores the old text. | +| gRPC result views | Unary and streaming clients and servers now use the protobuf conversion for the selected view. Dynamic server streams send the view in initial `goa-view` metadata, and dynamic clients require that metadata before decoding the first message. The protobuf schema is unchanged. An old stream server does not send this value, while an old stream client assumes the default view. Older conversions can also fail when a selected view omits a field used by the default conversion. | Regenerate and deploy both sides of a dynamic viewed gRPC stream together. Also regenerate both sides of any viewed method whose selected view omits default-view fields. Fixed-view methods otherwise keep their wire choice. | +| Protobuf name collisions | Normal schemas keep their existing field encoding. A design whose protobuf declarations collide may receive stable numeric message, file, or generated Go suffixes instead of invalid source. Descriptor names, and a gRPC method path if its service or method itself needed a suffix, can therefore change for that formerly conflicting design. | Regenerate both sides from the same Goa version. A previously valid, collision-free schema needs no coordinated runtime rollout. | +| Stricter design validation | Generation now rejects duplicate `ConvertTo` or `CreateFrom` mappings for the same Goa and external type, non-primitive gRPC metadata, streaming HTTP success fields mapped to headers or cookies, and inherited HTTP or gRPC error mappings whose concrete method error has a different type, validation, default, or metadata. Error metadata includes whether the error is temporary, a timeout, or a server fault. A service and its methods also cannot reuse one standard error name when those settings or value definitions differ, because Goa emits one shared `Make` constructor. Authored custom error types do not use that constructor and remain independent. gRPC and JSON-RPC reject a method that defines both `Result` and `StreamingResult`, even when both use the same Goa type. An ordinary HTTP method with both results must use `ServerSentEvents()`; the old same-type case could accidentally select WebSocket generation. JSON-RPC also rejects client and bidirectional streams and a server stream that does not call `ServerSentEvents()`. | These checks stop generation; they do not change a compiled program. Fix the design rather than rolling out mixed generated trees. | Fresh factories make repeated and concurrent generation independent. The main operational risks are an uncollected template symbol, an incomplete emitted diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index 8dd4648649..ed94c1ac26 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -28,6 +28,9 @@ type ( // VarName is the name of the command variable e.g. // "cellarStorage" VarName string + // FlagSetVar is the exact local variable that stores this command's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Subcommands is the list of endpoint commands. @@ -51,6 +54,9 @@ type ( Name string // FullName is the sub-command full name e.g. "storageAdd" FullName string + // FlagSetVar is the exact local variable that stores this method's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Flags is the list of flags supported by the subcommand. @@ -60,6 +66,8 @@ type ( // BuildFunction contains the data to generate a payload builder function // if any. Exclusive with Conversion. BuildFunction *BuildFunctionData + // ActualPointerVars lists the exact parser variables passed to BuildFunction. + ActualPointerVars []string // Conversion contains the flag value to payload conversion function if // any. Exclusive with BuildFunction. Conversion string @@ -67,14 +75,23 @@ type ( Example string // Interceptors contains the data for client interceptors if any apply to the endpoint method. Interceptors *InterceptorData + // conversionFlag is the parsed flag converted directly into a primitive payload. + conversionFlag *FlagData } // InterceptorData contains the data needed to generate interceptor code. InterceptorData struct { // VarName is the name of the interceptor variable. VarName string + // ParserVar is the exact parameter name used by the generated endpoint parser. + ParserVar string // PkgName is the package name containing the interceptor type. PkgName string + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact wrapper applied to one + // client endpoint. It is nil for service-level command data. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration } // FlagData contains the data needed to render a command-line flag. @@ -87,6 +104,8 @@ type ( Type string // FullName is the flag full name e.g. "storageAddVintage" FullName string + // PointerVar is the exact local variable that points to the parsed flag value. + PointerVar string // Description is the flag help text. Description string // Required is true if the flag is required. @@ -95,14 +114,16 @@ type ( Example string // Default returns the default value if any. Default any + // value describes how the flag text becomes a Go value. + value *flagValuePlan } // BuildFunctionData contains the data needed to generate a constructor // function that builds a service method payload type from the command-line // flags. BuildFunctionData struct { - // Declaration is the package name used by the function definition and calls. - Declaration *codegen.NameDeclaration + // Name is the build payload function name. + Name string // Description describes the payload function. Description string // ActualParams is the list of passed build function parameters. @@ -146,6 +167,9 @@ type ( Name string // TypeName is the argument Go type name. TypeName string + // Plan contains the conversion and validation selected by Goa's transport + // generators. Plugins may continue to use TypeName and Validate. + Plan *FlagPlan // TypeRef is the reference to the argument type. TypeRef string // FieldName is the name of the payload field initialized with the @@ -159,13 +183,32 @@ type ( Example any // DefaultValue is the default value of the argument if any. DefaultValue any - // Validate contains the validation code for the argument value if any. + // Validate contains validation code kept for plugins built against the + // released CLI data. It cannot be used with Plan. + // + // Deprecated: Goa transport generators use Plan so checks receive the + // exact parsed value name. Plugins may continue to use Validate. Validate string // OmitField if true generates the flag without a corresponding payload // builder field. OmitField bool } + // FlagPlan contains the conversion and validation choices made by Goa's + // transport generators. + FlagPlan struct { + value *flagValuePlan + validation func(string) string + } + + // flagValuePlan records how command-line text becomes one generated Go value. + flagValuePlan struct { + kind expr.Kind + typeName string + typeRef string + alias bool + } + // FieldData contains the data needed to generate the code that initializes a // field in the method payload type. FieldData struct { @@ -201,12 +244,33 @@ type ( // Args is the list of arguments for the constructor. Args []*codegen.InitArgData } + + // conversionData describes the Go value produced from command-line flag text. + conversionData struct { + code string + value string + declaresError bool + canError bool + } + + // conversionVariableNames contains local names used while parsing one flag. + conversionVariableNames struct { + error string + parsed string + converted string + } + + // parserFlagsData gives the shared flag template its commands and the fixed + // local names chosen for the surrounding endpoint parser. + parserFlagsData struct { + Commands []*CommandData + Variables *ParserVariablesData + } ) // BuildCommandData builds the data needed by CLI code generators to render the -// parsing of the service command. clientPkgName is the frozen qualifier for -// the generated transport client package. -func BuildCommandData(data *service.Data, clientPkgName string) *CommandData { +// parsing of the service command. +func BuildCommandData(data *service.Data) *CommandData { description := data.Description if description == "" { description = fmt.Sprintf("Make requests to the %q service", data.Name) @@ -215,17 +279,18 @@ func BuildCommandData(data *service.Data, clientPkgName string) *CommandData { var interceptors *InterceptorData if len(data.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, } } return &CommandData{ ServiceName: data.Name, - Name: codegen.KebabCase(data.Name), + Name: codegen.KebabCase(data.PathName), VarName: codegen.Goify(data.Name, false), Description: description, - PkgName: clientPkgName, + PkgName: data.PkgName + "c", Interceptors: interceptors, } } @@ -241,54 +306,30 @@ func BuildSubcommandData(data *service.Data, m *service.MethodData, buildFunctio description = fmt.Sprintf("Make request to the %q endpoint", m.Name) } - var conversion string + var conversionFlag *FlagData if m.Payload != "" && buildFunction == nil && len(flags) > 0 { - // No build function, just convert the arg to the body type - var convPre, convSuff string - target := "data" - if flagType(m.Payload) == "JSON" { - target = "val" - convPre = fmt.Sprintf("var val %s\n", m.Payload) - convSuff = "\ndata = val" - } - conv, _, check := conversionCode( - "*"+flags[0].FullName+"Flag", - target, - m.Payload, - false, - ) - conversion = convPre + conv + convSuff - if check { - conversion = "var err error\n" + conversion - conversion += "\nif err != nil {\n" - if flagType(m.Payload) == "JSON" { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - flags[0].FullName+"Flag", flags[0].Example) - } else { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, - flags[0].FullName+"Flag", flags[0].Type) - } - conversion += "\n}" - } + conversionFlag = flags[0] } var interceptors *InterceptorData if len(m.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, + ClientEndpointWrapperDeclaration: m.ClientEndpointWrapperDeclaration, } } sub := &SubcommandData{ - MethodName: m.Name, - Name: name, - FullName: fullName, - Description: description, - Flags: flags, - MethodVarName: m.VarName, - BuildFunction: buildFunction, - Conversion: conversion, - Interceptors: interceptors, + MethodName: m.Name, + Name: name, + FullName: fullName, + Description: description, + Flags: flags, + MethodVarName: m.VarName, + BuildFunction: buildFunction, + conversionFlag: conversionFlag, + Interceptors: interceptors, } generateExample(sub, data.Name) @@ -302,14 +343,44 @@ func EndpointParserFile( path, title string, specs []*codegen.ImportSpec, data []*CommandData, - declarations *ParserDeclarations, parseSection *codegen.SectionTemplate, +) *codegen.File { + return endpointParserFile(path, title, specs, data, parseSection, releasedUsageCommandsName, releasedUsageExamplesName) +} + +// EndpointParserFile returns a parser file that uses the function names chosen +// for this parser plan. +func (p *ParserPlan) EndpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, +) *codegen.File { + return endpointParserFile( + path, + title, + specs, + data, + parseSection, + p.Declarations.UsageCommands.Name, + p.Declarations.UsageExamples.Name, + ) +} + +// endpointParserFile assembles one parser file with the supplied help function +// names. +func endpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, + usageCommandsName, usageExamplesName func() string, ) *codegen.File { sections := make([]*codegen.SectionTemplate, 0, 4+len(data)) sections = append(sections, codegen.Header(title, "cli", specs), - UsageCommands(data, declarations.UsageCommands), - UsageExamples(data, declarations.UsageExamples), + usageCommands(data, usageCommandsName), + usageExamples(data, usageExamplesName), parseSection, ) for _, cmd := range data { @@ -338,20 +409,33 @@ func MakeFlags( check bool ) for i, arg := range args { - f := NewFlagData(svcn, m.Name, arg.Name, arg.TypeName, arg.Description, arg.Required, arg.Example, arg.DefaultValue) + value := (*flagValuePlan)(nil) + validation := func(string) string { + return arg.Validate + } + if arg.Plan == nil { + value = legacyFlagValuePlan(arg.TypeName) + } else { + if arg.Validate != "" { + panic("CLI flag validation cannot use both Validate and Plan") + } + value = arg.Plan.value + validation = arg.Plan.validation + } + f := newFlagData(svcn, m.Name, arg.Name, value, arg.Description, arg.Required, arg.Example, arg.DefaultValue) flags[i] = f params[i] = f.FullName if arg.OmitField { continue } - code, chek := FieldLoadCode(f, arg.Name, arg.TypeName, arg.Validate, arg.DefaultValue, payload, payloadRef) + code, chek := fieldLoadCode(f, arg.Name, value, validation, arg.DefaultValue, payload, payloadRef) check = check || chek tn := arg.TypeRef - if f.Type == "JSON" { + if value.isJSON() { // We need to declare the variable without // a pointer to be able to unmarshal the JSON // using its address. - tn = arg.TypeName + tn = value.typeName } fdata = append(fdata, &FieldData{ Name: arg.Name, @@ -362,6 +446,7 @@ func MakeFlags( } return flags, &BuildFunctionData{ + Name: "Build" + m.VarName + "Payload", ActualParams: params, FormalParams: params, ServiceName: svcn, @@ -389,7 +474,13 @@ func PayloadBuildersFile(path, title string, specs []*codegen.ImportSpec, data * // UsageCommands builds a section template that generates a help text showing // the list of allowed commands and sub-commands. -func UsageCommands(data []*CommandData, declaration *codegen.NameDeclaration) *codegen.SectionTemplate { +func UsageCommands(data []*CommandData) *codegen.SectionTemplate { + return usageCommands(data, releasedUsageCommandsName) +} + +// usageCommands renders command help with the function name chosen for its +// generated package. +func usageCommands(data []*CommandData, name func() string) *codegen.SectionTemplate { usages := make([]string, len(data)) for i, cmd := range data { subs := make([]string, len(cmd.Subcommands)) @@ -406,16 +497,22 @@ func UsageCommands(data []*CommandData, declaration *codegen.NameDeclaration) *c return &codegen.SectionTemplate{ Source: cliTemplates.Read(usageCommandsT), - Data: struct { - Declaration *codegen.NameDeclaration - Usages []string - }{declaration, usages}, + Data: usages, + FuncMap: map[string]any{ + "usageName": name, + }, } } // UsageExamples builds a section template that generates a help text showing // a valid invocation of the CLI tool. -func UsageExamples(data []*CommandData, declaration *codegen.NameDeclaration) *codegen.SectionTemplate { +func UsageExamples(data []*CommandData) *codegen.SectionTemplate { + return usageExamples(data, releasedUsageExamplesName) +} + +// usageExamples renders example help with the function name chosen for its +// generated package. +func usageExamples(data []*CommandData, name func() string) *codegen.SectionTemplate { var examples []string for i, cmd := range data { if i < 5 { @@ -425,13 +522,25 @@ func UsageExamples(data []*CommandData, declaration *codegen.NameDeclaration) *c return &codegen.SectionTemplate{ Source: cliTemplates.Read(usageExamplesT), - Data: struct { - Declaration *codegen.NameDeclaration - Examples []string - }{declaration, examples}, + Data: examples, + FuncMap: map[string]any{ + "usageName": name, + }, } } +// releasedUsageCommandsName returns the help function name used by the +// released parser helper. +func releasedUsageCommandsName() string { + return "UsageCommands" +} + +// releasedUsageExamplesName returns the example function name used by the +// released parser helper. +func releasedUsageExamplesName() string { + return "UsageExamples" +} + // FlagsCode returns a string containing the code that parses the command-line // flags to infer the command (service), sub-command (method), and the // arguments (method payload) invoked by the tool. It panics if any error @@ -452,6 +561,28 @@ func FlagsCode(data []*CommandData) string { return flagsCode.String() } +// FlagsCode renders flag parsing with the exact local names chosen by this +// parser plan. +func (p *ParserPlan) FlagsCode(data []*CommandData) string { + if p.Variables == nil { + panic("CLI parser variables must be planned before rendering flags") + } + section := codegen.SectionTemplate{ + Name: "parse-endpoint-flags", + Source: cliTemplates.Read(parseFlagsPlannedT), + Data: &parserFlagsData{ + Commands: data, + Variables: p.Variables, + }, + FuncMap: map[string]any{"printDescription": printDescription}, + } + var flagsCode bytes.Buffer + if err := section.Write(&flagsCode); err != nil { + panic(err) + } + return flagsCode.String() +} + // CommandUsage builds the section templates that can be used to generate the // endpoint command usage code. func CommandUsage(data *CommandData) *codegen.SectionTemplate { @@ -476,7 +607,35 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem } } -// NewFlagData creates a new FlagData from the given argument attributes. +// NewFlagPlan records the conversion and validation selected for one command- +// line flag. typeName is the concrete local type used for JSON values. typeRef +// is the concrete non-pointer reference used for primitive casts. +func NewFlagPlan(attribute *expr.AttributeExpr, typeName, typeRef string, validation func(string) string) *FlagPlan { + kind := expr.AnyKind + alias := expr.IsAlias(attribute.Type) + if custom, _ := codegen.GetMetaType(attribute); custom == "" && expr.IsPrimitive(attribute.Type) { + dataType := attribute.Type + for { + userType, ok := dataType.(expr.UserType) + if !ok { + break + } + dataType = userType.Attribute().Type + } + kind = dataType.Kind() + } + return &FlagPlan{ + value: &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeRef, + alias: alias, + }, + validation: validation, + } +} + +// NewFlagData creates flag data from the released string type description. // // svcn is the service name // en is the endpoint name @@ -486,104 +645,141 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem // required determines if the flag is required // example is an example value for the flag func NewFlagData(svcn, en, name, typeName, description string, required bool, example, def any) *FlagData { + return newFlagData(svcn, en, name, legacyFlagValuePlan(typeName), description, required, example, def) +} + +// NewFlagDataForPlan creates flag data from the given conversion and +// validation choices. +func NewFlagDataForPlan(svcn, en, name string, plan *FlagPlan, description string, required bool, example, def any) *FlagData { + return newFlagData(svcn, en, name, plan.value, description, required, example, def) +} + +// FieldLoadCode returns the code used in the build payload function that +// initializes one of the payload object fields. It returns the initialization +// code and a boolean indicating whether the code requires an "err" variable. +func FieldLoadCode(f *FlagData, argName, argTypeName, validate string, defaultValue any, payload expr.DataType, payloadRef string) (string, bool) { + var validation func(string) string + if validate != "" { + validation = func(string) string { + return validate + } + } + return fieldLoadCode(f, argName, legacyFlagValuePlan(argTypeName), validation, defaultValue, payload, payloadRef) +} + +// newFlagData creates flag data from the conversion selected during planning. +func newFlagData(svcn, en, name string, value *flagValuePlan, description string, required bool, example, def any) *FlagData { ex := jsonExample(example) fn := goifyTerms(svcn, en, name) return &FlagData{ Name: codegen.KebabCase(name), VarName: codegen.Goify(name, false), - Type: flagType(typeName), + Type: value.flagType(), FullName: fn, Description: description, Required: required, Example: ex, Default: def, + value: value, } } -// FieldLoadCode returns the code used in the build payload function that -// initializes one of the payload object fields. It returns the initialization -// code and a boolean indicating whether the code requires an "err" variable. -func FieldLoadCode(f *FlagData, argName, argTypeName, validate string, defaultValue any, payload expr.DataType, payloadRef string) (string, bool) { +// legacyFlagValuePlan reproduces the released string-based flag conversion. +func legacyFlagValuePlan(typeName string) *flagValuePlan { + kind := expr.AnyKind + switch typeName { + case codegen.GoNativeTypeName(expr.Boolean): + kind = expr.BooleanKind + case codegen.GoNativeTypeName(expr.Int): + kind = expr.IntKind + case codegen.GoNativeTypeName(expr.Int32): + kind = expr.Int32Kind + case codegen.GoNativeTypeName(expr.Int64): + kind = expr.Int64Kind + case codegen.GoNativeTypeName(expr.UInt): + kind = expr.UIntKind + case codegen.GoNativeTypeName(expr.UInt32): + kind = expr.UInt32Kind + case codegen.GoNativeTypeName(expr.UInt64): + kind = expr.UInt64Kind + case codegen.GoNativeTypeName(expr.Float32): + kind = expr.Float32Kind + case codegen.GoNativeTypeName(expr.Float64): + kind = expr.Float64Kind + case codegen.GoNativeTypeName(expr.String): + kind = expr.StringKind + case codegen.GoNativeTypeName(expr.Bytes): + kind = expr.BytesKind + } + return &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeName, + } +} + +// fieldLoadCode writes a field conversion from its complete generation plan. +func fieldLoadCode( + f *FlagData, + argName string, + value *flagValuePlan, + validation func(string) string, + defaultValue any, + payload expr.DataType, + payloadRef string, +) (string, bool) { var ( - code string - declErr bool - startIf string - endIf string + code string + validationTarget string + declErr bool + startIf string + endIf string ) if !f.Required { startIf = fmt.Sprintf("if %s != \"\" {\n", f.FullName) endIf = "\n}" } - if argTypeName == codegen.GoNativeTypeName(expr.String) { - ref := "&" - if f.Required || defaultValue != nil { - ref = "" + pointer := value.kind != expr.BytesKind && !value.isJSON() && !f.Required && defaultValue == nil + conversion := conversionCode(f.FullName, argName, value, pointer, conversionVariableNames{ + error: "err", + parsed: "v", + converted: "val", + }) + code = conversion.code + validationTarget = conversion.value + declErr = conversion.declaresError + if conversion.canError { + code += "\nif err != nil {\n" + nilVal := "nil" + if expr.IsPrimitive(payload) { + code += fmt.Sprintf("var zero %s\n", payloadRef) + nilVal = "zero" } - code = argName + " = " + ref + f.FullName - declErr = validate != "" - } else { - var checkErr bool - code, declErr, checkErr = conversionCode(f.FullName, argName, argTypeName, !f.Required && defaultValue == nil) - if checkErr { - code += "\nif err != nil {\n" + if value.isJSON() { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, + nilVal, argName, f.Example) + } else { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, + nilVal, argName, f.Type) + } + code += "\n}" + } + if validation != nil { + validate := validation(validationTarget) + if validate != "" { + declErr = true + code += "\n" + validate + "\n" nilVal := "nil" if expr.IsPrimitive(payload) { code += fmt.Sprintf("var zero %s\n", payloadRef) nilVal = "zero" } - if flagType(argTypeName) == "JSON" { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - nilVal, argName, f.Example) - } else { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, - nilVal, argName, f.Type) - } - code += "\n}" - } - } - if validate != "" { - nilCheck := "if " + argName + " != nil {" - if strings.HasPrefix(validate, nilCheck) { - // hackety hack... the validation code is generated for the client and needs to - // account for the fact that the field could be nil in this case. We are reusing - // that code to validate a CLI flag which can never be nil. Lint tools complain - // about that so remove the if statements. Ideally we'd have a better way to do - // this but that requires a lot of changes and the added complexity might not be - // worth it. - var lines []string - ls := strings.Split(validate, "\n") - for i := 1; i < len(ls)-1; i++ { - if ls[i+1] == nilCheck { - i++ // skip both closing brace on previous line and check - continue - } - lines = append(lines, ls[i]) - } - validate = strings.Join(lines, "\n") - } - code += "\n" + validate + "\n" - nilVal := "nil" - if expr.IsPrimitive(payload) { - code += fmt.Sprintf("var zero %s\n", payloadRef) - nilVal = "zero" + code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) } - code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) } return fmt.Sprintf("%s%s%s", startIf, code, endIf), declErr } -// flagType calculates the type of a flag -func flagType(tname string) string { - switch tname { - case boolN, intN, int32N, int64N, uintN, uint32N, uint64N, float32N, float64N, stringN: - return strings.ToUpper(tname) - case bytesN: - return "STRING" - default: // Any, Array, Map, Object, User - return "JSON" - } -} - // jsonExample generates a json example func jsonExample(v any) string { // In JSON, keys must be a string. But goa allows map keys to be anything. @@ -626,96 +822,171 @@ func jsonExample(v any) string { return ex } -var ( - boolN = codegen.GoNativeTypeName(expr.Boolean) - intN = codegen.GoNativeTypeName(expr.Int) - int32N = codegen.GoNativeTypeName(expr.Int32) - int64N = codegen.GoNativeTypeName(expr.Int64) - uintN = codegen.GoNativeTypeName(expr.UInt) - uint32N = codegen.GoNativeTypeName(expr.UInt32) - uint64N = codegen.GoNativeTypeName(expr.UInt64) - float32N = codegen.GoNativeTypeName(expr.Float32) - float64N = codegen.GoNativeTypeName(expr.Float64) - stringN = codegen.GoNativeTypeName(expr.String) - bytesN = codegen.GoNativeTypeName(expr.Bytes) -) - -// conversionCode produces the code that converts the string contained in the -// variable named from to the value stored in the variable "to" of type -// typeName. The second return value indicates whether the "err" variable must -// be declared prior to the conversion code being rendered. The last return -// value indicates whether the generated code can produce errors (i.e. -// initialize the err variable). -func conversionCode(from, to, typeName string, pointer bool) (string, bool, bool) { - var ( - parse string - cast string - - target = to - needCast = typeName != stringN && typeName != bytesN && flagType(typeName) != "JSON" - declErr = true - checkErr = true - decl = "" - ) - if needCast && pointer { - target = "val" - decl = ":" +// directPayloadConversion writes the primitive payload conversion after the +// parser has chosen the exact flag pointer variable used by this method. +func directPayloadConversion(flag *FlagData, variables *ParserVariablesData) string { + var prefix, suffix string + target := variables.Data + if flag.value.isJSON() { + target = variables.ConvertedValue + prefix = fmt.Sprintf("var %s %s\n", variables.ConvertedValue, flag.value.typeName) + suffix = fmt.Sprintf("\n%s = %s", variables.Data, variables.ConvertedValue) } - switch typeName { - case boolN: - if pointer { - parse = fmt.Sprintf("var %s bool\n", target) + converted := conversionCode("*"+flag.PointerVar, target, flag.value, false, conversionVariableNames{ + error: variables.Error, + parsed: variables.ParsedValue, + converted: variables.ConvertedValue, + }) + code := prefix + converted.code + suffix + if !converted.canError { + return code + } + code = fmt.Sprintf("var %s error\n%s\nif %s != nil {\n", variables.Error, code, variables.Error) + if flag.value.isJSON() { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", %s, %q)`, + flag.PointerVar, variables.Error, flag.Example) + } else { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, + flag.PointerVar, flag.Type) + } + return code + "\n}" +} + +// conversionCode describes the code and concrete Go value produced from the +// flag text in from. The result also reports how the conversion uses err. +func conversionCode(from, to string, value *flagValuePlan, pointer bool, variables conversionVariableNames) conversionData { + var parse string + switch value.kind { + case expr.BooleanKind: + if !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseBool(%s)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s bool\n%s, %s = strconv.ParseBool(%s)", variables.parsed, variables.parsed, variables.error, from) + case expr.IntKind, expr.Int32Kind, expr.Int64Kind: + bits := "64" + if value.kind == expr.IntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.Int32Kind { + bits = "32" } - parse += fmt.Sprintf("%s, err = strconv.ParseBool(%s)", target, from) - case intN: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= int(v)", target, decl) - case int32N: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= int32(v)", target, decl) - case int64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseInt(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case uintN: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= uint(v)", target, decl) - case uint32N: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= uint32(v)", target, decl) - case uint64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseUint(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case float32N: - parse = fmt.Sprintf("var v float64\nv, err = strconv.ParseFloat(%s, 32)", from) - cast = fmt.Sprintf("%s %s= float32(v)", target, decl) - case float64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseFloat(%s, 64)", target, decl, from) - declErr = decl == "" - case stringN: - parse = fmt.Sprintf("%s %s= %s", target, decl, from) - declErr = false - checkErr = false - case bytesN: - parse = fmt.Sprintf("%s %s= []byte(%s)", target, decl, from) - declErr = false - checkErr = false + if value.kind == expr.Int64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseInt(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s int64\n%s, %s = strconv.ParseInt(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind: + bits := "64" + if value.kind == expr.UIntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.UInt32Kind { + bits = "32" + } + if value.kind == expr.UInt64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseUint(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s uint64\n%s, %s = strconv.ParseUint(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.Float32Kind, expr.Float64Kind: + bits := "64" + if value.kind == expr.Float32Kind { + bits = "32" + } + if value.kind == expr.Float64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseFloat(%s, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s float64\n%s, %s = strconv.ParseFloat(%s, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.StringKind: + converted := from + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + if pointer && !value.alias { + return conversionData{code: fmt.Sprintf("%s = &%s", to, from), value: from} + } + code, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{code: code, value: target} + case expr.BytesKind: + converted := fmt.Sprintf("[]byte(%s)", from) + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + return conversionData{code: fmt.Sprintf("%s = %s", to, converted), value: to} default: - parse = fmt.Sprintf("err = json.Unmarshal([]byte(%s), &%s)", from, target) - } - if !needCast { - return parse, declErr, checkErr + parse = fmt.Sprintf("%s = json.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + return conversionData{code: parse, value: to, declaresError: true, canError: true} } - if cast != "" { - parse = parse + "\n" + cast + converted := fmt.Sprintf("%s(%s)", value.typeRef, variables.parsed) + assignment, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{ + code: parse + "\n" + assignment, + value: target, + declaresError: true, + canError: true, } - if to != target { - ref := "" - if pointer { - ref = "&" +} + +// directParsedConversion writes a parser result directly into its final value +// when the parser already returns the generated Go type. +func directParsedConversion(target, typeRef, parser string, pointer bool, variables conversionVariableNames) conversionData { + if !pointer { + return conversionData{ + code: fmt.Sprintf("%s, %s = %s", target, variables.error, parser), + value: target, + declaresError: true, + canError: true, } - parse += fmt.Sprintf("\n%s = %s%s", to, ref, target) } - return parse, declErr, checkErr + return conversionData{ + code: fmt.Sprintf("var %s %s\n%s, %s = %s\n%s = &%s", variables.converted, typeRef, variables.converted, variables.error, parser, target, variables.converted), + value: variables.converted, + declaresError: true, + canError: true, + } +} + +// assignConvertedValue writes a converted scalar into its final local and +// returns the concrete value expression used by validation. +func assignConvertedValue(target, converted string, pointer bool, valueVariable string) (string, string) { + if !pointer { + return fmt.Sprintf("%s = %s", target, converted), target + } + return fmt.Sprintf("%s := %s\n%s = &%s", valueVariable, converted, target, valueVariable), valueVariable +} + +// flagType returns the command-line type shown in help and conversion errors. +func (p *flagValuePlan) flagType() string { + switch p.kind { + case expr.BooleanKind: + return "BOOL" + case expr.IntKind: + return "INT" + case expr.Int32Kind: + return "INT32" + case expr.Int64Kind: + return "INT64" + case expr.UIntKind: + return "UINT" + case expr.UInt32Kind: + return "UINT32" + case expr.UInt64Kind: + return "UINT64" + case expr.Float32Kind: + return "FLOAT32" + case expr.Float64Kind: + return "FLOAT64" + case expr.StringKind, expr.BytesKind: + return "STRING" + default: + return "JSON" + } +} + +// isJSON reports whether flag text uses JSON decoding. +func (p *flagValuePlan) isJSON() bool { + return p.kind != expr.BooleanKind && p.kind != expr.IntKind && + p.kind != expr.Int32Kind && p.kind != expr.Int64Kind && + p.kind != expr.UIntKind && p.kind != expr.UInt32Kind && + p.kind != expr.UInt64Kind && p.kind != expr.Float32Kind && + p.kind != expr.Float64Kind && p.kind != expr.StringKind && + p.kind != expr.BytesKind } // goifyTerms makes valid go identifiers out of the supplied terms @@ -730,10 +1001,19 @@ func goifyTerms(terms ...string) string { return res } +// printDescription indents each line embedded in generated Go code while +// keeping blank lines free of whitespace. func printDescription(desc string) string { - res := strings.ReplaceAll(desc, "`", "`+\"`\"+`") - res = strings.ReplaceAll(res, "\n", "\n\t") - return res + desc = strings.TrimRight(desc, " \t\r\n") + lines := strings.Split(strings.ReplaceAll(desc, "`", "`+\"`\"+`"), "\n") + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + lines[i] = "" + continue + } + lines[i] = "\t" + lines[i] + } + return strings.Join(lines, "\n") } func generateExample(sub *SubcommandData, svc string) { diff --git a/codegen/cli/cli_test.go b/codegen/cli/cli_test.go new file mode 100644 index 0000000000..fcfae347f7 --- /dev/null +++ b/codegen/cli/cli_test.go @@ -0,0 +1,499 @@ +// This file verifies command-line payload builders validate the concrete Go +// values produced from flag text. The tests catch regressions where validation +// is generated for a pointer and then edited as source text. +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// TestParserVariablesKeepCollidingMethodsDistinct catches generated parsers +// that rebuild local variable names from method text. The two command names are +// different, but both become StatusUpdate when converted to a Go identifier. +func TestParserVariablesKeepCollidingMethodsDistinct(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/jsonrpc/cli/server") + require.NoError(t, err) + parser, err := DeclareParser(pkg, "jsonrpc", "api", "server", []CommandDeclarationInput{ + {Service: "notifications", Methods: []string{"status_update", "status+update"}}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + const preferred = "notificationsStatusUpdate" + value := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil).value + firstFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + secondFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + builder := &BuildFunctionData{ + ActualParams: []string{preferred + "Body"}, + FormalParams: []string{preferred + "Body"}, + } + command := &CommandData{ + ServiceName: "notifications", + Name: "notifications", + VarName: "notifications", + UsageDeclaration: parser.Commands["notifications"].Usage, + Subcommands: []*SubcommandData{ + { + MethodName: "status_update", + Name: "status-update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status_update"], + Flags: []*FlagData{firstFlag}, + conversionFlag: firstFlag, + }, + { + MethodName: "status+update", + Name: "status+update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status+update"], + Flags: []*FlagData{secondFlag}, + BuildFunction: builder, + }, + }, + } + + parser.PlanVariables([]*CommandData{command}, nil) + generated := parser.FlagsCode([]*CommandData{command}) + + require.Equal(t, "notificationsFlags", command.FlagSetVar) + require.Equal(t, preferred+"Flags2", command.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags", command.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag2", firstFlag.PointerVar) + require.Equal(t, preferred+"BodyFlag", secondFlag.PointerVar) + require.Equal(t, "data = *"+preferred+"BodyFlag2", command.Subcommands[0].Conversion) + require.Equal(t, []string{preferred + "Body"}, builder.ActualParams) + require.Equal(t, []string{preferred + "BodyFlag"}, command.Subcommands[1].ActualPointerVars) + require.Equal(t, 4, strings.Count(generated, preferred+"Flags2")) + require.Equal(t, 1, strings.Count(generated, preferred+"BodyFlag2 =")) + + reversedPlus := &FlagData{Name: "body", FullName: preferred + "Body"} + reversedUnderscore := &FlagData{Name: "body", FullName: preferred + "Body"} + reversed := &CommandData{ + ServiceName: "notifications", + VarName: "notifications", + Subcommands: []*SubcommandData{ + {MethodName: "status+update", FullName: preferred, Flags: []*FlagData{reversedPlus}}, + {MethodName: "status_update", FullName: preferred, Flags: []*FlagData{reversedUnderscore}}, + }, + } + parser.PlanVariables([]*CommandData{reversed}, nil) + require.Equal(t, preferred+"Flags", reversed.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags2", reversed.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag", reversedPlus.PointerVar) + require.Equal(t, preferred+"BodyFlag2", reversedUnderscore.PointerVar) +} + +// TestBuildCommandDataUsesPlannedServicePath checks that the public command +// name matches the unique path chosen while service packages are planned. +func TestBuildCommandDataUsesPlannedServicePath(t *testing.T) { + tests := []struct { + name string + service *service.Data + command string + }{ + { + name: "ordinary service", + service: &service.Data{Name: "Calculator", PathName: "calculator"}, + command: "calculator", + }, + { + name: "first colliding service", + service: &service.Data{Name: "mcp_read_value", PathName: "mcp_read_value"}, + command: "mcp-read-value", + }, + { + name: "second colliding service", + service: &service.Data{Name: "mcp-read-value", PathName: "mcp_read_value2"}, + command: "mcp-read-value2", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := BuildCommandData(test.service) + require.Equal(t, test.command, command.Name) + }) + } +} + +func TestFieldLoadCodeValidationTarget(t *testing.T) { + minimum := 1.0 + minLength := 1 + cases := []struct { + name string + flag *FlagData + argument string + attribute *expr.AttributeExpr + typeName string + typeRef string + wantTarget string + wantCondition string + wantError string + avoidValidation string + }{ + { + name: "optional integer", + flag: &FlagData{FullName: "serviceMethodCount", Type: "INT"}, + argument: "count", + typeName: "int", + typeRef: "int", + attribute: &expr.AttributeExpr{Type: expr.Int, Validation: &expr.ValidationExpr{Minimum: &minimum}}, + wantTarget: "val", + wantCondition: "if val < 1 {", + wantError: `goa.InvalidRangeError("count", val, 1, true)`, + avoidValidation: "if count != nil", + }, + { + name: "optional string", + flag: &FlagData{FullName: "serviceMethodState", Type: "STRING"}, + argument: "state", + typeName: "string", + typeRef: "string", + attribute: &expr.AttributeExpr{Type: expr.String, Validation: &expr.ValidationExpr{Values: []any{"ready"}}}, + wantTarget: "serviceMethodState", + wantCondition: `if !(serviceMethodState == "ready") {`, + wantError: `goa.InvalidEnumValueError("state", serviceMethodState, []any{"ready"})`, + avoidValidation: "if state != nil", + }, + { + name: "optional JSON array", + flag: &FlagData{FullName: "serviceMethodItems", Type: "JSON", Example: "[]"}, + argument: "items", + typeName: "[]string", + typeRef: "[]string", + attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, Validation: &expr.ValidationExpr{MinLength: &minLength}}, + wantTarget: "items", + wantCondition: "if len(items) < 1 {", + wantError: `goa.InvalidLengthError("items", items, len(items), 1, true)`, + avoidValidation: "if items != nil", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode( + test.attribute, + nil, + context, + true, + false, + value, + test.argument, + ) + } + value := NewFlagPlan(test.attribute, test.typeName, test.typeRef, nil).value + + generated, declaresError := fieldLoadCode( + test.flag, + test.argument, + value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, test.wantTarget, target) + require.Contains(t, generated, test.wantCondition) + require.Contains(t, generated, test.wantError) + require.NotContains(t, generated, test.avoidValidation) + require.True(t, declaresError) + }) + } +} + +// TestFlagArgumentTypeNameMatchesValuePlan checks the released type-name field +// against the value description used to generate the flag conversion. +func TestFlagArgumentTypeNameMatchesValuePlan(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "Label", "Label", nil) + argument := &FlagArgData{Plan: plan, TypeName: plan.value.typeName} + require.Equal(t, plan.value.typeName, argument.TypeName) +} + +// TestLegacyFlagValidationIsEmitted verifies that plugins using the released +// Validate field still write their validation code into payload builders. +func TestLegacyFlagValidationIsEmitted(t *testing.T) { + _, builder := MakeFlags( + "Service", + &service.MethodData{Name: "Method", VarName: "Method"}, + []*FlagArgData{{ + Name: "value", + TypeName: "string", + TypeRef: "string", + Required: true, + Validate: "if value == \"\" {\n\terr = goa.MissingFieldError(\"value\", \"payload\")\n}", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + + require.Contains(t, builder.Fields[0].Init, "if value == \"\"") + require.Contains(t, builder.Fields[0].Init, "goa.MissingFieldError") + require.Equal(t, "BuildMethodPayload", builder.Name) + require.True(t, builder.CheckErr) +} + +// TestFlagValidationFormsAreExclusive catches generators that silently choose +// one validation form when a plugin supplies both forms. +func TestFlagValidationFormsAreExclusive(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", func(string) string { return "typed" }) + require.PanicsWithValue(t, "CLI flag validation cannot use both Validate and Plan", func() { + MakeFlags( + "Service", + &service.MethodData{Name: "Method"}, + []*FlagArgData{{ + Name: "value", + Plan: plan, + TypeRef: "string", + Required: true, + Validate: "legacy", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + }) +} + +// TestUsageSectionsKeepReleasedData checks that plugins still receive a string +// slice when they replace the source of a generated help section. +func TestUsageSectionsKeepReleasedData(t *testing.T) { + command := &CommandData{Name: "calc", Subcommands: []*SubcommandData{{Name: "add"}}, Example: "calc add"} + require.IsType(t, []string{}, UsageCommands([]*CommandData{command}).Data) + require.IsType(t, []string{}, UsageExamples([]*CommandData{command}).Data) +} + +// TestPrintDescriptionLeavesBlankLinesEmpty catches generated help text that +// puts tabs on otherwise empty lines. +func TestPrintDescriptionLeavesBlankLinesEmpty(t *testing.T) { + require.Equal(t, "First line.\n\n\tSecond paragraph.", printDescription("First line.\n\nSecond paragraph.\n\t\n")) +} + +// TestReleasedFunctionSignaturesCompile keeps the public CLI helper calls used +// by plugins source compatible. +func TestReleasedFunctionSignaturesCompile(t *testing.T) { + var buildCommand func(*service.Data) *CommandData = BuildCommandData + var endpointFile func(string, string, []*codegen.ImportSpec, []*CommandData, *codegen.SectionTemplate) *codegen.File = EndpointParserFile + var usageCommands func([]*CommandData) *codegen.SectionTemplate = UsageCommands + var usageExamples func([]*CommandData) *codegen.SectionTemplate = UsageExamples + var flagsCode func([]*CommandData) string = FlagsCode + var newFlag func(string, string, string, string, string, bool, any, any) *FlagData = NewFlagData + var fieldLoad func(*FlagData, string, string, string, any, expr.DataType, string) (string, bool) = FieldLoadCode + + require.NotNil(t, buildCommand) + require.NotNil(t, endpointFile) + require.NotNil(t, usageCommands) + require.NotNil(t, usageExamples) + require.NotNil(t, flagsCode) + require.NotNil(t, newFlag) + require.NotNil(t, fieldLoad) +} + +// TestReleasedFlagHelpersGenerateConversions checks the string-based flag +// helpers still produce the conversion and validation requested by plugins. +func TestReleasedFlagHelpersGenerateConversions(t *testing.T) { + flag := NewFlagData("Service", "Method", "count", "int32", "", true, int32(1), nil) + generated, declaresError := FieldLoadCode( + flag, + "count", + "int32", + "if count < 1 {\n\terr = goa.InvalidRangeError(\"count\", count, 1, true)\n}", + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "if count < 1") + require.True(t, declaresError) +} + +// TestReleasedFlagsCodeUsesReleasedTemplateData checks that plugins can still +// render flags without creating a parser plan. +func TestReleasedFlagsCodeUsesReleasedTemplateData(t *testing.T) { + command := &CommandData{ + Name: "calc", + VarName: "calc", + Subcommands: []*SubcommandData{{ + Name: "add", + FullName: "calcAdd", + Flags: []*FlagData{{ + Name: "value", + FullName: "calcAddValue", + }}, + }}, + } + generated := FlagsCode([]*CommandData{command}) + require.Contains(t, generated, `calcFlags = flag.NewFlagSet("calc"`) + require.Contains(t, generated, `calcAddValueFlag = calcAddFlags.String("value"`) +} + +func TestFieldLoadCodeRequiredIntegerValidationUsesLoadedValue(t *testing.T) { + maximum := 42.0 + attribute := &expr.AttributeExpr{ + Type: expr.Int32, + Validation: &expr.ValidationExpr{Maximum: &maximum}, + } + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode(attribute, nil, context, true, false, value, "count") + } + + generated, declaresError := fieldLoadCode( + &FlagData{FullName: "serviceMethodCount", Type: "INT32", Required: true}, + "count", + NewFlagPlan(attribute, "int32", "int32", nil).value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "count", target) + require.Contains(t, generated, "if count > 42") + require.True(t, declaresError) +} + +func TestPrimitiveAliasFlagPlan(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Count", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.Int32, + }, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, "Count", "service.Count", nil).value + flag := newFlagData("Service", "Method", "count", value, "", false, int32(3), nil) + + generated, declaresError := fieldLoadCode( + flag, + "count", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "val := service.Count(v)") + require.Contains(t, generated, "count = &val") + require.NotContains(t, generated, "json.Unmarshal") + require.True(t, declaresError) +} + +func TestCompositeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + } + value := NewFlagPlan(attribute, "[]string", "[]string", nil).value + flag := newFlagData("Service", "Method", "items", value, "", false, []string{"one"}, nil) + + generated, declaresError := fieldLoadCode( + flag, + "items", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodItems), &items)") + require.True(t, declaresError) +} + +func TestStringAndBytesAliasesUseStringFlags(t *testing.T) { + cases := []struct { + name string + primitive expr.DataType + typeName string + typeRef string + wantGenerated string + }{ + { + name: "string alias", + primitive: expr.String, + typeName: "Label", + typeRef: "service.Label", + wantGenerated: "val := service.Label(serviceMethodValue)\nvalue = &val", + }, + { + name: "bytes alias", + primitive: expr.Bytes, + typeName: "Blob", + typeRef: "service.Blob", + wantGenerated: "value = service.Blob(serviceMethodValue)", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: test.typeName, + AttributeExpr: &expr.AttributeExpr{Type: test.primitive}, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, test.typeName, test.typeRef, nil).value + flag := newFlagData("Service", "Method", "value", value, "", false, "value", nil) + + generated, _ := fieldLoadCode( + flag, + "value", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "STRING", flag.Type) + require.Contains(t, generated, test.wantGenerated) + require.NotContains(t, generated, "json.Unmarshal") + }) + } +} + +func TestCustomGoTypeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": []string{"time.Time", "time"}, + }, + } + value := NewFlagPlan(attribute, "time.Time", "time.Time", nil).value + flag := newFlagData("Service", "Method", "at", value, "", true, "2026-08-22T00:00:00Z", nil) + + generated, declaresError := fieldLoadCode( + flag, + "at", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodAt), &at)") + require.True(t, declaresError) +} diff --git a/codegen/cli/symbols.go b/codegen/cli/symbols.go index ea63783893..5ab72b3733 100644 --- a/codegen/cli/symbols.go +++ b/codegen/cli/symbols.go @@ -1,6 +1,6 @@ -// This file assigns the package function names used by command-line client -// files. HTTP and gRPC planning call these functions before generated names -// are finalized, then pass the returned records to the shared CLI templates. +// This file assigns the function and local variable names used by command-line +// client files. HTTP and gRPC planning completes these names before rendering +// the shared CLI templates. package cli import ( @@ -8,6 +8,8 @@ import ( "cmp" "crypto/sha256" "encoding/binary" + "slices" + "sort" "goa.design/goa/v3/codegen" ) @@ -28,6 +30,72 @@ type ( Declarations *ParserDeclarations // Commands contains the help function names for each design service. Commands map[string]*CommandPlan + // Variables contains the exact parameter and local names used by ParseEndpoint. + Variables *ParserVariablesData + family string + variables *codegen.NameScope + imports []string + variableNames map[parserVariableIdentity]parserVariableName + planned bool + } + + // ParserVariablesData contains the exact names of parameters and local + // values written directly by the shared HTTP and gRPC parser templates. + ParserVariablesData struct { + // ServiceName stores the selected service command name. + ServiceName string + // ServiceFlags stores the flag set for the selected service. + ServiceFlags string + // MethodName stores the selected method command name. + MethodName string + // MethodFlags stores the flag set for the selected method. + MethodFlags string + // Data stores the payload passed to the selected endpoint. + Data string + // Endpoint stores the selected Goa endpoint. + Endpoint string + // Error stores an error returned while building the payload. + Error string + // Client stores the generated transport client. + Client string + // Scheme is the HTTP URL scheme parameter. + Scheme string + // Host is the HTTP server address parameter. + Host string + // Doer is the HTTP request executor parameter. + Doer string + // Encoder is the HTTP request encoder parameter. + Encoder string + // Decoder is the HTTP response decoder parameter. + Decoder string + // Restore is the HTTP response-body restore parameter. + Restore string + // Dialer is the WebSocket dialer parameter. + Dialer string + // Connection is the gRPC connection parameter. + Connection string + // Options is the gRPC call option parameter. + Options string + // ParsedValue stores a primitive value returned by a string parser. + ParsedValue string + // ConvertedValue stores a value before it is assigned through a pointer. + ConvertedValue string + } + + // ParserLocalData describes one transport-specific parameter written in the + // generated endpoint parser. PlanVariables fills VarName before rendering. + ParserLocalData struct { + // ServiceName is the exact design service that uses this parameter. + ServiceName string + // MethodName is the exact design method that uses this parameter. It is + // empty for a service-wide parameter. + MethodName string + // Use distinguishes parameters that serve different purposes in one method. + Use string + // PreferredName is the Go name used when it does not conflict with another local. + PreferredName string + // VarName is the exact Go name written by the parameter and every use. + VarName string } // CommandPlan contains the help function names for one service command. @@ -52,6 +120,37 @@ type ( // symbolRole lists the package functions emitted by shared CLI templates. symbolRole uint8 + + // parserVariableCandidate records one local definition and the data field + // that receives its exact Go name. + parserVariableCandidate struct { + identity parserVariableIdentity + preferred string + command *CommandData + subcommand *SubcommandData + flag *FlagData + interceptor *InterceptorData + local *ParserLocalData + } + + // parserVariableIdentity orders local definitions by their exact design + // names, so reversing input slices does not change collision suffixes. + parserVariableIdentity struct { + service string + method string + flag string + use string + role parserVariableRole + } + + // parserVariableName stores the preferred and exact name selected for one local. + parserVariableName struct { + preferred string + name string + } + + // parserVariableRole distinguishes local definitions with the same design names. + parserVariableRole uint8 ) const ( @@ -63,6 +162,14 @@ const ( payloadBuilderRole ) +const ( + serviceFlagSetVariable parserVariableRole = iota + 1 + methodFlagSetVariable + flagPointerVariable + interceptorVariable + transportVariable +) + // DeclareParser submits every function written to one parser package. family // is "http", "jsonrpc", or "grpc"; root and server distinguish files from // separate designs; commands supplies the service and method help names. @@ -102,7 +209,10 @@ func DeclareParser(pkg *codegen.GeneratedPackage, family, root, server string, c UsageCommands: usageCommands, UsageExamples: usageExamples, }, - Commands: make(map[string]*CommandPlan, len(commands)), + Commands: make(map[string]*CommandPlan, len(commands)), + family: family, + variables: codegen.NewNameScope(), + variableNames: make(map[parserVariableIdentity]parserVariableName), } for _, command := range commands { usage, err := declare(goifyTerms(command.Service)+"Usage", commandUsageRole, command.Service, "") @@ -125,6 +235,67 @@ func DeclareParser(pkg *codegen.GeneratedPackage, family, root, server string, c return plan, nil } +// PlanVariables chooses every local Go name written by one endpoint parser. +// data contains the shared service, method, flag, and interceptor values; +// locals contains transport-specific parameters such as multipart encoders. +func (p *ParserPlan) PlanVariables(data []*CommandData, locals []*ParserLocalData) { + candidates := parserVariableCandidates(data, locals) + sort.Slice(candidates, func(i, j int) bool { + return compareParserVariable(candidates[i], candidates[j]) < 0 + }) + if !p.planned { + p.imports = parserImportQualifiers(p.family, data) + for _, qualifier := range p.imports { + p.variables.Unique(qualifier) + } + p.Variables = planParserVariables(p.variables, p.family) + for _, candidate := range candidates { + if _, exists := p.variableNames[candidate.identity]; exists { + panic("CLI parser contains the same local variable more than once") + } + name := p.variables.Unique(candidate.preferred) + p.variableNames[candidate.identity] = parserVariableName{ + preferred: candidate.preferred, + name: name, + } + candidate.assign(name) + } + p.variables.Freeze() + p.planned = true + } else { + if !slices.Equal(p.imports, parserImportQualifiers(p.family, data)) { + panic("CLI parser imports changed after local variables were planned") + } + if len(candidates) != len(p.variableNames) { + panic("CLI parser local variables changed after planning") + } + for _, candidate := range candidates { + planned, exists := p.variableNames[candidate.identity] + if !exists || planned.preferred != candidate.preferred { + panic("CLI parser local variable changed after planning") + } + candidate.assign(planned.name) + } + } + for _, command := range data { + for _, subcommand := range command.Subcommands { + if subcommand.Interceptors != nil { + subcommand.Interceptors.ParserVar = command.Interceptors.ParserVar + } + if subcommand.BuildFunction != nil { + count := len(subcommand.BuildFunction.ActualParams) + subcommand.ActualPointerVars = make([]string, count) + for index := range count { + subcommand.ActualPointerVars[index] = subcommand.Flags[index].PointerVar + } + } + if subcommand.conversionFlag != nil { + subcommand.Conversion = directPayloadConversion(subcommand.conversionFlag, p.Variables) + } + } + } +} + // DeclarePayloadBuilder submits the function that builds one method payload // from command-line flags and returns the record used by its definition and // calls. @@ -160,6 +331,171 @@ func (order symbolOrder) ComparePackageName(other codegen.PackageNameOrder) int return bytes.Compare(order.commands[:], right.commands[:]) } +// parserVariableCandidates collects each local definition before any name is +// chosen, including transport parameters supplied by the caller. +func parserVariableCandidates(data []*CommandData, locals []*ParserLocalData) []*parserVariableCandidate { + var candidates []*parserVariableCandidate + for _, command := range data { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: serviceFlagSetVariable, + }, + preferred: command.VarName + "Flags", + command: command, + }) + if command.Interceptors != nil { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: interceptorVariable, + }, + preferred: command.Interceptors.VarName, + interceptor: command.Interceptors, + }) + } + for _, subcommand := range command.Subcommands { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + role: methodFlagSetVariable, + }, + preferred: subcommand.FullName + "Flags", + subcommand: subcommand, + }) + for _, flag := range subcommand.Flags { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + flag: flag.Name, + role: flagPointerVariable, + }, + preferred: flag.FullName + "Flag", + flag: flag, + }) + } + } + } + for _, local := range locals { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: local.ServiceName, + method: local.MethodName, + use: local.Use, + role: transportVariable, + }, + preferred: local.PreferredName, + local: local, + }) + } + return candidates +} + +// parserImportQualifiers returns every package name referenced by ParseEndpoint. +// Reserving them first prevents a local variable from hiding an imported package. +func parserImportQualifiers(family string, data []*CommandData) []string { + qualifiers := map[string]struct{}{ + "flag": {}, + "fmt": {}, + "goa": {}, + "os": {}, + } + switch family { + case "grpc": + qualifiers["grpc"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + case "http", "jsonrpc": + qualifiers["goahttp"] = struct{}{} + qualifiers["http"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + } + for _, command := range data { + if command.PkgName != "" { + qualifiers[command.PkgName] = struct{}{} + } + if command.Interceptors != nil && command.Interceptors.PkgName != "" { + qualifiers[command.Interceptors.PkgName] = struct{}{} + } + } + result := make([]string, 0, len(qualifiers)) + for qualifier := range qualifiers { + result = append(result, qualifier) + } + sort.Strings(result) + return result +} + +// planParserVariables chooses names for parameters and local values written by +// the parser templates after all imported package names are reserved. +func planParserVariables(scope *codegen.NameScope, family string) *ParserVariablesData { + variables := &ParserVariablesData{ + ServiceName: scope.Unique("svcn"), + ServiceFlags: scope.Unique("svcf"), + MethodName: scope.Unique("epn"), + MethodFlags: scope.Unique("epf"), + Data: scope.Unique("data"), + Endpoint: scope.Unique("endpoint"), + Error: scope.Unique("err"), + Client: scope.Unique("c"), + ParsedValue: scope.Unique("v"), + ConvertedValue: scope.Unique("val"), + } + switch family { + case "grpc": + variables.Connection = scope.Unique("cc") + variables.Options = scope.Unique("opts") + case "http", "jsonrpc": + variables.Scheme = scope.Unique("scheme") + variables.Host = scope.Unique("host") + variables.Doer = scope.Unique("doer") + variables.Encoder = scope.Unique("enc") + variables.Decoder = scope.Unique("dec") + variables.Restore = scope.Unique("restore") + variables.Dialer = scope.Unique("dialer") + } + return variables +} + +// compareParserVariable orders exact design identities before the preferred Go +// spelling, so the same design always receives the same suffix. +func compareParserVariable(left, right *parserVariableCandidate) int { + for _, compared := range []int{ + cmp.Compare(left.identity.service, right.identity.service), + cmp.Compare(left.identity.method, right.identity.method), + cmp.Compare(left.identity.flag, right.identity.flag), + cmp.Compare(left.identity.use, right.identity.use), + cmp.Compare(left.identity.role, right.identity.role), + cmp.Compare(left.preferred, right.preferred), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// assign stores one exact name on the data read by its definition and uses. +func (candidate *parserVariableCandidate) assign(name string) { + switch { + case candidate.command != nil: + candidate.command.FlagSetVar = name + case candidate.subcommand != nil: + candidate.subcommand.FlagSetVar = name + case candidate.flag != nil: + candidate.flag.PointerVar = name + case candidate.interceptor != nil: + candidate.interceptor.ParserVar = name + case candidate.local != nil: + candidate.local.VarName = name + } +} + // commandDeclarationNames returns fixed-size bytes derived from every service // and method name written into one parser file. func commandDeclarationNames(commands []CommandDeclarationInput) [sha256.Size]byte { diff --git a/codegen/cli/templates.go b/codegen/cli/templates.go index 51525e204a..a64a15e85b 100644 --- a/codegen/cli/templates.go +++ b/codegen/cli/templates.go @@ -8,11 +8,12 @@ import ( // Template constants const ( - usageCommandsT = "usage_commands" - usageExamplesT = "usage_examples" - parseFlagsT = "parse_flags" - commandUsageT = "command_usage" - buildPayloadT = "build_payload" + usageCommandsT = "usage_commands" + usageExamplesT = "usage_examples" + parseFlagsT = "parse_flags" + parseFlagsPlannedT = "parse_flags_planned" + commandUsageT = "command_usage" + buildPayloadT = "build_payload" ) //go:embed templates/*.go.tpl diff --git a/codegen/cli/templates/build_payload.go.tpl b/codegen/cli/templates/build_payload.go.tpl index 0dc0d5850a..4de3b35885 100644 --- a/codegen/cli/templates/build_payload.go.tpl +++ b/codegen/cli/templates/build_payload.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s builds the payload for the %s %s endpoint from CLI flags." .Declaration.Name .ServiceName .MethodName | comment }} -func {{ .Declaration.Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .ResultType }}, error) { +{{ printf "%s builds the payload for the %s %s endpoint from CLI flags." .Name .ServiceName .MethodName | comment }} +func {{ .Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .ResultType }}, error) { {{- if .CheckErr }} var err error {{- end }} diff --git a/codegen/cli/templates/parse_flags.go.tpl b/codegen/cli/templates/parse_flags.go.tpl index 6b5ab16d2d..f8617fff3c 100644 --- a/codegen/cli/templates/parse_flags.go.tpl +++ b/codegen/cli/templates/parse_flags.go.tpl @@ -12,9 +12,9 @@ var ( ) {{ range . -}} {{ $cmd := . -}} - {{ .VarName }}Flags.Usage = {{ .UsageDeclaration.Name }} + {{ .VarName }}Flags.Usage = {{ .VarName }}Usage {{ range .Subcommands -}} - {{ .FullName }}Flags.Usage = {{ .UsageDeclaration.Name }} + {{ .FullName }}Flags.Usage = {{ .FullName }}Usage {{ end }} {{ end }} if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { diff --git a/codegen/cli/templates/parse_flags_planned.go.tpl b/codegen/cli/templates/parse_flags_planned.go.tpl new file mode 100644 index 0000000000..d6422acd75 --- /dev/null +++ b/codegen/cli/templates/parse_flags_planned.go.tpl @@ -0,0 +1,74 @@ +var ( + {{- range .Commands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ContinueOnError) + {{ range .Subcommands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ExitOnError) + {{- $sub := . }} + {{- range .Flags }} + {{ .PointerVar }} = {{ $sub.FlagSetVar }}.String("{{ .Name }}", "{{ if .Default }}{{ .Default }}{{ else if .Required }}REQUIRED{{ end }}", {{ printf "%q" .Description }}) + {{- end }} + {{ end }} + {{- end }} + ) + {{ range .Commands -}} + {{ $cmd := . -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ range .Subcommands -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ end }} + {{ end }} + if {{ .Variables.Error }} := flag.CommandLine.Parse(os.Args[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + {{ .Variables.ServiceName }} string + {{ .Variables.ServiceFlags }} *flag.FlagSet + ) + { + {{ .Variables.ServiceName }} = flag.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + {{ $.Variables.ServiceFlags }} = {{ .FlagSetVar }} + {{- end }} + default: + return nil, nil, fmt.Errorf("unknown service %q", {{ .Variables.ServiceName }}) + } + } + if {{ .Variables.Error }} := {{ .Variables.ServiceFlags }}.Parse(flag.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + var ( + {{ .Variables.MethodName }} string + {{ .Variables.MethodFlags }} *flag.FlagSet + ) + { + {{ .Variables.MethodName }} = {{ .Variables.ServiceFlags }}.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + switch {{ $.Variables.MethodName }} { + {{- range .Subcommands }} + case "{{ .Name }}": + {{ $.Variables.MethodFlags }} = {{ .FlagSetVar }} + {{ end }} + } + {{ end }} + } + } + if {{ .Variables.MethodFlags }} == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", {{ .Variables.ServiceName }}, {{ .Variables.MethodName }}) + } + + // Parse endpoint flags if any + if {{ .Variables.ServiceFlags }}.NArg() > 1 { + if {{ .Variables.Error }} := {{ .Variables.MethodFlags }}.Parse({{ .Variables.ServiceFlags }}.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + } diff --git a/codegen/cli/templates/usage_commands.go.tpl b/codegen/cli/templates/usage_commands.go.tpl index 90673253cf..3e452fe302 100644 --- a/codegen/cli/templates/usage_commands.go.tpl +++ b/codegen/cli/templates/usage_commands.go.tpl @@ -2,9 +2,9 @@ // // command (subcommand1|subcommand2|...) // -func {{ .Declaration.Name }}() []string { +func {{ usageName }}() []string { return []string{ -{{- range .Usages }} +{{- range . }} "{{ . }}", {{- end }} } diff --git a/codegen/cli/templates/usage_examples.go.tpl b/codegen/cli/templates/usage_examples.go.tpl index 8b72d24d0b..63ea9afb3a 100644 --- a/codegen/cli/templates/usage_examples.go.tpl +++ b/codegen/cli/templates/usage_examples.go.tpl @@ -1,5 +1,5 @@ // UsageExamples produces an example of a valid invocation of the CLI tool. -func {{ .Declaration.Name }}() string { - return {{ range .Examples }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + +func {{ usageName }}() string { + return {{ range . }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + {{ end }}"" } diff --git a/codegen/example/example_client.go b/codegen/example/example_client.go index 43af0a1ad8..8d797af71c 100644 --- a/codegen/example/example_client.go +++ b/codegen/example/example_client.go @@ -1,22 +1,57 @@ -// This file renders example CLI entrypoints from the retained server analysis. -// Generated service and transport imports are already frozen by planning; the -// CLI renderer receives no separate generated-module path. +// This file writes example command-line programs from copied server data and +// the package names already chosen for this generation. package example import ( - "os" - "path/filepath" "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// CLIFiles returns example client tool main implementation for each server -// expression in the design. -func CLIFiles(root *expr.RootExpr) []*codegen.File { +type ( + // clientMainData contains all design values selected before one example + // command-line client is rendered. + clientMainData struct { + // APIName is the API name written in help text. + APIName string + // Server contains the copied host, transport, and URL variable settings. + Server *clientMainServerData + // HasJSONRPC reports whether the client includes JSON-RPC commands. + HasJSONRPC bool + // HasHTTP reports whether the client includes ordinary HTTP commands. + HasHTTP bool + // UsageCommands is the sorted command list written in help text. + UsageCommands []string + // JSONRPCOnly lists commands handled only by the JSON-RPC client. + JSONRPCOnly []*jsonRPCServiceData + // WritesEndpointResult reports whether a command returns one result. + WritesEndpointResult bool + // WritesStreamResults reports whether a command receives server results. + WritesStreamResults bool + } + + // clientMainServerData contains the URL variables planned for one client + // main and the hosts that use them. + clientMainServerData struct { + *Data + // Variables lists every URL variable with its client flag names. + Variables []*mainVariableData + // Hosts lists each host with the same planned URL variables. + Hosts []*clientMainHostData + } + + // clientMainHostData contains one host and its planned URL variables. + clientMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + } +) + +// CLIFiles returns one example command-line program for each copied server. +func CLIFiles(root *Root) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { + for _, svr := range root.Servers { if m := exampleCLIMain(root, svr); m != nil { fw = append(fw, m) } @@ -24,64 +59,45 @@ func CLIFiles(root *expr.RootExpr) []*codegen.File { return fw } -// exampleCLIMain returns an example client tool main implementation for the -// given server expression. -func exampleCLIMain(root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { - svrdata := Servers.Get(svr, root) - - // Skip CLI generation for servers with no transports (e.g., agent-only services) - if svrdata.DefaultTransport() == nil { +// exampleCLIMain writes the command-line program for server. +func exampleCLIMain(root *Root, server *Data) *codegen.File { + // A server with no HTTP, JSON-RPC, or gRPC service has no client to run. + if server.DefaultTransport() == nil { return nil } - path := filepath.Join("cmd", svrdata.Dir+"-cli", "main.go") - if _, err := os.Stat(path); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "sort"}, - {Path: "slices"}, - {Path: "strings"}, - codegen.GoaImport(""), + path := server.clientMainPath + main := &clientMainData{ + APIName: root.APIName, + Server: planClientMainServer(server), + HasJSONRPC: server.HasJSONRPC, + HasHTTP: server.HasHTTP, + UsageCommands: server.usageCommands, + JSONRPCOnly: server.jsonRPCOnly, + WritesEndpointResult: server.writesEndpointResult, + WritesStreamResults: server.writesStreamResults, } + specs := packageImports(server.clientPackage, clientMainFixedImports(server)) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "cli-main-start", Source: exampleTemplates.Read(clientStartT), - Data: map[string]any{ - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-var-init", Source: exampleTemplates.Read(clientVarInitT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-endpoint-init", Source: exampleTemplates.Read(clientEndpointInitT), - Data: map[string]any{ - "Server": svrdata, - "Root": root, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, "toUpper": strings.ToUpper, @@ -89,15 +105,11 @@ func exampleCLIMain(root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { }, { Name: "cli-main-end", Source: exampleTemplates.Read(clientEndT), + Data: main, }, { Name: "cli-main-usage", Source: exampleTemplates.Read(clientUsageT), - Data: map[string]any{ - "APIName": root.API.Name, - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "toUpper": strings.ToUpper, "join": strings.Join, @@ -107,22 +119,52 @@ func exampleCLIMain(root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { return &codegen.File{Path: path, SectionTemplates: sections, SkipExist: true} } -// hasJSONRPC returns true if the server expression has a JSON-RPC server. -func hasJSONRPC(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.JSONRPC.Service(s) != nil { - return true - } +// clientMainFixedImports lists packages whose names are written directly by +// the command-line client templates. +func clientMainFixedImports(server *Data) []*codegen.ImportSpec { + specs := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "errors"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "strings"}, + } + if server.writesEndpointResult || server.writesStreamResults { + specs = append(specs, + &codegen.ImportSpec{Path: "encoding/json"}, + &codegen.ImportSpec{Path: "io"}, + ) } - return false + if server.writesEndpointResult { + specs = append(specs, codegen.GoaImport("")) + } + return specs } -// hasHTTP returns true if the server expression has an HTTP server. -func hasHTTP(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.HTTP.Service(s) != nil { - return true +// planClientMainServer selects URL flag names that are distinct from the +// built-in client flags. +func planClientMainServer(server *Data) *clientMainServerData { + fixedFlags := []string{"host", "url", "timeout", "verbose", "v"} + if server.HasJSONRPC { + fixedFlags = append(fixedFlags, "jsonrpc", "j") + } + variables := planMainVariables(server.Variables, fixedFlags) + planned := &clientMainServerData{ + Data: server, + Variables: variables.all, + Hosts: make([]*clientMainHostData, len(server.Hosts)), + } + for index, host := range server.Hosts { + plannedHost := &clientMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] } + planned.Hosts[index] = plannedHost } - return false + return planned } diff --git a/codegen/example/example_client_test.go b/codegen/example/example_client_test.go index 60feeb5ec7..35cd66c458 100644 --- a/codegen/example/example_client_test.go +++ b/codegen/example/example_client_test.go @@ -5,31 +5,49 @@ package example import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) func TestExampleCLIFiles(t *testing.T) { cases := []struct { - Name string - DSL func() + Name string + DSL func() + HasEndpointResults bool + HasStreamResults bool }{ - {"no-server", testdata.NoServerDSL}, - {"single-server-single-host", testdata.SingleServerSingleHostDSL}, - {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL}, - {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL}, - {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL}, + {"no-server", testdata.NoServerDSL, true, false}, + {"single-server-single-host", testdata.SingleServerSingleHostDSL, true, false}, + {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL, true, false}, + {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL, true, false}, + {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL, true, false}, + {"server-stream", serverStreamClientDSL, false, true}, + {"input-stream", inputStreamClientDSL, false, false}, + {"mixed-results", mixedResultClientDSL, true, false}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - fs := CLIFiles(root) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + rootData, ok := plan.Root(servicePlan) + require.True(t, ok) + fs := CLIFiles(rootData) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -37,8 +55,118 @@ func TestExampleCLIFiles(t *testing.T) { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + require.Equal(t, c.HasEndpointResults, strings.Contains(code, "func writeEndpointResult(")) + require.Equal(t, c.HasStreamResults, strings.Contains(code, "func writeStreamResults[")) + require.Equal(t, c.HasEndpointResults || c.HasStreamResults, strings.Contains(code, "func writeJSON(")) golden := filepath.Join("testdata", "client-"+c.Name+".golden") compareOrUpdateGolden(t, code, golden) }) } } + +var serverStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} + +var inputStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + }) +} + +var mixedResultClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) +} + +func TestMixedClientRoutesJSONRPCCommandsFromPlannedEndpoints(t *testing.T) { + root := codegen.RunDSL(t, mixedClientRoutingDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + files := CLIFiles(plannedRoot) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.NotContains(t, code, "strings.HasPrefix(err.Error()") + require.Contains(t, code, `case "catalog":`) + require.Contains(t, code, `case "watch":`) + require.Contains(t, code, "err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout)") + require.Contains(t, code, `usageCommands := []string{`) + require.NotContains(t, code, "sort.Strings(usageCommands)") + require.NotContains(t, code, "slices.Compact(usageCommands)") + first := strings.Index(code, `"catalog read"`) + second := strings.Index(code, `"catalog watch"`) + require.GreaterOrEqual(t, first, 0) + require.Greater(t, second, first) +} + +func TestClientHostVariableValidationEmitsFixedCases(t *testing.T) { + root := codegen.RunDSL(t, testdata.SingleServerMultipleHostsWithVariablesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + code := renderExampleSections(t, CLIFiles(plannedRoot)[0]) + require.Contains(t, code, `switch *versionF`) + require.Contains(t, code, `case "v1", "v2":`) + require.NotContains(t, code, "for _, v := range []string") +} + +var mixedClientRoutingDSL = func() { + dsl.API("mixed client", func() { + dsl.Server("public", func() { + dsl.Services("catalog") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("catalog", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/catalog") + }) + }) + dsl.Method("watch", func() { + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index 7cece88c22..c271df5d3c 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -1,142 +1,172 @@ -// This file renders the shared example server entrypoint and resolves every -// generated service, application, and interceptor import through the frozen -// generation catalog. +// This file writes the shared example server from copied server data and the +// package names already chosen for this generation. package example import ( - "os" "path" - "path/filepath" + "sort" "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/expr" ) -// ServerFiles returns an example server main implementation for every server -// expression in the service design. -func ServerFiles(root *expr.RootExpr, services *service.ServicesData) []*codegen.File { +type ( + // serverMainData contains every import, declaration, and local name used by + // one generated server main. + serverMainData struct { + // Server contains the listener settings and handler arguments written to the main. + Server *serverMainServerData + // Services lists the generated declarations and local names for each service. + Services []*serverMainServiceData + // APIPkg is the package name used for starter service constructors. + APIPkg string + // InterPkg is the package name used for starter interceptor constructors. + InterPkg string + // HasServices reports whether the main initializes any service endpoints. + HasServices bool + // HasInterceptors reports whether the main initializes server interceptors. + HasInterceptors bool + } + + // serverMainServerData keeps the server settings and handler arguments used + // by one generated main. + serverMainServerData struct { + *Data + // Variables lists every URL variable with its server flag names. + Variables []*mainVariableData + // Hosts contains each host and the arguments passed to its handlers. + Hosts []*serverMainHostData + } + + // serverMainHostData keeps one host and the arguments passed to each of its + // generated handlers. + serverMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + // URIs lists the host URLs and the arguments passed to their handlers. + URIs []*URIData + } + + // serverMainServiceData contains exact generated declarations and the local + // names used to connect one service to its handlers. + serverMainServiceData struct { + // Name is the design service name used to connect handler arguments. + Name string + // PkgName is the package name used for the generated service package. + PkgName string + // ServiceVar is the local variable holding the starter service. + ServiceVar string + // EndpointsVar is the local variable holding the service endpoints. + EndpointsVar string + // InterceptorsVar is the local variable holding server interceptors. + InterceptorsVar string + // HasMethods reports whether the service needs a service and endpoint value. + HasMethods bool + // HasServerInterceptors reports whether NewEndpoints takes interceptors. + HasServerInterceptors bool + // ServiceDeclaration is the exact generated service interface. + ServiceDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact generated endpoint collection type. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact generated endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact generated interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the exact starter service constructor. + ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleInterceptorsConstructor is the exact starter server interceptor + // constructor. + ExampleInterceptorsConstructor *codegen.NameDeclaration + } +) + +// ServerFiles returns one example main program for each copied server. +func ServerFiles(root *Root, services *service.ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { - if m := exampleSvrMain(root, svr, services); m != nil { + for _, svr := range root.Servers { + if m := exampleSvrMain(svr, services); m != nil { fw = append(fw, m) } } return fw } -// exampleSvrMain returns the default main function for the given server -// expression. -func exampleSvrMain(root *expr.RootExpr, svr *expr.ServerExpr, services *service.ServicesData) *codegen.File { - svrdata := Servers.Get(svr, root) - mainPath := filepath.Join("cmd", svrdata.Dir, "main.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "os/signal"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "syscall"}, - {Path: "time"}, - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/log"}, - } +// RootPath returns the project import path that contains genpkg. +func RootPath(genpkg string) string { + return path.Dir(genpkg) +} + +// exampleSvrMain writes the main program for server. +func exampleSvrMain(server *Data, services *service.ServicesData) *codegen.File { + mainPath := server.serverMainPath + outputPackage := server.serverPackage.ImportPath() + specs := packageImports(server.serverPackage, serverMainFixedImports()) - // Iterate through services listed in the server expression. - svcData := make([]*service.Data, len(svr.Services)) + // Load the generated information for each service hosted by this server. + svcData := make([]*service.Data, len(server.Services)) hasInterceptors := false - for i, svc := range svr.Services { + serviceImports := make(map[string]struct{}, len(server.Services)) + servicePackages := make(map[string]string, len(server.Services)) + for i, svc := range server.Services { sd := services.Get(svc) svcData[i] = sd - serviceImport := services.ServiceImport(svc) - specs = append(specs, serviceImport) + serviceImport := services.ServiceImport(outputPackage, svc) + servicePackages[svc] = serviceImport.Name + if _, exists := serviceImports[serviceImport.Path]; !exists { + specs = append(specs, serviceImport) + serviceImports[serviceImport.Path] = struct{}{} + } hasInterceptors = hasInterceptors || len(sd.ServerInterceptors) > 0 } rootPath := path.Dir(services.GenPkg()) - apiImport := services.PackageImport(rootPath) + apiImport := services.PackageImport(outputPackage, rootPath) apiPkg := apiImport.Name specs = append(specs, apiImport) var interPkg string if hasInterceptors { - interceptorImport := services.PackageImport(rootPath + "/interceptors") + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") interPkg = interceptorImport.Name specs = append(specs, interceptorImport) } + main := planServerMain(server, svcData, servicePackages, apiPkg, interPkg) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "server-main-start", Source: exampleTemplates.Read(serverStartT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "server-main-logger", Source: exampleTemplates.Read(serverLoggerT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Server": svrdata, - }, + Data: main, }, { Name: "server-main-services", Source: exampleTemplates.Read(serverServicesT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interceptors", Source: exampleTemplates.Read(serverInterceptorsT), - Data: map[string]any{ - "APIPkg": apiPkg, - "InterPkg": interPkg, - "Services": svcData, - "HasInterceptors": hasInterceptors, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-endpoints", Source: exampleTemplates.Read(serverEndpointsT), - Data: map[string]any{ - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interrupts", Source: exampleTemplates.Read(serverInterruptsT), }, { Name: "server-main-handler", Source: exampleTemplates.Read(serverHandlerT), - Data: map[string]any{ - "Server": svrdata, - "Services": svcData, - }, + Data: main, FuncMap: map[string]any{ - "goify": codegen.Goify, "join": strings.Join, "toUpper": strings.ToUpper, - "hasJSONRPCEndpoints": func(svcData *service.Data) bool { - return hasJSONRPCEndpoints(root, svcData) - }, }, }, { @@ -148,23 +178,130 @@ func exampleSvrMain(root *expr.RootExpr, svr *expr.ServerExpr, services *service return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} } -// mustInitServices returns true if at least one of the services defines methods. -// It is used by the template to initialize service variables. -func mustInitServices(data []*service.Data) bool { - for _, svc := range data { - if len(svc.Methods) > 0 { - return true +// serverMainFixedImports lists packages whose names are written directly by +// the server main templates. +func serverMainFixedImports() []*codegen.ImportSpec { + return []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "os/signal"}, + {Path: "strings"}, + {Path: "sync"}, + {Path: "syscall"}, + {Path: "time"}, + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/log"}, + } +} + +// planServerMain chooses every local name once and connects each handler +// argument to the matching service variable. +func planServerMain( + server *Data, + services []*service.Data, + packages map[string]string, + apiPkg, interPkg string, +) *serverMainData { + scope := codegen.NewNameScope() + importNames := map[string]struct{}{apiPkg: {}} + if interPkg != "" { + importNames[interPkg] = struct{}{} + } + for _, packageName := range packages { + importNames[packageName] = struct{}{} + } + orderedImports := make([]string, 0, len(importNames)) + for name := range importNames { + orderedImports = append(orderedImports, name) + } + sort.Strings(orderedImports) + for _, name := range orderedImports { + scope.Unique(name) + } + for _, name := range []string{ + "addr", "c", "cancel", "context", "ctx", "debug", "err", "errc", + "flag", "fmt", "format", "h", "log", "net", "os", "signal", "strings", + "sync", "syscall", "time", "u", "url", "wg", + } { + scope.Unique(name) + } + byName := make(map[string]*serverMainServiceData, len(services)) + main := &serverMainData{ + APIPkg: apiPkg, + InterPkg: interPkg, + HasInterceptors: interPkg != "", + } + for _, serviceData := range services { + planned := &serverMainServiceData{ + Name: serviceData.Name, + PkgName: packages[serviceData.Name], + HasMethods: len(serviceData.Methods) > 0, + HasServerInterceptors: len(serviceData.ServerInterceptors) > 0, + ServiceDeclaration: serviceData.ServiceDeclaration, + EndpointsDeclaration: serviceData.EndpointsDeclaration, + NewEndpointsDeclaration: serviceData.NewEndpointsDeclaration, + ServerInterceptorsDeclaration: serviceData.ServerInterceptorsDeclaration, + ExampleConstructorDeclaration: serviceData.ExampleConstructorDeclaration, + ExampleInterceptorsConstructor: serviceData.ExampleServerInterceptorsConstructorDeclaration, + } + if planned.HasMethods { + base := codegen.Goify(serviceData.Name, false) + planned.ServiceVar = scope.Unique(base + "Svc") + planned.EndpointsVar = scope.Unique(base + "Endpoints") + if planned.HasServerInterceptors { + planned.InterceptorsVar = scope.Unique(base + "Interceptors") + } + main.HasServices = true } + main.Services = append(main.Services, planned) + byName[planned.Name] = planned } - return false + main.Server = planServerMainHandlers(server, byName) + return main } -// hasJSONRPCEndpoints returns true if the service has JSON-RPC endpoints. -func hasJSONRPCEndpoints(root *expr.RootExpr, data *service.Data) bool { - for _, svc := range root.API.JSONRPC.Services { - if svc.Name() == data.Name { - return true +// planServerMainHandlers copies each host and replaces service names with the +// local variables chosen for this main function. +func planServerMainHandlers(server *Data, services map[string]*serverMainServiceData) *serverMainServerData { + fixedFlags := []string{"host", "domain", "secure", "debug"} + for _, transport := range server.Transports { + fixedFlags = append(fixedFlags, string(transport.Type)+"-port") + } + variables := planMainVariables(server.Variables, fixedFlags) + planned := &serverMainServerData{ + Data: server, + Variables: variables.all, + Hosts: make([]*serverMainHostData, len(server.Hosts)), + } + for hostIndex, host := range server.Hosts { + plannedHost := &serverMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + URIs: make([]*URIData, len(host.URIs)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] + } + for uriIndex, uri := range host.URIs { + plannedURI := *uri + plannedURI.HandlerArgs = make([]HandlerArg, len(uri.HandlerArgs)) + for argIndex, arg := range uri.HandlerArgs { + plannedArg := arg + service := services[arg.Service] + if arg.Endpoint { + plannedArg.Variable = service.EndpointsVar + } else { + plannedArg.Variable = service.ServiceVar + } + plannedURI.HandlerArgs[argIndex] = plannedArg + } + plannedHost.URIs[uriIndex] = &plannedURI } + planned.Hosts[hostIndex] = plannedHost } - return false + return planned } diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 24815ad2e8..b347eb0b33 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -16,6 +16,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -63,17 +64,19 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - require.NoError(t, Plan(generation)) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() - fs := ServerFiles(root, services) + fs := ServerFiles(rootData, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -86,3 +89,131 @@ func TestExampleServerFiles(t *testing.T) { }) } } + +func TestGRPCOnlyServerLoggerDoesNotUseHTTPPort(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + files := ServerFiles(rootData, servicePlan.Services()) + require.Len(t, files, 1) + + var code bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&code)) + } + require.NotContains(t, code.String(), "httpPortF") + require.Contains(t, code.String(), `log.KV{K: "grpc-port", V: *grpcPortF}`) +} + +func TestServerMainUsesPlannedDeclarationsAndDistinctLocals(t *testing.T) { + root := codegen.RunDSL(t, collidingServerServicesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + services := servicePlan.Services() + files := ServerFiles(plannedRoot, services) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + + first := services.Get("foo-bar") + second := services.Get("foo bar") + outputPackage := plannedRoot.Servers[0].serverPackage.ImportPath() + serviceImport := services.ServiceImport(outputPackage, first.Name) + require.Contains(t, code, serviceImport.Name+` "`+serviceImport.Path+`"`) + require.Contains(t, code, serviceImport.Name+"."+first.ServiceDeclaration.Name()) + require.Contains(t, code, serviceImport.Name+"."+second.ServiceDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+first.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+second.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+first.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+second.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "fooBarSvc2") + require.Contains(t, code, "fooBarEndpoints2") + require.Contains(t, code, "fooBarInterceptors2") +} + +// TestServerMainUsesItsOutputPackageImportNames checks that clue/log keeps the +// name log in cmd/public/main.go. The application receives log2, and every +// generated call uses that selected name. +func TestServerMainUsesItsOutputPackageImportNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("log", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + + files := ServerFiles(plannedRoot, servicePlan.Services()) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.Contains(t, code, `log2 "example.local"`) + require.Contains(t, code, `"goa.design/clue/log"`) + require.Contains(t, code, "log2.NewStatus()") +} + +var collidingServerServicesDSL = func() { + trace := dsl.Interceptor("trace") + dsl.API("colliding server", func() { + dsl.Server("public", func() { + dsl.Services("foo-bar", "foo bar") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("foo-bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("first", func() { + dsl.HTTP(func() { + dsl.GET("/first") + }) + }) + }) + dsl.Service("foo bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("second", func() { + dsl.HTTP(func() { + dsl.GET("/second") + }) + }) + }) +} diff --git a/codegen/example/plan.go b/codegen/example/plan.go index 3f79bf0772..40c85ccb86 100644 --- a/codegen/example/plan.go +++ b/codegen/example/plan.go @@ -1,48 +1,133 @@ -// This file declares application packages imported by generated examples so -// their qualifiers are selected with the same catalog as generated services. +// This file copies the server information used by generated examples and +// records every package imported by their server and client programs. package example import ( "path" - "strings" + "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/codegen/service" ) -// Plan reserves the application and interceptor package aliases consumed by -// example server and client files before the generation catalog freezes. -func Plan(generation *codegen.Generation) error { - rootPath := path.Dir(generation.GenPkg()) - for _, root := range generation.Roots() { - design, ok := root.(*expr.RootExpr) - if !ok { - continue +type ( + // Plan stores copied API, service, server, and JSON-RPC names for one + // example generation. + Plan struct { + rootByService map[*service.Plan]*Root + } + + // Root stores the API name, service names, and server descriptions copied + // from one design. + Root struct { + // APIName is the design API name written in example help text. + APIName string + // Services lists every design service in declaration order. + Services []string + // Servers lists the copied server values in declaration order. + Servers []*Data + } +) + +// NewPlan copies the server information from each service plan and records the +// imports used by every generated server and command-line client. +func NewPlan(generation *codegen.Generation, services ...*service.Plan) (*Plan, error) { + plan := &Plan{rootByService: make(map[*service.Plan]*Root, len(services))} + for _, servicePlan := range services { + design := servicePlan.Root() + plannedRoot := &Root{ + APIName: design.API.Name, + Services: make([]string, len(design.Services)), + Servers: make([]*Data, len(design.API.Servers)), } - scope := codegen.NewNameScope() - for _, service := range design.Services { - scope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + for i, service := range design.Services { + plannedRoot.Services[i] = service.Name + } + for i, server := range design.API.Servers { + planned := buildServerData(server, design) + if err := planMainPackages(generation, servicePlan, planned); err != nil { + return nil, err + } + plannedRoot.Servers[i] = planned } - packageName := scope.Unique(strings.ToLower(codegen.Goify(design.API.Name, false)), "api") - if err := generation.DeclareImport(codegen.NewImport(packageName, rootPath)); err != nil { + plan.rootByService[servicePlan] = plannedRoot + } + return plan, nil +} + +// Root returns the copied design description for servicePlan. The second +// result is false when servicePlan was not used to create this plan. +func (p *Plan) Root(servicePlan *service.Plan) (*Root, bool) { + root, ok := p.rootByService[servicePlan] + return root, ok +} + +// planMainPackages records the imports used by one generated server and its +// command-line client before generation chooses their Go names. +func planMainPackages(generation *codegen.Generation, servicePlan *service.Plan, server *Data) error { + rootPath := RootPath(generation.GenPkg()) + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, filepath.Dir(server.serverMainPath)) + if err != nil { + return err + } + server.serverPackage = serverPackage + generated := make([]*codegen.ImportSpec, 0, len(server.Services)+2) + for _, serviceName := range server.Services { + serviceImport, _, err := servicePlan.ServicePackageImports(servicePlan.Root().Service(serviceName)) + if err != nil { return err } - if hasInterceptors(design) { - if err := generation.DeclareImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { - return err - } + generated = append(generated, serviceImport) + } + hasInterceptors := false + for _, serviceName := range server.Services { + hasInterceptors = hasInterceptors || len(servicePlan.Root().Service(serviceName).ServerInterceptors) > 0 + } + for _, spec := range servicePlan.ExampleImports() { + if spec.Path == path.Join(rootPath, "interceptors") && !hasInterceptors { + continue } + generated = append(generated, spec) } - return nil + if err := registerPackageImports(serverPackage, serverMainFixedImports(), generated); err != nil { + return err + } + + if server.DefaultTransport() == nil { + return nil + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, filepath.Dir(server.clientMainPath)) + if err != nil { + return err + } + server.clientPackage = clientPackage + return registerPackageImports(clientPackage, clientMainFixedImports(server), nil) } -// hasInterceptors reports whether generated examples import the application -// interceptor package for at least one service. -func hasInterceptors(root *expr.RootExpr) bool { - for _, service := range root.Services { - if len(service.ServerInterceptors) > 0 || len(service.ClientInterceptors) > 0 { - return true +// registerPackageImports records names written directly in templates first. +// Generated packages receive another name when a template already uses theirs. +func registerPackageImports(owner *codegen.GeneratedPackage, fixed, generated []*codegen.ImportSpec) error { + for _, spec := range fixed { + if err := owner.RequireImport(spec); err != nil { + return err } } - return false + for _, spec := range generated { + if err := owner.ReserveGeneratedImport(spec); err != nil { + return err + } + } + return nil +} + +// packageImports returns the import declarations chosen for one generated +// file after generation has made every package name final. +func packageImports(owner *codegen.GeneratedPackage, planned []*codegen.ImportSpec) []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(planned)) + for index, spec := range planned { + imports[index] = owner.Import(spec.Path) + } + return imports } diff --git a/codegen/example/plan_test.go b/codegen/example/plan_test.go new file mode 100644 index 0000000000..2ce27d0422 --- /dev/null +++ b/codegen/example/plan_test.go @@ -0,0 +1,283 @@ +// This file checks that each example generation keeps its copied server data +// separate from every other generation. +package example + +import ( + "bytes" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlansKeepSameNamedServersSeparate(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + httpPlan, err := NewPlan(httpGeneration, httpService) + require.NoError(t, err) + httpData, ok := httpPlan.Root(httpService) + require.True(t, ok) + httpServer := httpData.Servers[0] + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) + + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcPlan, err := NewPlan(grpcGeneration, grpcService) + require.NoError(t, err) + grpcData, ok := grpcPlan.Root(grpcService) + require.True(t, ok) + grpcServer := grpcData.Servers[0] + require.False(t, grpcServer.HasHTTP) + require.True(t, grpcServer.HasTransport(TransportGRPC)) + + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) +} + +func TestPlanKeepsHostVariableDefaultAndAllowedValues(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variables", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{region}.example.com") + dsl.Variable("region", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + variable := plannedRoot.Servers[0].Hosts[0].Variables[0] + require.Equal(t, "west", variable.DefaultValue) + require.Equal(t, []string{"west", "east"}, variable.Values) +} + +// TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag checks that a +// generated flag says what it configures instead of receiving a number. +func TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variable collision", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{host}.example.com") + dsl.Variable("host", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + plannedClient := planClientMainServer(plannedRoot.Servers[0]) + variable := plannedClient.Variables[0] + require.Equal(t, "url-host", variable.FlagName) + require.Equal(t, "urlHostF", variable.VarName) + require.Same(t, variable, plannedClient.Hosts[0].Variables[0]) + + plannedServer := planMainVariables(plannedRoot.Servers[0].Variables, []string{"host"}) + require.Equal(t, "url-host", plannedServer.all[0].FlagName) + require.Equal(t, "urlHostF", plannedServer.all[0].VarName) +} + +// TestPlanFindsRootForExactServicePlan checks that copied server data belongs +// only to the service plan from which it was built. +func TestPlanFindsRootForExactServicePlan(t *testing.T) { + firstRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + secondRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + firstGeneration, err := codegen.NewGeneration("example.local/first/gen", []eval.Root{firstRoot}) + require.NoError(t, err) + firstService, err := service.NewPlan(firstRoot, firstGeneration, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.NoError(t, err) + secondGeneration, err := codegen.NewGeneration("example.local/second/gen", []eval.Root{secondRoot}) + require.NoError(t, err) + secondService, err := service.NewPlan(secondRoot, secondGeneration, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(firstGeneration, firstService) + require.NoError(t, err) + + root, ok := plan.Root(firstService) + require.True(t, ok) + require.Equal(t, firstRoot.API.Name, root.APIName) + _, ok = plan.Root(secondService) + require.False(t, ok) +} + +func TestPlansBuildConcurrently(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + + start := make(chan struct{}) + var ( + plans [2]*Plan + errs [2]error + ready sync.WaitGroup + wait sync.WaitGroup + ) + build := func(index int, generation *codegen.Generation, servicePlan *service.Plan) { + defer wait.Done() + ready.Done() + <-start + plans[index], errs[index] = NewPlan(generation, servicePlan) + } + ready.Add(2) + wait.Add(2) + go build(0, httpGeneration, httpService) + go build(1, grpcGeneration, grpcService) + ready.Wait() + close(start) + wait.Wait() + + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + httpData, ok := plans[0].Root(httpService) + require.True(t, ok) + grpcData, ok := plans[1].Root(grpcService) + require.True(t, ok) + require.True(t, httpData.Servers[0].HasHTTP) + require.False(t, grpcData.Servers[0].HasHTTP) +} + +// TestPlanCopiesEveryServerValue checks that example output does not keep a +// path back to the design values it copied. +func TestPlanCopiesEveryServerValue(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + generation, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + copied, ok := plan.Root(servicePlan) + require.True(t, ok) + server := root.API.Servers[0] + require.False(t, pointsToDesignValue(reflect.ValueOf(copied), map[uintptr]struct{}{ + reflect.ValueOf(root).Pointer(): {}, + reflect.ValueOf(server).Pointer(): {}, + }, make(map[uintptr]struct{}))) + + files := CLIFiles(copied) + require.NotEmpty(t, files) + before := renderExampleSections(t, files[0]) + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + require.Equal(t, before, renderExampleSections(t, files[0])) +} + +// renderExampleSections writes the complete file without touching the file +// system so a test can compare the exact generated text. +func renderExampleSections(t *testing.T, file *codegen.File) string { + t.Helper() + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + return output.String() +} + +// pointsToDesignValue reports whether value contains one of the original +// design pointers. visited prevents loops in linked values. +func pointsToDesignValue(value reflect.Value, targets, visited map[uintptr]struct{}) bool { + if !value.IsValid() { + return false + } + switch value.Kind() { + case reflect.Interface: + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Pointer: + pointer := value.Pointer() + if _, ok := targets[pointer]; ok { + return true + } + if _, ok := visited[pointer]; ok { + return false + } + visited[pointer] = struct{}{} + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Map: + for iterator := value.MapRange(); iterator.Next(); { + if pointsToDesignValue(iterator.Key(), targets, visited) || + pointsToDesignValue(iterator.Value(), targets, visited) { + return true + } + } + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + if pointsToDesignValue(value.Index(index), targets, visited) { + return true + } + } + case reflect.Struct: + for index := 0; index < value.NumField(); index++ { + if pointsToDesignValue(value.Field(index), targets, visited) { + return true + } + } + } + return false +} diff --git a/codegen/example/public_api_test.go b/codegen/example/public_api_test.go new file mode 100644 index 0000000000..cf86b23650 --- /dev/null +++ b/codegen/example/public_api_test.go @@ -0,0 +1,15 @@ +// This file protects small released helpers that plugins and generator tools +// can use without rebuilding example-generation data. +package example + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRootPathReturnsProjectImportPath checks the released project path helper. +func TestRootPathReturnsProjectImportPath(t *testing.T) { + require.Equal(t, "goa.design/calc", RootPath("goa.design/calc/gen")) + require.Equal(t, ".", RootPath("gen")) +} diff --git a/codegen/example/server_data.go b/codegen/example/server_data.go index 830a637bfd..ea7e75e51c 100644 --- a/codegen/example/server_data.go +++ b/codegen/example/server_data.go @@ -1,7 +1,12 @@ +// This file copies server, host, URL, and transport values used to write +// example programs. package example import ( "fmt" + "path/filepath" + "slices" + "sort" "strconv" "strings" @@ -9,15 +14,7 @@ import ( "goa.design/goa/v3/expr" ) -// Servers holds the server data needed to generate the example service and -// client. It is computed from the Server expressions in the service design. -var Servers = make(ServersData) - type ( - // ServersData holds the server data from the service design indexed by - // server name. - ServersData map[string]*Data - // Data contains the data about a single server. Data struct { // Name is the server name. @@ -35,7 +32,29 @@ type ( // Transports is the list of transports defined in the server. Transports []*TransportData // Dir is the directory name for the generated client and server examples. - Dir string + Dir string + serverMainPath string + clientMainPath string + // serverPackage stores the import names selected for cmd/ files. + serverPackage *codegen.GeneratedPackage + // clientPackage stores the import names selected for cmd/-cli files. + clientPackage *codegen.GeneratedPackage + // HasHTTP reports whether the server exposes an ordinary HTTP service. + HasHTTP bool + // HasJSONRPC reports whether the server exposes a JSON-RPC service. + HasJSONRPC bool + writesEndpointResult bool + writesStreamResults bool + usageCommands []string + jsonRPCOnly []*jsonRPCServiceData + } + + // jsonRPCServiceData lists the JSON-RPC-only endpoints for one service. + jsonRPCServiceData struct { + // Service is the command-line service name. + Service string + // Endpoints lists command-line endpoint names. + Endpoints []string } // HostData contains the data about a single host in a server. @@ -44,8 +63,7 @@ type ( Name string // Description is the host description. Description string - // Schemes is the list of schemes supported by the host. It is computed - // from the URI expressions defined in the Host. + // Schemes lists the protocols used by the host URLs. // Possible values are http, https, grpc, grpcs. Schemes []string // URIs is the list of URLs defined in the host. @@ -60,19 +78,31 @@ type ( Name string // Description is the variable description. Description string - // VarName is the variable name used in generating flag variables. - VarName string - // DefaultValue is the default value for the variable. It is set to the - // default value defined in the variable attribute if exists, or else set - // to the first value in the enum expression. + // DefaultValue is the configured default, or the first allowed value when + // no default was configured. DefaultValue string - // Values is the list of allowed values for the variable. The values can - // only be primitives. We convert the primitives into string type so that - // we could use them to replace the URL variables in the example - // generation. + // Values lists the allowed values as text so the generated program can + // replace variables in a URL. Values []string } + // mainVariableData contains the exact command-line and Go names selected + // for one URL variable in one generated main program. + mainVariableData struct { + *VariableData + // FlagName is the exact command-line flag name. + FlagName string + // VarName is the exact Go variable name holding the flag value. + VarName string + } + + // mainVariables contains every planned URL variable and provides the same + // planned value to each host that uses it. + mainVariables struct { + all []*mainVariableData + byName map[string]*mainVariableData + } + // URIData contains the data about a URL. URIData struct { // URL is the underlying URL. @@ -84,17 +114,20 @@ type ( Port string // Transport is the transport type for the URL. Transport *TransportData - // HandlerArgs are the precomputed handler arguments for this URI used by - // the example server template. Each entry may contain an Endpoint and/or - // Service argument name to be passed to the handler in order. + // HandlerArgs lists the service values passed to the generated handler in + // call order. The generated main adds each local variable name later. HandlerArgs []HandlerArg } - // HandlerArg represents one argument slot to the handler call in the example - // server. Only one of Endpoint or Service may be set for each entry. + // HandlerArg identifies one service or endpoint value passed to a generated + // transport handler. HandlerArg struct { - Endpoint string - Service string + // Service is the design service name. + Service string + // Endpoint is true when the handler receives the service's endpoint collection. + Endpoint bool + // Variable is the local variable passed by the generated main. + Variable string } // TransportData contains the data about a transport (http or grpc). @@ -118,18 +151,7 @@ const ( TransportGRPC = "grpc" ) -// Get returns the server data for the given server expression. It builds the -// server data if the server name does not exist in the map. -func (d ServersData) Get(svr *expr.ServerExpr, root *expr.RootExpr) *Data { - if data, ok := d[svr.Name]; ok { - return data - } - sd := buildServerData(svr, root) - d[svr.Name] = sd - return sd -} - -// DefaultHost returns the first host defined in the server expression. +// DefaultHost returns the server's first host. func (s *Data) DefaultHost() *HostData { if len(s.Hosts) == 0 { return nil @@ -157,7 +179,7 @@ func (s *Data) DefaultTransport() *TransportData { return t } } - return nil // bug + return nil } // HasTransport checks if the server supports the given transport. @@ -170,6 +192,20 @@ func (s *Data) HasTransport(transport Transport) bool { return false } +// HandlerArgs returns the ordered service values accepted by the handler for +// transport. Every host using the same transport has the same arguments. It +// panics when the server does not use transport. +func (s *Data) HandlerArgs(transport Transport) []HandlerArg { + for _, host := range s.Hosts { + for _, uri := range host.URIs { + if uri.Transport.Type == transport { + return uri.HandlerArgs + } + } + } + panic(fmt.Sprintf("server %q does not use the %s transport", s.Name, transport)) +} + // DefaultURL returns the first URL defined for the given transport in a host. func (h *HostData) DefaultURL(transport Transport) string { for _, u := range h.URIs { @@ -180,7 +216,8 @@ func (h *HostData) DefaultURL(transport Transport) string { return "" } -// buildServerData builds the server data for the given server expression. +// buildServerData copies one server's service names, hosts, URL variables, +// transports, and handler arguments for the example templates. func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { hosts := make([]*HostData, 0, len(svr.Hosts)) for _, h := range svr.Hosts { @@ -192,7 +229,7 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { foundVars = make(map[string]struct{}) ) - // collect all the URL variables defined in host expressions + // List each URL variable once even when several hosts use it. for _, h := range hosts { for _, v := range h.Variables { if _, ok := foundVars[v.Name]; ok { @@ -207,6 +244,8 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { transports []*TransportData httpServices []string grpcServices []string + hasHTTP bool + hasJSONRPC bool foundTrans = make(map[Transport]struct{}) ) @@ -214,6 +253,7 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { _, seenHTTP := foundTrans[TransportHTTP] _, seenGRPC := foundTrans[TransportGRPC] if root.API.HTTP.Service(svc) != nil { + hasHTTP = true httpServices = append(httpServices, svc) if !seenHTTP { transports = append(transports, newHTTPTransport()) @@ -222,7 +262,8 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { seenHTTP = true } if root.API.JSONRPC.Service(svc) != nil { - // JSON-RPC implies HTTP transport; ensure HTTP transport exists + hasJSONRPC = true + // JSON-RPC runs over HTTP, so both use the same server listener. if !seenHTTP { transports = append(transports, newHTTPTransport()) foundTrans[TransportHTTP] = struct{}{} @@ -244,26 +285,151 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { transport.Services = grpcServices } } + dir := codegen.SnakeCase(codegen.Goify(svr.Name, true)) sd := &Data{ - Name: svr.Name, - Description: svr.Description, - Services: svr.Services, - Schemes: svr.Schemes(), - Hosts: hosts, - Variables: variables, - Transports: transports, - Dir: codegen.SnakeCase(codegen.Goify(svr.Name, true)), - } - // Precompute handler args for each URI of each host + Name: svr.Name, + Description: svr.Description, + Services: append([]string(nil), svr.Services...), + Schemes: svr.Schemes(), + Hosts: hosts, + Variables: variables, + Transports: transports, + Dir: dir, + serverMainPath: filepath.Join("cmd", dir, "main.go"), + clientMainPath: filepath.Join("cmd", dir+"-cli", "main.go"), + HasHTTP: hasHTTP, + HasJSONRPC: hasJSONRPC, + usageCommands: usageCommands(svr, root), + jsonRPCOnly: jsonRPCOnlyCommands(svr, root), + } + sd.writesEndpointResult, sd.writesStreamResults = clientResultWriters(svr, root) + // Keep the handler argument order while the complete design is still available. for _, h := range sd.Hosts { for _, u := range h.URIs { - u.HandlerArgs = computeHandlerArgsForURI(u, sd, root) + u.HandlerArgs = planHandlerArgsForURI(u, sd, root) } } return sd } -// buildHostData builds the host data for the given host expression. +// clientResultWriters reports which result helpers the server's example +// client calls. Commands that need streamed input are rejected before they +// invoke an endpoint and therefore need neither helper. +func clientResultWriters(server *expr.ServerExpr, root *expr.RootExpr) (endpoint, stream bool) { + addMethod := func(method *expr.MethodExpr, mixedUsesEndpoint bool) { + if method.IsPayloadStreaming() { + return + } + if method.IsResultStreaming() && !(mixedUsesEndpoint && method.HasMixedResults()) { + stream = true + return + } + endpoint = true + } + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, true) + } + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + if service := root.API.GRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.GRPCEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + } + return +} + +// usageCommands returns the complete help list for one server. Each transport +// contributes the commands accepted by its generated client. +func usageCommands(server *expr.ServerExpr, root *expr.RootExpr) []string { + var commands []string + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceName, httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceName, httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.GRPC.Service(serviceName); service != nil { + endpoints := make([]string, len(service.GRPCEndpoints)) + for i, endpoint := range service.GRPCEndpoints { + endpoints[i] = codegen.KebabCase(endpoint.Name()) + } + commands = appendUsageCommand(commands, serviceName, endpoints) + } + } + sort.Strings(commands) + return slices.Compact(commands) +} + +// jsonRPCOnlyCommands returns the service and endpoint pairs handled only by +// the JSON-RPC client. +func jsonRPCOnlyCommands(server *expr.ServerExpr, root *expr.RootExpr) []*jsonRPCServiceData { + var services []*jsonRPCServiceData + for _, serviceName := range server.Services { + jsonRPC := root.API.JSONRPC.Service(serviceName) + if jsonRPC == nil { + continue + } + httpMethods := make(map[string]struct{}) + if httpService := root.API.HTTP.Service(serviceName); httpService != nil { + for _, endpoint := range httpService.HTTPEndpoints { + httpMethods[endpoint.MethodExpr.Name] = struct{}{} + } + } + var endpoints []string + for _, endpoint := range jsonRPC.HTTPEndpoints { + if _, alsoHTTP := httpMethods[endpoint.MethodExpr.Name]; !alsoHTTP { + endpoints = append(endpoints, codegen.KebabCase(endpoint.Name())) + } + } + if len(endpoints) > 0 { + services = append(services, &jsonRPCServiceData{ + Service: codegen.KebabCase(serviceName), + Endpoints: endpoints, + }) + } + } + return services +} + +// httpEndpointNames returns the command-line names for endpoints in design +// order. +func httpEndpointNames(endpoints []*expr.HTTPEndpointExpr) []string { + names := make([]string, len(endpoints)) + for i, endpoint := range endpoints { + names[i] = codegen.KebabCase(endpoint.Name()) + } + return names +} + +// appendUsageCommand adds one client's help entry when it has endpoints. +func appendUsageCommand(commands []string, serviceName string, endpoints []string) []string { + if len(endpoints) == 0 { + return commands + } + var left, right string + if len(endpoints) > 1 { + left, right = "(", ")" + } + return append(commands, fmt.Sprintf( + "%s %s%s%s", + codegen.KebabCase(serviceName), + left, + strings.Join(endpoints, "|"), + right, + )) +} + +// buildHostData copies one host's name, description, URLs, and URL variables +// for the example templates. func buildHostData(host *expr.HostExpr) *HostData { uris := make([]*URIData, len(host.URIs)) for i, uv := range host.URIs { @@ -315,16 +481,15 @@ func buildHostData(host *expr.HostExpr) *HostData { for i, v := range *vars { def := v.Attribute.DefaultValue var values []string + if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { + values = convertToString(v.Attribute.Validation.Values...) + } if def == nil { def = v.Attribute.Validation.Values[0] - // DSL ensures v.Attribute has either a - // default value or an enum validation - values = convertToString(v.Attribute.Validation.Values...) } variables[i] = &VariableData{ Name: v.Name, Description: v.Attribute.Description, - VarName: codegen.Goify(v.Name, false), DefaultValue: convertToString(def)[0], Values: values, } @@ -339,6 +504,38 @@ func buildHostData(host *expr.HostExpr) *HostData { } } +// planMainVariables chooses command-line and Go names that do not collide +// with the flags already emitted by one main program. +func planMainVariables(variables []*VariableData, fixedFlags []string) *mainVariables { + flagScope := codegen.NewNameScope() + localScope := codegen.NewNameScope() + fixed := make(map[string]struct{}, len(fixedFlags)) + for _, flagName := range fixedFlags { + fixed[flagName] = struct{}{} + flagScope.Unique(flagName) + localScope.Unique(codegen.Goify(flagName, false) + "F") + } + planned := &mainVariables{ + all: make([]*mainVariableData, len(variables)), + byName: make(map[string]*mainVariableData, len(variables)), + } + for index, variable := range variables { + preferred := variable.Name + if _, conflicts := fixed[preferred]; conflicts { + preferred = "url-" + preferred + } + flagName := flagScope.Unique(preferred) + value := &mainVariableData{ + VariableData: variable, + FlagName: flagName, + VarName: localScope.Unique(codegen.Goify(flagName, false) + "F"), + } + planned.all[index] = value + planned.byName[variable.Name] = value + } + return planned +} + // convertToString converts primitive type to a string. func convertToString(vals ...any) []string { str := make([]string, len(vals)) @@ -379,12 +576,10 @@ func newGRPCTransport() *TransportData { return &TransportData{Type: TransportGRPC, Name: "gRPC"} } -// computeHandlerArgsForURI returns the ordered handler arguments for the given URI. -// For HTTP URIs that serve both HTTP and JSON-RPC services, the order is: -// - HTTP service endpoints (for services in the HTTP transport list) -// - JSON-RPC service interfaces (in JSONRPC.Services order) -// - JSON-RPC service endpoints (for services not already added as HTTP endpoints) -func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { +// planHandlerArgsForURI lists the services passed to one generated handler. +// HTTP endpoints come first, followed by JSON-RPC services and any remaining +// JSON-RPC endpoints. +func planHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { capHint := len(server.Services) grpcSvcNames := make([]string, 0, capHint) for _, t := range server.Transports { @@ -395,14 +590,15 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ if uri.Transport.Type == TransportGRPC { out := make([]HandlerArg, 0, len(grpcSvcNames)) for _, name := range grpcSvcNames { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) } return out } - var jsonrpcServices []*expr.HTTPServiceExpr - if root.API != nil && root.API.JSONRPC != nil { - jsonrpcServices = root.API.JSONRPC.Services + jsonrpcServices := root.API.JSONRPC.Services + hostedServices := make(map[string]struct{}, len(server.Services)) + for _, name := range server.Services { + hostedServices[name] = struct{}{} } httpSvcSet := make(map[string]struct{}, len(server.Services)) @@ -430,53 +626,32 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ return false } - // Build set of services that are in $.Services for the template. - // The template data depends on whether there are HTTP services: - // - If there are HTTP services: $.Services = HTTP services only - // - If there are NO HTTP services: $.Services = all JSON-RPC services + // The HTTP helper receives ordinary HTTP endpoints first. servicesInTemplate := make(map[string]struct{}) - hasHTTPServices := false - if root.API != nil && root.API.HTTP != nil && len(root.API.HTTP.Services) > 0 { - hasHTTPServices = true - for _, hs := range root.API.HTTP.Services { - if hs.ServiceExpr != nil { - servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} - } - } - } - // If no HTTP services, JSON-RPC services populate $.Services - if !hasHTTPServices && root.API != nil && root.API.JSONRPC != nil { - for _, js := range root.API.JSONRPC.Services { - if js.ServiceExpr != nil { - servicesInTemplate[js.ServiceExpr.Name] = struct{}{} - } - } + for _, hs := range root.API.HTTP.Services { + servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} } addedEndpoints := make(map[string]bool, len(server.Services)) - // Step 1: Add endpoint pointers for services in server.Services that are also in $.Services. - // This matches the template's first loop: {{ range $.Services }}{{ if .Service.Methods }} - // where $.Services includes both HTTP and JSON-RPC services. + // Add endpoint variables for the services passed first. for _, svcName := range server.Services { if _, inTemplate := servicesInTemplate[svcName]; inTemplate && serviceHasHandlers(svcName) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(svcName, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: svcName, Endpoint: true}) addedEndpoints[svcName] = true } } - // Step 2: For each JSON-RPC service, add service interface, then endpoint (if not HTTP). - // This matches the template's second loop: {{ range $.JSONRPCServices }} - // where each iteration adds the service, checks if it's in $.Services, and conditionally - // adds the endpoint - all in the same iteration (not separate loops). + // Add each JSON-RPC service variable followed by its endpoint variable when + // that endpoint was not already added above. for _, jsvc := range jsonrpcServices { name := jsvc.ServiceExpr.Name - // Add service interface - out = append(out, HandlerArg{Service: codegen.Goify(name, false) + "Svc"}) - // Add endpoint if this service doesn't have HTTP transport - // (i.e., wasn't added in Step 1) + if _, hosted := hostedServices[name]; !hosted { + continue + } + out = append(out, HandlerArg{Service: name}) if !addedEndpoints[name] && serviceHasHandlers(name) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) addedEndpoints[name] = true } } diff --git a/codegen/example/server_data_test.go b/codegen/example/server_data_test.go index faf015c837..21861ff28c 100644 --- a/codegen/example/server_data_test.go +++ b/codegen/example/server_data_test.go @@ -3,6 +3,8 @@ package example import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -24,6 +26,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: mcpMethod}}, } + jsonrpcUnhosted := &expr.HTTPServiceExpr{ + ServiceExpr: &expr.ServiceExpr{ + Name: "unhosted", + Methods: []*expr.MethodExpr{{Name: "Ignore"}}, + }, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: &expr.MethodExpr{Name: "Ignore"}}}, + } root := &expr.RootExpr{ API: &expr.APIExpr{ HTTP: &expr.HTTPExpr{ @@ -31,13 +40,14 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, JSONRPC: &expr.JSONRPCExpr{ HTTPExpr: expr.HTTPExpr{ - Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant}, + Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant, jsonrpcUnhosted}, }, }, }, Services: []*expr.ServiceExpr{ {Name: "orchestrator", Methods: []*expr.MethodExpr{method}}, {Name: "mcp_assistant", Methods: []*expr.MethodExpr{mcpMethod}}, + {Name: "unhosted", Methods: []*expr.MethodExpr{{Name: "Ignore"}}}, }, } server := &Data{ @@ -48,13 +58,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} - args := computeHandlerArgsForURI(uri, server, root) + args := planHandlerArgsForURI(uri, server, root) want := []HandlerArg{ - {Endpoint: "orchestratorEndpoints"}, - {Service: "orchestratorSvc"}, - {Service: "mcpAssistantSvc"}, - {Endpoint: "mcpAssistantEndpoints"}, + {Service: "orchestrator", Endpoint: true}, + {Service: "orchestrator"}, + {Service: "mcp_assistant"}, + {Service: "mcp_assistant", Endpoint: true}, } if len(args) != len(want) { t.Fatalf("expected %d handler args, got %d (%v)", len(want), len(args), args) @@ -65,3 +75,42 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } } } + +// TestPlanHandlerArgsForJSONRPCOnlyServer checks that the generated main and +// HTTP helper can use the same service-by-service argument order. +func TestPlanHandlerArgsForJSONRPCOnlyServer(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "First"} + secondMethod := &expr.MethodExpr{Name: "Second"} + first := &expr.ServiceExpr{Name: "first", Methods: []*expr.MethodExpr{firstMethod}} + second := &expr.ServiceExpr{Name: "second", Methods: []*expr.MethodExpr{secondMethod}} + root := &expr.RootExpr{ + API: &expr.APIExpr{ + HTTP: &expr.HTTPExpr{}, + JSONRPC: &expr.JSONRPCExpr{HTTPExpr: expr.HTTPExpr{Services: []*expr.HTTPServiceExpr{ + { + ServiceExpr: first, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: firstMethod}}, + }, + { + ServiceExpr: second, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: secondMethod}}, + }, + }}}, + }, + Services: []*expr.ServiceExpr{first, second}, + } + server := &Data{ + Services: []string{"first", "second"}, + Transports: []*TransportData{{ + Type: TransportHTTP, + }}, + } + uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} + + require.Equal(t, []HandlerArg{ + {Service: "first"}, + {Service: "first", Endpoint: true}, + {Service: "second"}, + {Service: "second", Endpoint: true}, + }, planHandlerArgsForURI(uri, server, root)) +} diff --git a/codegen/example/templates/client_end.go.tpl b/codegen/example/templates/client_end.go.tpl index 21b99bc20b..6e78d3386a 100644 --- a/codegen/example/templates/client_end.go.tpl +++ b/codegen/example/templates/client_end.go.tpl @@ -1,13 +1,48 @@ +} - data, err := endpoint(context.Background(), payload) +{{- if .WritesEndpointResult }} +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} +{{- end }} + +{{- if .WritesStreamResults }} +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} +{{- end }} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +{{- if or .WritesEndpointResult .WritesStreamResults }} +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) } + return nil } +{{- end }} diff --git a/codegen/example/templates/client_endpoint_init.go.tpl b/codegen/example/templates/client_endpoint_init.go.tpl index 02353b8c76..b1ef3de424 100644 --- a/codegen/example/templates/client_endpoint_init.go.tpl +++ b/codegen/example/templates/client_endpoint_init.go.tpl @@ -1,7 +1,5 @@ var ( - endpoint goa.Endpoint - payload any err error ) { @@ -11,18 +9,29 @@ {{- if and (eq $t.Type "http") $.HasJSONRPC }} {{- if $.HasHTTP }} if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + {{- range $.JSONRPCOnly }} + case {{ printf "%q" .Service }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + case {{ printf "%q" . }}: + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } {{- else }} - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- else }} - endpoint, payload, err = do{{ toUpper $t.Name }}(scheme, host, timeout, debug) + err = do{{ toUpper $t.Name }}(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/client_start.go.tpl b/codegen/example/templates/client_start.go.tpl index b668793bdc..2054095416 100644 --- a/codegen/example/templates/client_start.go.tpl +++ b/codegen/example/templates/client_start.go.tpl @@ -4,7 +4,7 @@ func main() { hostF = flag.String("host", {{ printf "%q" .Server.DefaultHost.Name }}, "Server host (valid values: {{ (join .Server.AvailableHosts ", ") }})") addrF = flag.String("url", "", "URL to service host") {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) {{- end }} {{- if and .HasJSONRPC .HasHTTP }} jsonrpcF = flag.Bool("jsonrpc", false, "Force JSON-RPC transport") diff --git a/codegen/example/templates/client_usage.go.tpl b/codegen/example/templates/client_usage.go.tpl index e5ee17de74..3151b319c3 100644 --- a/codegen/example/templates/client_usage.go.tpl +++ b/codegen/example/templates/client_usage.go.tpl @@ -1,21 +1,15 @@ func usage() { - var usageCommands []string -{{- range .Server.Transports }} - {{- if and (eq .Type "http") $.HasHTTP }} - usageCommands = append(usageCommands, {{ .Type }}UsageCommands()...) + usageCommands := []string{ + {{- range .UsageCommands }} + {{ printf "%q" . }}, {{- end }} -{{- end }} -{{- if .HasJSONRPC }} - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) -{{- end }} - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + } fmt.Fprintf(os.Stderr, `%s is a command line client for the {{ .APIName }} API. Usage: - %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .Name }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .FlagName }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] -host HOST: server host ({{ .Server.DefaultHost.Name }}). valid values: {{ (join .Server.AvailableHosts ", ") }} -url URL: specify service URL overriding host URL (http://localhost:8080) @@ -25,7 +19,7 @@ Usage: -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) {{- range .Server.Variables }} - -{{ .Name }}: {{ .Description }} ({{ .DefaultValue }}) + -{{ .FlagName }}: {{ .Description }} ({{ .DefaultValue }}) {{- end }} Commands: diff --git a/codegen/example/templates/client_var_init.go.tpl b/codegen/example/templates/client_var_init.go.tpl index 65f0430c95..dae738f250 100644 --- a/codegen/example/templates/client_var_init.go.tpl +++ b/codegen/example/templates/client_var_init.go.tpl @@ -10,24 +10,17 @@ var ( switch *hostF { {{- range $h := .Server.Hosts }} case {{ printf "%q" $h.Name }}: - addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} + addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}) os.Exit(1) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/server_endpoints.go.tpl b/codegen/example/templates/server_endpoints.go.tpl index e0587e27ee..8a47b2a743 100644 --- a/codegen/example/templates/server_endpoints.go.tpl +++ b/codegen/example/templates/server_endpoints.go.tpl @@ -1,19 +1,19 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Wrap the services in endpoints that can be invoked from other services potentially running in different processes." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints *{{ .PkgName }}.Endpoints + {{- if .HasMethods }} + {{ .EndpointsVar }} *{{ .PkgName }}.{{ .EndpointsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints = {{ .PkgName }}.NewEndpoints({{ .VarName }}Svc{{ if .ServerInterceptors }}, {{ .VarName }}Interceptors{{ end }}) - {{ .VarName }}Endpoints.Use(debug.LogPayloads()) - {{ .VarName }}Endpoints.Use(log.Endpoint) + {{- if .HasMethods }} + {{ .EndpointsVar }} = {{ .PkgName }}.{{ .NewEndpointsDeclaration.Name }}({{ .ServiceVar }}{{ if .HasServerInterceptors }}, {{ .InterceptorsVar }}{{ end }}) + {{ .EndpointsVar }}.Use(debug.LogPayloads()) + {{ .EndpointsVar }}.Use(log.Endpoint) {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_handler.go.tpl b/codegen/example/templates/server_handler.go.tpl index 13d2f03c09..93427fff80 100644 --- a/codegen/example/templates/server_handler.go.tpl +++ b/codegen/example/templates/server_handler.go.tpl @@ -10,20 +10,13 @@ addr := {{ printf "%q" $u.URL }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F)) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }})) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} u, err := url.Parse(addr) if err != nil { @@ -44,7 +37,7 @@ } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "{{ $u.Port }}") } - handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}{{- if .Endpoint }}, {{ .Endpoint }}{{- end }}{{- if .Service }}, {{ .Service }}{{- end }}{{- end }}, &wg, errc, *dbgF) + handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}, {{ .Variable }}{{- end }}, &wg, errc, *dbgF) } {{- end }} {{ end }} diff --git a/codegen/example/templates/server_interceptors.go.tpl b/codegen/example/templates/server_interceptors.go.tpl index cdb973dcce..d1d23b0c4f 100644 --- a/codegen/example/templates/server_interceptors.go.tpl +++ b/codegen/example/templates/server_interceptors.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{- if .HasInterceptors }} {{ comment "Initialize the interceptors." }} var ( {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors {{ .PkgName }}.ServerInterceptors + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} {{ .PkgName }}.{{ .ServerInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors = {{ $.InterPkg }}.New{{ .StructName }}ServerInterceptors() + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} = {{ $.InterPkg }}.{{ .ExampleInterceptorsConstructor.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_logger.go.tpl b/codegen/example/templates/server_logger.go.tpl index 642c082687..89569a2665 100644 --- a/codegen/example/templates/server_logger.go.tpl +++ b/codegen/example/templates/server_logger.go.tpl @@ -10,6 +10,6 @@ ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } -{{- if .Server.Transports }} - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) +{{- range .Server.Transports }} + log.Print(ctx, log.KV{K: "{{ .Type }}-port", V: *{{ .Type }}PortF}) {{- end }} diff --git a/codegen/example/templates/server_services.go.tpl b/codegen/example/templates/server_services.go.tpl index 2de5109753..231a040aa9 100644 --- a/codegen/example/templates/server_services.go.tpl +++ b/codegen/example/templates/server_services.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Initialize the services." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc {{ .PkgName }}.Service + {{- if .HasMethods }} + {{ .ServiceVar }} {{ .PkgName }}.{{ .ServiceDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc = {{ $.APIPkg }}.New{{ .StructName }}() + {{- if .HasMethods }} + {{ .ServiceVar }} = {{ $.APIPkg }}.{{ .ExampleConstructorDeclaration.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_start.go.tpl b/codegen/example/templates/server_start.go.tpl index 0e722ae8eb..cf246267e9 100644 --- a/codegen/example/templates/server_start.go.tpl +++ b/codegen/example/templates/server_start.go.tpl @@ -8,7 +8,7 @@ func main() { {{ .Type }}PortF = flag.String("{{ .Type }}-port", "", "{{ .Name }} port (overrides host {{ .Name }} port specified in service design)") {{- end }} {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") {{- end }} secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") dbgF = flag.Bool("debug", false, "Log request and response bodies") diff --git a/codegen/example/testdata/client-input-stream.golden b/codegen/example/testdata/client-input-stream.golden new file mode 100644 index 0000000000..8f1dc1f2c8 --- /dev/null +++ b/codegen/example/testdata/client-input-stream.golden @@ -0,0 +1,101 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +func usage() { + usageCommands := []string{ + "events upload", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-mixed-results.golden b/codegen/example/testdata/client-mixed-results.golden new file mode 100644 index 0000000000..43c77a70a5 --- /dev/null +++ b/codegen/example/testdata/client-mixed-results.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events create", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-no-server.golden b/codegen/example/testdata/client-no-server.golden index 4bed82edee..3b8e5060e3 100644 --- a/codegen/example/testdata/client-no-server.golden +++ b/codegen/example/testdata/client-no-server.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. Usage: diff --git a/codegen/example/testdata/client-server-stream.golden b/codegen/example/testdata/client-server-stream.golden new file mode 100644 index 0000000000..531f60a75b --- /dev/null +++ b/codegen/example/testdata/client-server-stream.golden @@ -0,0 +1,130 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8080" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } + } +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events watch", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden index bb0df699fc..6fcdcb5a35 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden @@ -24,16 +24,9 @@ func main() { switch *hostF { case "dev": addr = "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: fmt.Fprintf(os.Stderr, "invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF) os.Exit(1) } @@ -66,14 +59,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -88,23 +79,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHostsWithVariables API. Usage: diff --git a/codegen/example/testdata/client-single-server-multiple-hosts.golden b/codegen/example/testdata/client-single-server-multiple-hosts.golden index efb2085ef0..e02e11766e 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts.golden @@ -47,14 +47,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHosts API. Usage: diff --git a/codegen/example/testdata/client-single-server-single-host-with-variables.golden b/codegen/example/testdata/client-single-server-single-host-with-variables.golden index 4fd6755bc6..00d2c111c3 100644 --- a/codegen/example/testdata/client-single-server-single-host-with-variables.golden +++ b/codegen/example/testdata/client-single-server-single-host-with-variables.golden @@ -63,14 +63,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -85,23 +83,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHostWithVariables API. Usage: diff --git a/codegen/example/testdata/client-single-server-single-host.golden b/codegen/example/testdata/client-single-server-single-host.golden index fadb54b2b9..88d83a97e1 100644 --- a/codegen/example/testdata/client-single-server-single-host.golden +++ b/codegen/example/testdata/client-single-server-single-host.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http|https)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHost API. Usage: diff --git a/codegen/example/testdata/server-no-server.golden b/codegen/example/testdata/server-no-server.golden index 8d6ed8fba7..55490aa714 100644 --- a/codegen/example/testdata/server-no-server.golden +++ b/codegen/example/testdata/server-no-server.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-same-api-service-name.golden b/codegen/example/testdata/server-same-api-service-name.golden index 30462fadc0..db02a0b490 100644 --- a/codegen/example/testdata/server-same-api-service-name.golden +++ b/codegen/example/testdata/server-same-api-service-name.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-sercice-for-only-grpc.golden b/codegen/example/testdata/server-sercice-for-only-grpc.golden index 37d920f66a..1e62a57815 100644 --- a/codegen/example/testdata/server-sercice-for-only-grpc.golden +++ b/codegen/example/testdata/server-sercice-for-only-grpc.golden @@ -20,7 +20,7 @@ func main() { ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-multiple-services.golden b/codegen/example/testdata/server-server-hosting-multiple-services.golden index 50ff33ea91..ac3f603fae 100644 --- a/codegen/example/testdata/server-server-hosting-multiple-services.golden +++ b/codegen/example/testdata/server-server-hosting-multiple-services.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-service-subset.golden b/codegen/example/testdata/server-server-hosting-service-subset.golden index ee3a68a010..78071cfbcd 100644 --- a/codegen/example/testdata/server-server-hosting-service-subset.golden +++ b/codegen/example/testdata/server-server-hosting-service-subset.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden index 9d052dacb3..2639ec0d88 100644 --- a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden +++ b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-name-with-spaces.golden b/codegen/example/testdata/server-service-name-with-spaces.golden index cd3eeee20e..384db71363 100644 --- a/codegen/example/testdata/server-service-name-with-spaces.golden +++ b/codegen/example/testdata/server-service-name-with-spaces.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden index dd0fea4a7d..d10843b2e1 100644 --- a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden @@ -2,14 +2,14 @@ func main() { // Define command line flags, add any other flag required to configure the // service. var ( - hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") - domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") - httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") - versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") - domainF = flag.String("domain", "test", "Domain") - portF = flag.String("port", "8080", "Port") - secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") - dbgF = flag.Bool("debug", false, "Log request and response bodies") + hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") + versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") + urlDomainF = flag.String("url-domain", "test", "Domain") + portF = flag.String("port", "8080", "Port") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") ) flag.Parse() @@ -64,16 +64,9 @@ func main() { case "dev": { addr := "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: log.Fatal(ctx, fmt.Errorf("invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF)) } addr = strings.ReplaceAll(addr, "{version}", *versionF) @@ -102,7 +95,7 @@ func main() { case "stage": { addr := "https://example-{domain}:{port}" - addr = strings.ReplaceAll(addr, "{domain}", *domainF) + addr = strings.ReplaceAll(addr, "{domain}", *urlDomainF) addr = strings.ReplaceAll(addr, "{port}", *portF) u, err := url.Parse(addr) if err != nil { diff --git a/codegen/example/testdata/server-single-server-single-host.golden b/codegen/example/testdata/server-single-server-single-host.golden index 57c0d3f245..f7847d537a 100644 --- a/codegen/example/testdata/server-single-server-single-host.golden +++ b/codegen/example/testdata/server-single-server-single-host.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/funcs.go b/codegen/funcs.go index e42762ecbe..cf6f334815 100644 --- a/codegen/funcs.go +++ b/codegen/funcs.go @@ -191,7 +191,6 @@ func camelCaseUncached(name string, firstUpper, acronym bool) string { // advance to next word w = i } - return string(runes) } diff --git a/codegen/funcs_test.go b/codegen/funcs_test.go index 1fd4152d79..a58aac5111 100644 --- a/codegen/funcs_test.go +++ b/codegen/funcs_test.go @@ -72,6 +72,56 @@ func TestCamelCase(t *testing.T) { } } +func TestProtobufNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "Val"}, + {name: "leading digits", source: "123_message", want: "_123Message"}, + {name: "acronym", source: "api_message", want: "APIMessage"}, + {name: "mixed Unicode", source: "café_message", want: "CafMessage"}, + {name: "only Unicode", source: "東京", want: "Val"}, + {name: "field keyword is a legal declaration", source: "string", want: "String"}, + {name: "invalid characters", source: "---", want: "Val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + +func TestProtobufFieldNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "val"}, + {name: "leading digits", source: "123Field", want: "_123_field"}, + {name: "acronym", source: "HTTPServer", want: "http_server"}, + {name: "mixed Unicode", source: "caféField", want: "caf_field"}, + {name: "only Unicode", source: "東京", want: "val"}, + {name: "reserved word", source: "string", want: "string_"}, + {name: "invalid characters", source: "---", want: "val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufFieldName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + func TestKebabCase(t *testing.T) { cases := map[string]struct { str string diff --git a/codegen/generated_types.go b/codegen/generated_types.go index 8ba20c2c16..9722e88d06 100644 --- a/codegen/generated_types.go +++ b/codegen/generated_types.go @@ -1,6 +1,6 @@ -// This file defines the declaration catalog owned by one generated Go -// package. Planning reserves every package-level name here before generators -// use the frozen records to render declarations and references. +// This file chooses every package-level Go name before source files are +// written. Generators record the names they need, then use the chosen names +// when writing code. package codegen import ( @@ -13,8 +13,8 @@ import ( ) type ( - // GeneratedPackage owns declarations and their shared naming scope for one - // generated Go package. + // GeneratedPackage stores declarations, final names, and the output location + // for one generated Go package. GeneratedPackage struct { claim string path string @@ -22,6 +22,9 @@ type ( scope *NameScope names []*NameDeclaration exactNames map[string]*NameDeclaration + nameBindings map[string]*NameDeclaration + importPlan *importAliasPlan + imports map[string]importAliasBinding userTypes map[expr.UserType]*TypeDeclaration typeBindings map[expr.UserType]*TypeDeclaration derivedTypes map[DerivedTypeID]*TypeDeclaration @@ -29,79 +32,82 @@ type ( frozen bool } - // DerivedTypeID identifies a generated declaration by the exact source - // declaration and the closed transformation that produces it. + // DerivedTypeID identifies a type Goa generated from a source type, such as a + // view, method payload, or method result. DerivedTypeID struct { origin expr.UserType kind derivedTypeKind } - // MethodTypeIdentity identifies one closed normalized service method role. - // It supplies both the semantic expression UID and the compiler declaration - // kind used for the wrapper created from a raw object. + // MethodTypeIdentity records the API, Go wrapper name, whether it holds a + // payload or result, the key used to repeat its examples, and the source type + // for one service method. MethodTypeIdentity struct { + api string name string kind derivedTypeKind exampleIdentity expr.ExampleIdentity origin expr.UserType } - // TypeDeclaration records the canonical name and package path of one - // generated type declaration. + // TypeDeclaration stores the final name and package path of one generated Go + // type. TypeDeclaration struct { declaration *NameDeclaration } - // UnionDeclaration records the canonical union and discriminator names in - // the package that emits them. + // UnionDeclaration stores the generated union type name and its kind type + // name. UnionDeclaration struct { declaration *NameDeclaration kindDeclaration *NameDeclaration } - // UnionBranchDeclaration records the package-level declarations emitted for - // one union branch. + // UnionBranchDeclaration stores the constant, constructor, and optional type + // generated for one union branch. UnionBranchDeclaration struct { kindDeclaration *NameDeclaration constructorDeclaration *NameDeclaration branchType *TypeDeclaration } - // unionDeclaration retains the expression needed to allocate the public - // union name deterministically when the generation freezes. + // unionDeclaration stores a union expression and the names generated for the + // union and each branch. unionDeclaration struct { union *expr.Union declaration *UnionDeclaration branches map[unionBranchID]*UnionBranchDeclaration } - // unionBranchID identifies one generated branch alias within its union - // declaration family. + // unionBranchID selects a generated union branch by its design name. unionBranchID struct { name string } - // derivedTypeKind distinguishes the closed declaration families rebuilt - // independently during planning and rendering. + // derivedTypeKind records whether Goa is generating a view, viewed result, + // method payload, or method result. derivedTypeKind uint - // derivedTypeOrder contains only stable semantic values so view declaration - // suffixes never depend on expression pointer addresses or traversal order. + // derivedTypeOrder sorts generated view types from copied strings and numbers + // so pointer addresses and visit order cannot change their suffixes. derivedTypeOrder struct { kind derivedTypeKind name string sourceName string sourceID string + api string } - // unionNameOrder identifies one declaration in a generated union family. + // unionNameOrder sorts the type, kind, branch type, constant, and constructor + // generated for a union. unionNameOrder struct { union UnionTypeID role unionNameRole branch string } - // unionNameRole orders the closed package-level symbols emitted for unions. + // unionNameRole records whether a name belongs to the union type, kind type, + // branch type, branch constant, or constructor. unionNameRole uint8 ) @@ -122,117 +128,122 @@ const ( unionBranchConstructorNameRole ) -// NewProjectedTypeID returns the generated declaration identity for the -// pointer-backed projection of source emitted in a service views package. +// NewProjectedTypeID returns the key used to find the view-specific copy of +// source whose fields use pointers in the generated service views package. func NewProjectedTypeID(source expr.UserType) DerivedTypeID { return newDerivedTypeID(source, projectedTypeKind) } -// NewViewedResultTypeID returns the generated declaration identity for the -// viewed-result wrapper of source emitted in a service views package. +// NewViewedResultTypeID returns the key used to find the viewed-result wrapper +// generated from source in a service views package. func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { return newDerivedTypeID(source, viewedResultTypeKind) } -// Name returns the semantic wrapper name assigned during normalization. +// Name returns the Go wrapper name assigned while preparing the method. func (i MethodTypeIdentity) Name() string { return i.name } -// UID returns the stable semantic expression identifier assigned during -// normalization. It reuses the wrapper's typed example owner so declaration -// identity and example identity cannot disagree about the method role. +// UID returns the stable example key stored when the method value was prepared. func (i MethodTypeIdentity) UID() string { return "generated:" + i.exampleIdentity.Seed() } -// Name returns the unqualified Go declaration name. It panics until the -// generation freezes declarations whose names depend on package collisions. +// Name returns the Go type name without a package qualifier. It panics until +// Generation.Freeze chooses every declaration name because another +// declaration may still change this one. func (d *TypeDeclaration) Name() string { return d.declaration.Name() } -// PackagePath returns the import path of the package that owns the declaration. +// PackagePath returns the import path of the package that declares the type. func (d *TypeDeclaration) PackagePath() string { return d.declaration.packagePath() } -// Declaration returns the canonical package-owned name record. +// Declaration returns the NameDeclaration used for this generated type. func (d *TypeDeclaration) Declaration() *NameDeclaration { return d.declaration } -// Name returns the unqualified Go union declaration name. It panics until the -// generation freezes the owning package. +// Name returns the Go union type name without a package qualifier. It panics +// until Generation.Freeze chooses every declaration name. func (d *UnionDeclaration) Name() string { return d.declaration.Name() } -// KindName returns the unqualified Go discriminator type name. It panics until -// the generation freezes the owning package. +// KindName returns the Go union kind type name without a package qualifier. It +// panics until Generation.Freeze chooses every declaration name. func (d *UnionDeclaration) KindName() string { return d.kindDeclaration.Name() } -// PackagePath returns the import path of the package that owns the union. +// PackagePath returns the import path of the package that declares the union. func (d *UnionDeclaration) PackagePath() string { return d.declaration.packagePath() } -// Declaration returns the canonical package-owned union type name. +// Declaration returns the NameDeclaration used for the union type. func (d *UnionDeclaration) Declaration() *NameDeclaration { return d.declaration } -// KindDeclaration returns the canonical package-owned discriminator type name. +// KindDeclaration returns the NameDeclaration used for the union kind type. func (d *UnionDeclaration) KindDeclaration() *NameDeclaration { return d.kindDeclaration } -// KindConst returns the unqualified discriminator constant for the branch. +// KindConst returns the branch kind constant without a package qualifier. func (d *UnionBranchDeclaration) KindConst() string { return d.kindDeclaration.Name() } -// Constructor returns the unqualified constructor function for the branch. +// Constructor returns the branch constructor name without a package qualifier. func (d *UnionBranchDeclaration) Constructor() string { return d.constructorDeclaration.Name() } -// KindDeclaration returns the canonical discriminator constant name. +// KindDeclaration returns the NameDeclaration used for the branch kind +// constant. func (d *UnionBranchDeclaration) KindDeclaration() *NameDeclaration { return d.kindDeclaration } -// ConstructorDeclaration returns the canonical branch constructor name. +// ConstructorDeclaration returns the NameDeclaration used for the branch +// constructor. func (d *UnionBranchDeclaration) ConstructorDeclaration() *NameDeclaration { return d.constructorDeclaration } -// Type returns the generated branch alias declaration and whether the branch -// emits one. +// Type returns the generated branch type and true when the branch has one. func (d *UnionBranchDeclaration) Type() (*TypeDeclaration, bool) { return d.branchType, d.branchType != nil } -// Ref returns the Go reference spelling for declaration's data type, including -// Goa's pointer/value semantics for named objects, unions, and aliases. +// Ref returns the Go type reference for dataType, including the pointer chosen +// by Goa for named objects, unions, and aliases. func (d *TypeDeclaration) Ref(dataType expr.DataType) string { return goTypeRef(d.Name(), dataType) } -// DeclareName registers one canonical package-level declaration. Registering -// the same record again is idempotent; another owner or ambiguous order fails. -func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { +// DeclareName records one package-level Go name. Each supplied key will return +// that same name during type formatting. Repeating the same name and keys has +// no effect. It returns an error if the name belongs to another package, cannot +// be ordered, or a key already selects another name. +func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration, keys ...Hasher) error { if p.frozen { return fmt.Errorf("generated package %q is frozen", p.path) } if err := validateNameDeclaration(declaration); err != nil { return err } + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } if declaration.owner != nil { if declaration.owner == p { - return nil + return p.recordNameBindings(declaration, keys) } return fmt.Errorf( "package name %q already belongs to generated package %q", @@ -296,13 +307,11 @@ func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration) error { } declaration.owner = p p.names = append(p.names, declaration) - return nil + return p.recordNameBindings(declaration, keys) } -// DeclareDependentName registers a compiler-owned companion whose preferred -// spelling is derived from base's final name. The base must already belong to -// p. Freeze resolves base first, then reserves prefix+base+suffix in the same -// package namespace. +// DeclareDependentName adds a generated declaration named by placing prefix and +// suffix around base's final name. base must already be declared in p. func (p *GeneratedPackage) DeclareDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) (*NameDeclaration, error) { declaration := newDependentName(kind, base, prefix, suffix, order) if err := p.DeclareName(declaration); err != nil { @@ -311,8 +320,20 @@ func (p *GeneratedPackage) DeclareDependentName(kind PackageNameKind, base *Name return declaration, nil } -// DeclareUserType reserves userType's exact exported Go name and returns its -// canonical package declaration. Repeated calls return the same declaration. +// DeclareGeneratedType adds a type produced by a generator plugin. Goa assigns +// a stable final name but does not associate the declaration with an authored +// Goa type. +func (p *GeneratedPackage) DeclareGeneratedType(preferredName string, order PackageNameOrder) (*TypeDeclaration, error) { + declaration := NewPreferredName(NameType, Goify(preferredName, true), ExportedName, order) + if err := p.DeclareName(declaration); err != nil { + return nil, fmt.Errorf("declare generated type %q: %w", preferredName, err) + } + return &TypeDeclaration{declaration: declaration}, nil +} + +// DeclareUserType adds userType with its exact exported Go name and returns the +// generated declaration. Repeated calls for the same source type return the +// same declaration. func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) @@ -345,10 +366,15 @@ func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclara return declaration, nil } -// DeclareDerivedType records one declaration produced by a closed compiler -// transformation. Rebuilding it from the same source origin returns the same -// canonical declaration record. +// DeclareDerivedType adds one generated form of a source type. Repeated calls +// with the same DerivedTypeID return the same declaration. func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name string) (*TypeDeclaration, error) { + return p.declareDerivedType(identity, name, "") +} + +// declareDerivedType adds one generated form and uses api only to order method +// wrappers contributed by different APIs to the same Go package. +func (p *GeneratedPackage) declareDerivedType(identity DerivedTypeID, name, api string) (*TypeDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) } @@ -365,7 +391,7 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin } return declaration, nil } - order := newDerivedTypeOrder(identity, canonicalName) + order := newDerivedTypeOrder(identity, canonicalName, api) nameDeclaration := NewPreferredName(NameType, canonicalName, ExportedName, order) if err := p.DeclareName(nameDeclaration); err != nil { return nil, err @@ -380,8 +406,9 @@ func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name strin return declaration, nil } -// DeclareMethodType records the declaration created for identity from source -// and returns the derived identity used for later lookup. +// DeclareMethodType adds the wrapper described by the MethodTypeIdentity value +// for source. It returns the declaration and DerivedTypeID used by later +// lookups. func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source expr.UserType) (*TypeDeclaration, DerivedTypeID, error) { if identity.origin == nil || identity.origin != source.Origin() { return nil, DerivedTypeID{}, fmt.Errorf( @@ -391,13 +418,14 @@ func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source ) } derived := newDerivedTypeID(source, identity.kind) - declaration, err := p.DeclareDerivedType(derived, identity.Name()) + declaration, err := p.declareDerivedType(derived, identity.Name(), identity.api) return declaration, derived, err } -// DeclareUnion records union's emitted definition and returns the same -// declaration for unions with the same emitted identity. Reading its name -// panics until the owning generation freezes its package catalogs. +// DeclareUnion adds the generated union type, kind type, branch constants, and +// branch constructors. Unions with the same UnionTypeID return the same +// declaration. Reading generated names panics until Generation.Freeze chooses +// every declaration name. func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) @@ -465,9 +493,9 @@ func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, e return declaration, nil } -// DeclareUnionBranchType records a generated user type that names one branch -// of union. Equivalent union expressions share the same branch declaration; -// ordinary DSL user types must instead be declared with DeclareUserType. +// DeclareUnionBranchType adds the generated type used by branchName in union. +// Equivalent union expressions share that declaration. Types written directly +// in the DSL must use DeclareUserType instead. func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName string, userType expr.UserType) (*TypeDeclaration, error) { if p.frozen { return nil, fmt.Errorf("generated package %q is frozen", p.path) @@ -519,8 +547,8 @@ func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName return declaration, nil } -// UserType returns userType's existing package declaration without allocating -// a name or declaration record. +// UserType returns the declaration previously added for userType. It does not +// add a name. func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) { if declaration, ok := p.userTypes[userType.Origin()]; ok { return declaration, nil @@ -528,8 +556,8 @@ func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, e return nil, fmt.Errorf("user type %q is not declared in generated package %q", userType.Name(), p.path) } -// Type returns the frozen exact or generated branch declaration bound to -// userType's origin in this package. +// Type returns the exact user type declaration or generated union branch type +// previously associated with userType's source declaration. func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error) { if declaration, ok := p.typeBindings[userType.Origin()]; ok { return declaration, nil @@ -537,7 +565,8 @@ func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error return nil, fmt.Errorf("user type %q has no declaration in generated package %q", userType.Name(), p.path) } -// DerivedType returns a previously planned generated view declaration. +// DerivedType returns the generated view type previously added for the supplied +// DerivedTypeID. func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration, error) { if declaration, ok := p.derivedTypes[identity]; ok { return declaration, nil @@ -549,8 +578,8 @@ func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration ) } -// UnionBranch returns the existing declaration family for one union branch -// without allocating package names. +// UnionBranch returns the constant, constructor, and optional type previously +// added for branchName. It does not add names. func (p *GeneratedPackage) UnionBranch(union *expr.Union, branchName string) (*UnionBranchDeclaration, error) { planned, ok := p.unions[NewUnionTypeID(union)] if !ok { @@ -563,8 +592,8 @@ func (p *GeneratedPackage) UnionBranch(union *expr.Union, branchName string) (*U return branch, nil } -// Union returns union's existing package declaration without allocating a -// name or declaration record. +// Union returns the declaration previously added for union. It does not add a +// name. func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { if planned, ok := p.unions[NewUnionTypeID(union)]; ok { return planned.declaration, nil @@ -572,8 +601,8 @@ func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) } -// UnionBranchType returns the existing declaration for one generated branch -// alias without allocating a name or declaration record. +// UnionBranchType returns the generated type previously added for branchName. +// It returns an error when the branch does not generate a type. func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string) (*TypeDeclaration, error) { branch, err := p.UnionBranch(union, branchName) if err != nil { @@ -585,8 +614,8 @@ func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string) return branch.branchType, nil } -// Scope returns the frozen package-owned name scope used to render generated -// references. It panics before declaration planning has been frozen. +// Scope returns the package's NameScope after all names are final. It panics +// until Generation.Freeze chooses every declaration name. func (p *GeneratedPackage) Scope() *NameScope { if !p.frozen { panic(fmt.Sprintf("generated package %q scope requested before freeze", p.path)) @@ -594,12 +623,12 @@ func (p *GeneratedPackage) Scope() *NameScope { return p.scope } -// ComparePackageName orders two derived service declaration identities. +// ComparePackageName sorts two generated service type names. func (o derivedTypeOrder) ComparePackageName(other PackageNameOrder) int { return compareDerivedTypeOrder(o, other.(derivedTypeOrder)) } -// ComparePackageName orders union declarations by emitted identity and role. +// ComparePackageName sorts two declarations generated for unions. func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { right := other.(unionNameOrder) if compared := strings.Compare(string(o.union), string(right.union)); compared != 0 { @@ -611,7 +640,7 @@ func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { return strings.Compare(o.branch, right.branch) } -// newGeneratedPackage creates an empty mutable declaration catalog for path. +// newGeneratedPackage returns an empty package record for path and outputDir. func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { return &GeneratedPackage{ claim: claim, @@ -619,6 +648,10 @@ func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { outputDir: outputDir, scope: NewNameScope(), exactNames: make(map[string]*NameDeclaration), + nameBindings: make(map[string]*NameDeclaration), + importPlan: &importAliasPlan{ + candidates: make(map[string]*importAliasCandidate), + }, userTypes: make(map[expr.UserType]*TypeDeclaration), typeBindings: make(map[expr.UserType]*TypeDeclaration), derivedTypes: make(map[DerivedTypeID]*TypeDeclaration), @@ -626,9 +659,13 @@ func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { } } -// freeze allocates exact names first, then independent preferred names in -// stable typed order, followed by names derived from an already frozen base. +// freeze chooses exact names first, then names that may receive a number, and +// finally names built from another chosen name. It then rejects any attempt to +// add or change a name. func (p *GeneratedPackage) freeze() error { + if err := p.freezeImports(); err != nil { + return err + } exact := make([]*NameDeclaration, 0, len(p.names)) preferred := make([]*NameDeclaration, 0, len(p.names)) dependent := make([]*NameDeclaration, 0, len(p.names)) @@ -692,15 +729,54 @@ func (p *GeneratedPackage) freeze() error { return nil } -// bindName associates a type identity with a canonical declaration after all -// package names have been allocated. +// bindName makes lookups for hash return declaration's chosen Go name. func (p *GeneratedPackage) bindName(declaration *NameDeclaration, hash Hasher) { - declaration.hashes = append(declaration.hashes, hash) + if err := p.recordNameBindings(declaration, []Hasher{hash}); err != nil { + panic(err) + } +} + +// validateNameBindings rejects nil lookup keys and keys that already return a +// different Go declaration name. +func (p *GeneratedPackage) validateNameBindings(declaration *NameDeclaration, keys []Hasher) error { + for _, key := range keys { + if key == nil { + return fmt.Errorf("generated package %q cannot declare a nil lookup key", p.path) + } + hash := key.Hash() + if existing := p.nameBindings[hash]; existing != nil && existing != declaration { + return fmt.Errorf( + "generated package %q lookup key %q already belongs to %s %q", + p.path, + hash, + existing.kind, + existing.preferredName(), + ) + } + } + return nil +} + +// recordNameBindings makes each lookup key return declaration's chosen Go +// name. +func (p *GeneratedPackage) recordNameBindings(declaration *NameDeclaration, keys []Hasher) error { + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } + for _, key := range keys { + hash := key.Hash() + if p.nameBindings[hash] == declaration { + continue + } + p.nameBindings[hash] = declaration + declaration.hashes = append(declaration.hashes, key) + } + return nil } -// bindType gives one exact expression origin one canonical package -// declaration. Repeating the same binding is harmless; claiming the origin for -// another record is a planning error. +// bindType associates one source type with one generated declaration. Repeating +// the same association has no effect; using a different declaration returns an +// error. func (p *GeneratedPackage) bindType(origin expr.UserType, declaration *TypeDeclaration) error { if existing, ok := p.typeBindings[origin]; ok { if existing == declaration { @@ -716,8 +792,8 @@ func (p *GeneratedPackage) bindType(origin expr.UserType, declaration *TypeDecla return nil } -// newDerivedTypeID validates and records the exact declaration origin used by -// independently rebuilt planning and rendering graphs. +// newDerivedTypeID identifies one generated view, payload, or result type by +// the source type it came from. It panics when that source is unknown. func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID { if source == nil || source.Origin() == nil { panic("derived type source has no declaration origin") @@ -725,27 +801,30 @@ func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID return DerivedTypeID{origin: source.Origin(), kind: kind} } -// newMethodTypeIdentity records the declaration role and exact example owner -// of one wrapper created from a raw method object. -func newMethodTypeIdentity(methodName string, kind derivedTypeKind, exampleIdentity expr.ExampleIdentity) MethodTypeIdentity { +// newMethodTypeIdentity records the API, generated wrapper name, whether it +// holds a payload or result, and the key used to repeat examples for one +// service method value. +func newMethodTypeIdentity(apiName, methodName string, kind derivedTypeKind, exampleIdentity expr.ExampleIdentity) MethodTypeIdentity { if !kind.isMethodType() { panic("method type identity requires a method role") } return MethodTypeIdentity{ + api: apiName, name: Goify(methodName, true) + kind.methodSuffix(), kind: kind, exampleIdentity: exampleIdentity, } } -// bind records the exact wrapper created during normalization. The pointer is -// provenance for this run and does not participate in stable names or IDs. +// bind records the source type wrapped for one method. Goa uses the pointer only +// while preparing this run; it does not change generated names or identifiers. func (i MethodTypeIdentity) bind(source expr.UserType) MethodTypeIdentity { i.origin = source.Origin() return i } -// methodSuffix returns the semantic suffix for one closed method wrapper kind. +// methodSuffix returns the Go name suffix for a method payload or result +// wrapper. func (k derivedTypeKind) methodSuffix() string { switch k { case methodPayloadTypeKind: @@ -761,24 +840,26 @@ func (k derivedTypeKind) methodSuffix() string { } } -// isMethodType reports whether the derived declaration names a raw method -// object wrapper in the service package. +// isMethodType reports whether this kind is a service method payload or result +// wrapper. func (k derivedTypeKind) isMethodType() bool { return k >= methodPayloadTypeKind && k <= methodStreamingResultTypeKind } -// newDerivedTypeOrder builds deterministic ordering data independent of -// expression pointer addresses. -func newDerivedTypeOrder(identity DerivedTypeID, name string) derivedTypeOrder { +// newDerivedTypeOrder copies the values used to sort generated type names so +// expression pointer addresses cannot affect their suffixes. +func newDerivedTypeOrder(identity DerivedTypeID, name, api string) derivedTypeOrder { return derivedTypeOrder{ kind: identity.kind, name: name, sourceName: identity.origin.Name(), sourceID: identity.origin.ID(), + api: api, } } -// compareDerivedTypeOrder orders view declarations by stable typed fields. +// compareDerivedTypeOrder sorts generated view types by kind, requested name, +// source name, source ID, and API name. func compareDerivedTypeOrder(left, right derivedTypeOrder) int { if left.kind != right.kind { return int(left.kind) - int(right.kind) @@ -787,6 +868,7 @@ func compareDerivedTypeOrder(left, right derivedTypeOrder) int { {left.name, right.name}, {left.sourceName, right.sourceName}, {left.sourceID, right.sourceID}, + {left.api, right.api}, } { if compared := strings.Compare(values[0], values[1]); compared != 0 { return compared @@ -795,9 +877,8 @@ func compareDerivedTypeOrder(left, right derivedTypeOrder) int { return 0 } -// unionHasBranchType verifies that userType is the branch expression supplied -// for this concrete union copy. Structural reuse is established separately by -// UnionTypeID when the owning union declaration is looked up. +// unionHasBranchType reports whether branchName in this union expression uses +// userType. UnionTypeID handles equivalent copies of the whole union. func unionHasBranchType(union *expr.Union, branchName string, userType expr.UserType) bool { for _, branch := range union.Values { if branch.Name == branchName && branch.Attribute.Type == userType { diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index e3db9ca04e..f2a7ff789c 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -33,6 +33,9 @@ type ( indirectTestNameOrder struct { value *string } + + // testNameKey supplies the lookup key used by generated type formatters. + testNameKey string ) // ComparePackageName orders declarations from the same test family. @@ -60,6 +63,39 @@ func (o indirectTestNameOrder) ComparePackageName(other PackageNameOrder) int { return strings.Compare(*o.value, *other.(indirectTestNameOrder).value) } +// Hash returns the lookup key used by a generated package. +func (k testNameKey) Hash() string { + return string(k) +} + +// TestDeclareNameBindsLookupKeys checks that a plugin can declare a name and +// use the same final name through its normal typed lookup. +func TestDeclareNameBindsLookupKeys(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + declaration := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "request"}) + + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, generation.Freeze()) + require.Equal(t, declaration.Name(), pkg.Scope().HashedUnique(key, "Ignored")) +} + +// TestDeclareNameRejectsAnotherDeclarationForOneKey checks that one lookup +// key cannot select two package-level names. +func TestDeclareNameRejectsAnotherDeclarationForOneKey(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + first := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "first"}) + second := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "second"}) + + require.NoError(t, pkg.DeclareName(first, key)) + err := pkg.DeclareName(second, key) + require.ErrorContains(t, err, "lookup key") +} + // TestNameDeclarationOwnsOnePackageNamespace verifies that exact and preferred // package symbols of every kind share one collision domain and one frozen name. func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { @@ -89,6 +125,40 @@ func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { } } +// TestDeclareGeneratedTypeUsesStablePackageNames verifies that plugins can +// declare generated types without claiming that they are authored Goa types. +func TestDeclareGeneratedTypeUsesStablePackageNames(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstOrder := testNameOrder{value: "first"} + secondOrder := testNameOrder{value: "second"} + var first, second *TypeDeclaration + var err error + if reverse { + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + require.NoError(t, err) + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + } else { + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + require.NoError(t, err) + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + } + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + _, err = pkg.DeclareGeneratedType("Other", testNameOrder{value: "other"}) + require.ErrorContains(t, err, "frozen") + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Value", first) + require.Equal(t, "Value2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + // TestGeneratedPackagePreservesExactGoNames checks that names produced by // another Go generator are stored without changing their spelling. func TestGeneratedPackagePreservesExactGoNames(t *testing.T) { @@ -375,6 +445,35 @@ func TestNameDeclarationRejectsSameImportAcrossGenerations(t *testing.T) { require.ErrorContains(t, err, "already belongs") } +// TestGenerationOwnsName checks declaration ownership before and after names +// are frozen without treating a matching package path as ownership. +func TestGenerationOwnsName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(declaration)) + + foreignGeneration := mustTestGeneration(t, "generated.local/gen", nil) + foreign := NewExactName(NameType, "Foreign") + require.NoError(t, mustClaimTestPackage(t, foreignGeneration, "generated.local/gen/types").DeclareName(foreign)) + + require.True(t, generation.OwnsName(declaration)) + require.False(t, generation.OwnsName(foreign)) + require.False(t, generation.OwnsName(NewExactName(NameType, "Unregistered"))) + require.False(t, generation.OwnsName(nil)) + require.True(t, pkg.OwnsName(declaration)) + require.False(t, pkg.OwnsName(foreign)) + require.False(t, pkg.OwnsName(nil)) + filePackage, ok := generation.PackageForFile("gen/types/value.go") + require.True(t, ok) + require.Same(t, pkg, filePackage) + _, ok = generation.PackageForFile("gen/other/value.go") + require.False(t, ok) + + require.NoError(t, generation.Freeze()) + require.True(t, generation.OwnsName(declaration)) +} + // TestGeneratedOutputPathRejectsNormalizedCollisions verifies that equivalent // import spellings cannot make two requested package identities share output. func TestGeneratedOutputPathRejectsNormalizedCollisions(t *testing.T) { @@ -502,7 +601,7 @@ func TestGeneratedTypeFamiliesContainCanonicalNames(t *testing.T) { pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") user, err := pkg.DeclareUserType(generatedUserType("Widget", "widget")) require.NoError(t, err) - union, alias := generatedUnionWithBranch("Value", "text", "text", expr.String) + union, alias := generatedUnionWithBranch("text") unionDeclaration, err := pkg.DeclareUnion(union) require.NoError(t, err) branchType, err := pkg.DeclareUnionBranchType(union, "text", alias) @@ -777,8 +876,8 @@ func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) types := mustClaimTestPackage(t, generation, "generated.local/gen/types") - firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) - secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion, secondAlias := generatedUnionWithBranch("second") _, err := types.DeclareUnion(firstUnion) require.NoError(t, err) @@ -803,8 +902,8 @@ func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) types := mustClaimTestPackage(t, generation, "generated.local/gen/types") - firstUnion, firstAlias := generatedUnionWithBranch("Value", "text", "first", expr.String) - secondUnion, secondAlias := generatedUnionWithBranch("Value", "text", "second", expr.String) + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion, secondAlias := generatedUnionWithBranch("second") secondUnion.TypeKey = "kind" _, err := types.DeclareUnion(firstUnion) @@ -834,7 +933,7 @@ func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { _, err := types.DeclareUserType(generatedUserType(name, name)) require.NoError(t, err) } - union, alias := generatedUnionWithBranch("Value", "text", "text", expr.String) + union, alias := generatedUnionWithBranch("text") _, err := types.DeclareUnion(union) require.NoError(t, err) aliasDeclaration, err := types.DeclareUnionBranchType(union, "text", alias) @@ -856,9 +955,9 @@ func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { func TestGeneratedPackageUnions(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) types := mustClaimTestPackage(t, generation, "generated.local/gen/types") - first := generatedUnion("Value", "type", "value") - equivalent := generatedUnion("Value", "type", "value") - different := generatedUnion("Value", "kind", "data") + first := generatedUnion("type", "value") + equivalent := generatedUnion("type", "value") + different := generatedUnion("kind", "data") firstDeclaration, err := types.DeclareUnion(first) require.NoError(t, err) @@ -888,9 +987,9 @@ func TestGeneratedPackageUnions(t *testing.T) { reversedGeneration := mustTestGeneration(t, "generated.local/gen", nil) reversedTypes := mustClaimTestPackage(t, reversedGeneration, "generated.local/gen/types") - reversedDifferent, err := reversedTypes.DeclareUnion(generatedUnion("Value", "kind", "data")) + reversedDifferent, err := reversedTypes.DeclareUnion(generatedUnion("kind", "data")) require.NoError(t, err) - reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("Value", "type", "value")) + reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("type", "value")) require.NoError(t, err) require.NoError(t, reversedGeneration.Freeze()) require.Equal(t, firstDeclaration.Name(), reversedFirst.Name()) @@ -907,7 +1006,7 @@ func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { types := mustClaimTestPackage(t, generation, "generated.local/gen/types") userType := generatedUserType("Value", "value") kindUserType := generatedUserType("ValueKind", "value-kind") - union := generatedUnion("Value", "type", "value") + union := generatedUnion("type", "value") var ( userDeclaration *TypeDeclaration kindDeclaration *TypeDeclaration @@ -958,7 +1057,7 @@ func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) types := mustClaimTestPackage(t, generation, "generated.local/gen/types") widget := generatedUserType("Widget", "widget") - union := generatedUnion("Value", "type", "value") + union := generatedUnion("type", "value") userDeclaration, err := types.DeclareUserType(widget) require.NoError(t, err) unionDeclaration, err := types.DeclareUnion(union) @@ -1103,16 +1202,28 @@ func TestMethodTypeIdentityPreservesRawOwner(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - first := newMethodTypeIdentity(firstMethod.Name, tc.kind, tc.first) - second := newMethodTypeIdentity(secondMethod.Name, tc.kind, tc.second) + first := newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first) + second := newMethodTypeIdentity("test api", secondMethod.Name, tc.kind, tc.second) require.Equal(t, first.Name(), second.Name()) require.NotEqual(t, first.UID(), second.UID()) - require.Equal(t, first.UID(), newMethodTypeIdentity(firstMethod.Name, tc.kind, tc.first).UID()) + require.Equal(t, first.UID(), newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first).UID()) }) } } +// TestMethodTypeNamesUseAPIToBreakTies verifies two APIs can write equal +// service and method wrapper names to one package without using input order. +func TestMethodTypeNamesUseAPIToBreakTies(t *testing.T) { + forwardFirst, forwardSecond := methodTypeNamesByAPI(t, false) + reverseFirst, reverseSecond := methodTypeNamesByAPI(t, true) + + require.Equal(t, "ReadPayload", forwardFirst) + require.Equal(t, "ReadPayload2", forwardSecond) + require.Equal(t, forwardFirst, reverseFirst) + require.Equal(t, forwardSecond, reverseSecond) +} + // TestGenerationPreservesRawMethodOwner proves synthesized wrappers retain // the raw method identity even when their preferred generated names coincide. func TestGenerationPreservesRawMethodOwner(t *testing.T) { @@ -1208,8 +1319,8 @@ func TestGenerationCatalogsAreIsolated(t *testing.T) { first := mustClaimTestPackage(t, firstGeneration, "generated.local/gen/types") secondGeneration := mustTestGeneration(t, "generated.local/gen", nil) second := mustClaimTestPackage(t, secondGeneration, "generated.local/gen/types") - firstUnion := generatedUnion("Value", "type", "value") - secondUnion := generatedUnion("Value", "type", "value") + firstUnion := generatedUnion("type", "value") + secondUnion := generatedUnion("type", "value") firstDeclaration, err := first.DeclareUnion(firstUnion) require.NoError(t, err) @@ -1223,6 +1334,51 @@ func TestGenerationCatalogsAreIsolated(t *testing.T) { require.NotSame(t, first.Scope(), second.Scope()) } +// methodTypeNamesByAPI declares equal method wrappers in the requested order +// and returns the name assigned to each API. +func methodTypeNamesByAPI(t *testing.T, reverse bool) (string, string) { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/shared") + method := &expr.MethodExpr{Name: "Read", Service: &expr.ServiceExpr{Name: "Shared"}} + example := expr.MethodPayloadExampleIdentity(method) + firstIdentity, firstWrapper := testMethodTypeWrapper("first api", method.Name, example) + secondIdentity, secondWrapper := testMethodTypeWrapper("second api", method.Name, example) + var first, second *TypeDeclaration + if reverse { + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + } else { + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() +} + +// testMethodTypeWrapper creates one generated method wrapper with an API name +// that is used only to order equal wrapper names. +func testMethodTypeWrapper(api, method string, example expr.ExampleIdentity) (MethodTypeIdentity, expr.UserType) { + identity := newMethodTypeIdentity(api, method, methodPayloadTypeKind, example) + wrapper := expr.NewGeneratedUserType( + identity.Name(), + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + example, + ) + return identity.bind(wrapper), wrapper +} + +// declareTestMethodType submits one generated wrapper and returns its stored +// type declaration. +func declareTestMethodType(t *testing.T, generatedPackage *GeneratedPackage, identity MethodTypeIdentity, wrapper expr.UserType) *TypeDeclaration { + t.Helper() + declaration, _, err := generatedPackage.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + return declaration +} + // generatedUserType builds a distinct user type for catalog tests. func generatedUserType(name, id string) expr.UserType { return generatedUserTypeOf(name, id, expr.String) @@ -1239,21 +1395,21 @@ func generatedUserTypeOf(name, id string, dataType expr.DataType) expr.UserType // generatedUnion builds a union whose emitted identity includes the supplied // JSON envelope keys. -func generatedUnion(name, typeKey, valueKey string) *expr.Union { +func generatedUnion(typeKey, valueKey string) *expr.Union { return &expr.Union{ - TypeName: name, + TypeName: "Value", TypeKey: typeKey, ValueKey: valueKey, } } // generatedUnionWithBranch builds a union with one generated branch alias. -func generatedUnionWithBranch(unionName, branchName, aliasID string, dataType expr.DataType) (*expr.Union, expr.UserType) { - alias := generatedUserTypeOf(unionName+expr.Title(branchName), aliasID, dataType) +func generatedUnionWithBranch(aliasID string) (*expr.Union, expr.UserType) { + alias := generatedUserTypeOf("ValueText", aliasID, expr.String) return &expr.Union{ - TypeName: unionName, + TypeName: "Value", Values: []*expr.NamedAttributeExpr{{ - Name: branchName, + Name: "text", Attribute: &expr.AttributeExpr{Type: alias}, }}, }, alias diff --git a/codegen/generation.go b/codegen/generation.go index c72bae64ca..6f0caa5136 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -1,6 +1,6 @@ -// This file defines the state shared by every generator contributing files to -// one generation run. The generation creates one naming catalog per output Go -// package and freezes all catalogs before rendering begins. +// This file stores one evaluated design and every Go package and name produced +// from it. Goa chooses all generated and imported package names before writing +// source files. package codegen import ( @@ -16,26 +16,22 @@ import ( ) type ( - // Generation owns the normalized design roots and generated-package naming - // catalogs for one standalone code generation run. + // Generation stores the evaluated design roots and output packages used by + // one code generation run. Generation struct { genpkg string roots []eval.Root packages map[string]*GeneratedPackage importOwners map[string]*GeneratedPackage outputOwners map[string]*GeneratedPackage - importPlan *importAliasPlan - imports map[string]importAliasBinding methodTypes map[expr.UserType]MethodTypeIdentity frozen bool } ) -// NewGeneration normalizes raw method objects, records their exact generated -// wrappers, creates an independent generation catalog, and rejects an invalid -// generated module import path. Construction has exclusive preparation access -// to the supplied evaluated expression graphs; callers must not concurrently -// construct another generation over the same graphs. +// NewGeneration checks the generated package path and gives unnamed method +// payloads and results stable generated types. It updates the supplied designs, +// so callers must not prepare the same designs concurrently. func NewGeneration(genpkg string, roots []eval.Root) (*Generation, error) { canonicalGenPkg, err := canonicalGenerationRoot(genpkg) if err != nil { @@ -49,15 +45,12 @@ func NewGeneration(genpkg string, roots []eval.Root) (*Generation, error) { importOwners: make(map[string]*GeneratedPackage), outputOwners: make(map[string]*GeneratedPackage), methodTypes: normalizeRoots(ownedRoots), - importPlan: &importAliasPlan{ - candidates: make(map[string]*importAliasCandidate), - }, }, nil } -// NormalizedMethodType returns the exact compiler-owned method role recorded -// when this generation wrapped source. Authored user types are never present, -// regardless of their name or semantic ID. +// NormalizedMethodType returns the generated name and payload-or-result role +// recorded for an unnamed method type. It returns false for a type declared +// directly in the design. func (g *Generation) NormalizedMethodType(source expr.UserType) (MethodTypeIdentity, bool) { identity, ok := g.methodTypes[source.Origin()] return identity, ok @@ -68,15 +61,16 @@ func (g *Generation) GenPkg() string { return g.genpkg } -// Roots returns a copy of the root slice participating in the run. The -// expression graphs themselves remain the prepared objects owned by the run. +// Roots returns a copy of the evaluated design root slice used by this run. The +// returned roots still point to the prepared design values. func (g *Generation) Roots() []eval.Root { return append([]eval.Root(nil), g.roots...) } -// ClaimPackage claims the exact planner-supplied import path and returns its -// package catalog. Repeating the exact claim is idempotent; a second claim for -// the same canonical import or portable output directory is rejected. +// ClaimPackage records path as a generated package and returns the package's +// name records. Repeating the same path returns the same package. A different +// path that resolves to the same import path or output directory returns an +// error. func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { if g.frozen { return nil, fmt.Errorf("generated package %q cannot be claimed after generation freeze", path) @@ -92,10 +86,10 @@ func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { return g.claimOutputPackage(path, canonicalPath, outputDir) } -// ClaimOutputPackage claims a Go package emitted at an explicit directory -// relative to the code generation working directory. It is used for generated -// files such as starter implementations that intentionally live outside the -// generated module import root while sharing the same declaration lifecycle. +// ClaimOutputPackage records a Go package written to outputDirectory, relative +// to the working directory. It supports generated files, such as starter +// implementations, that are written outside GenPkg but still need their names +// finalized with the other generated packages. func (g *Generation) ClaimOutputPackage(importPath, outputDirectory string) (*GeneratedPackage, error) { if g.frozen { return nil, fmt.Errorf("output package %q cannot be claimed after generation freeze", importPath) @@ -111,9 +105,8 @@ func (g *Generation) ClaimOutputPackage(importPath, outputDirectory string) (*Ge return g.claimOutputPackage(importPath, canonicalPath, canonicalDirectory) } -// claimOutputPackage installs one package after its import path and output -// directory have been validated by the public operation that owns their -// relationship. +// claimOutputPackage records a package after its caller has checked the import +// path and output directory. func (g *Generation) claimOutputPackage(claim, canonicalPath, outputDir string) (*GeneratedPackage, error) { if generatedPackage, ok := g.packages[claim]; ok { if generatedPackage.outputDir != outputDir { @@ -151,9 +144,9 @@ func (g *Generation) claimOutputPackage(claim, canonicalPath, outputDir string) return generatedPackage, nil } -// Package returns the package already claimed for canonicalPath. It panics -// when a renderer supplies a noncanonical or unplanned path because planning -// must establish every output package before freeze. +// Package returns the package previously recorded for canonicalPath, which +// must be a cleaned import path. It panics when the path is not clean or was +// not recorded before Freeze. func (g *Generation) Package(canonicalPath string) *GeneratedPackage { cleaned, err := canonicalGeneratedPackagePath(g.genpkg, canonicalPath) if err != nil || cleaned != canonicalPath { @@ -166,16 +159,13 @@ func (g *Generation) Package(canonicalPath string) *GeneratedPackage { return generatedPackage } -// Freeze assigns deterministic names to pending unions, then prevents every -// generated package and its name scope from accepting more declarations or -// name reservations. Existing declarations remain available through lookup. +// Freeze assigns every generated declaration and imported package its final Go +// name. It then rejects new packages, declarations, and name requests while +// keeping the completed records available for source generation. func (g *Generation) Freeze() error { if g.frozen { return nil } - if err := g.freezeImports(); err != nil { - return err - } for _, generatedPackage := range g.packages { if err := generatedPackage.freeze(); err != nil { return err @@ -185,25 +175,53 @@ func (g *Generation) Freeze() error { return nil } -// Frozen reports whether declaration and import collection has closed and all -// canonical names are available for linking retained subsystem plans. +// Frozen reports whether all generated and imported package names are final. func (g *Generation) Frozen() bool { return g.frozen } -// ImportPath returns the canonical Go import path owned by the package. +// OwnsName reports whether DeclareName added declaration to a package in this +// generation. It can return true before Freeze chooses the declaration's final +// Go name. +func (g *Generation) OwnsName(declaration *NameDeclaration) bool { + if declaration == nil || declaration.owner == nil { + return false + } + return g.importOwners[declaration.owner.path] == declaration.owner +} + +// PackageForFile returns the generated package that writes outputPath. The +// second result is false when no package claimed the file's directory during +// planning or when outputPath is not a valid relative output path. +func (g *Generation) PackageForFile(outputPath string) (*GeneratedPackage, bool) { + directory, err := canonicalOutputDirectory(filepath.Dir(outputPath)) + if err != nil { + return nil, false + } + pkg, ok := g.outputOwners[directory] + return pkg, ok +} + +// ImportPath returns the cleaned Go import path for the package. func (p *GeneratedPackage) ImportPath() string { return p.path } -// OutputDirectory returns the canonical directory relative to the generation -// output root where this package's files are written. +// OutputDirectory returns the cleaned directory, relative to the working +// directory, where this package's files are written. func (p *GeneratedPackage) OutputDirectory() string { return p.outputDir } -// canonicalGenerationRoot validates the module import prefix used by one run. -// Dot and slash are explicit local-output sentinels used by generator tests. +// OwnsName reports whether declaration was added to this exact generated +// package. A package with the same import path in another generation does not +// own the declaration. +func (p *GeneratedPackage) OwnsName(declaration *NameDeclaration) bool { + return declaration != nil && declaration.owner == p +} + +// The generated module import prefix is checked and cleaned here. Tests may +// pass dot or slash to request local output paths. func canonicalGenerationRoot(genpkg string) (string, error) { if genpkg == "." || genpkg == "/" { return genpkg, nil @@ -221,8 +239,8 @@ func canonicalGenerationRoot(genpkg string) (string, error) { return canonical, nil } -// canonicalGeneratedPackagePath validates one package import claimed beneath -// genpkg and returns the cleaned spelling emitted by generated source. +// A generated package import path is checked and cleaned here before it is +// written in generated source. func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { canonical, err := cleanImportPath("generated package path", importPath) if err != nil { @@ -243,8 +261,8 @@ func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { return canonical, nil } -// canonicalOutputPackagePath validates the import identity of an explicitly -// located generated output package without requiring it to be below GenPkg. +// A package written outside GenPkg has its import path checked and cleaned +// here. func canonicalOutputPackagePath(importPath string) (string, error) { canonical, err := cleanImportPath("output package path", importPath) if err != nil { @@ -256,8 +274,8 @@ func canonicalOutputPackagePath(importPath string) (string, error) { return canonical, nil } -// canonicalOutputDirectory accepts one relative output location and rejects -// spellings that could escape or vary across host path conventions. +// A relative output directory is cleaned here. Paths that escape the working +// directory or use platform-dependent separators are rejected. func canonicalOutputDirectory(outputDirectory string) (string, error) { if strings.Contains(outputDirectory, "\\") { return "", fmt.Errorf("output directory %q contains a backslash", outputDirectory) @@ -272,8 +290,8 @@ func canonicalOutputDirectory(outputDirectory string) (string, error) { return canonical, nil } -// cleanImportPath rejects filesystem separators in Go import identities and -// preserves the raw spelling for diagnostics before cleaning dot segments. +// cleanImportPath rejects backslashes and removes dot segments from a Go import +// path. Errors include the original path supplied by the caller. func cleanImportPath(label, importPath string) (string, error) { if strings.Contains(importPath, "\\") { return "", fmt.Errorf("%s %q contains a backslash", label, importPath) @@ -281,8 +299,8 @@ func cleanImportPath(label, importPath string) (string, error) { return path.Clean(importPath), nil } -// generatedOutputDirectory maps a generated import path to its directory -// below gen and rejects packages outside the generated module root. +// generatedOutputDirectory returns the directory under gen for importPath. It +// returns an error when importPath is outside genpkg. func generatedOutputDirectory(genpkg, importPath string) (string, error) { var relative string switch genpkg { diff --git a/codegen/generator/attached_jsonrpc_sse_integration_test.go b/codegen/generator/attached_jsonrpc_sse_integration_test.go new file mode 100644 index 0000000000..3af92ada7b --- /dev/null +++ b/codegen/generator/attached_jsonrpc_sse_integration_test.go @@ -0,0 +1,112 @@ +// This file checks that services added by plugins receive every declaration +// needed by their generated JSON-RPC server-sent-event code. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestAttachedJSONRPCSSEServiceCompiles runs every generation step after a +// plugin adds one method that returns a value and another that streams values. +func TestAttachedJSONRPCSSEServiceCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("attached-stream", func() {}) + }) + registry := newDefaultRegistry() + registry.registerPlugin("attached-stream", "gen", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + return attachJSONRPCSSEService(roots[0].(*expr.RootExpr)) + }} + }) + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + files, err := mergeFilesByPath(result.files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + serviceCode, err := os.ReadFile(filepath.Join(dir, "gen", "attached_stream", "service.go")) + require.NoError(t, err) + generated := string(serviceCode) + require.Contains(t, generated, "Watch(context.Context, WatchServerStream) (err error)") + require.Contains(t, generated, "Send(string) error") + require.Contains(t, generated, "SendWithContext(context.Context, string) error") + require.Contains(t, generated, "Close() error") + require.NotContains(t, generated, "SendAndClose") + require.NotContains(t, generated, "SendError") + require.NotContains(t, generated, "RequestID") + require.NotContains(t, generated, "isWatchEvent") + require.NotContains(t, generated, "Send(ctx context.Context") + runGeneratedTests(t, dir) +} + +// attachJSONRPCSSEService adds one evaluated service and its JSON-RPC route to +// a design that completed DSL evaluation before the plugin ran. +func attachJSONRPCSSEService(root *expr.RootExpr) error { + read := &expr.MethodExpr{ + Name: "Read", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.String}, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + watch := &expr.MethodExpr{ + Name: "Watch", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.String}, + Stream: expr.ServerStreamKind, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + service := &expr.ServiceExpr{ + Name: "AttachedStream", + Methods: []*expr.MethodExpr{read, watch}, + Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, + } + read.Service = service + watch.Service = service + + transport := &expr.HTTPServiceExpr{ + ServiceExpr: service, + JSONRPCRoute: &expr.RouteExpr{ + Method: "POST", + Path: "/rpc", + }, + SSE: &expr.HTTPSSEExpr{}, + } + transport.Root = &root.API.JSONRPC.HTTPExpr + transport.JSONRPCRoute.Endpoint = &expr.HTTPEndpointExpr{Service: transport} + for _, method := range service.Methods { + endpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Service: transport, + Body: method.Payload, + Params: expr.NewEmptyMappedAttributeExpr(), + Headers: expr.NewEmptyMappedAttributeExpr(), + Cookies: expr.NewEmptyMappedAttributeExpr(), + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + if method.IsResultStreaming() { + endpoint.SSE = &expr.HTTPSSEExpr{} + } + endpoint.Routes = []*expr.RouteExpr{{Method: "POST", Path: "/rpc", Endpoint: endpoint}} + transport.HTTPEndpoints = append(transport.HTTPEndpoints, endpoint) + } + + root.Services = append(root.Services, service) + root.API.JSONRPC.Services = append(root.API.JSONRPC.Services, transport) + return root.EvaluateAttachedServices([]*expr.ServiceExpr{service}) +} diff --git a/codegen/generator/command_isolation_test.go b/codegen/generator/command_isolation_test.go new file mode 100644 index 0000000000..4fc1278201 --- /dev/null +++ b/codegen/generator/command_isolation_test.go @@ -0,0 +1,233 @@ +// This file checks that each command builds files from its own Plan and that +// simultaneous commands cannot change each other's output. +package generator + +import ( + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // generatorCall records the Plan passed to one command's two functions. + generatorCall struct { + planCalls int + generateCalls int + planned *Plan + generated *Plan + } + + // commandResult contains every file written by one command, indexed by its + // path beneath the output directory. + commandResult struct { + files map[string][]byte + err error + } +) + +// TestCommandsUseEachSelectedGeneratorOnce checks that each command calls only +// its listed generators and passes the same Plan to both functions. +func TestCommandsUseEachSelectedGeneratorOnce(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("first")) + cases := []struct { + name string + factories []generatorFactory + selected []string + }{ + {"gen", genGeneratorFactories(), []string{"service", "transport", "openapi"}}, + {"example", exampleGeneratorFactories(), []string{"example"}}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + calls := make(map[string]*generatorCall, len(test.selected)) + registry := newRegistry() + registry.addCommand(test.name, observedGenerators(test.factories, calls)...) + + run, err := newGenerationRun(test.name, registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + require.ElementsMatch(t, test.selected, mapKeys(calls)) + for _, name := range test.selected { + call := calls[name] + require.Equal(t, 1, call.planCalls, "%s plan calls", name) + require.Equal(t, 1, call.generateCalls, "%s file calls", name) + require.Same(t, result.plan, call.planned, "%s planned Plan", name) + require.Same(t, call.planned, call.generated, "%s generated Plan", name) + } + }) + } +} + +// TestFocusedCommandDoesNotBuildExamplesOrOpenAPI checks that a command with no +// example or OpenAPI generator creates neither result. +func TestFocusedCommandDoesNotBuildExamplesOrOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("focused")) + registry := testRegistry("focused", testGenerator(nil, nil)) + run, err := newGenerationRun("focused", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, result.plan.example) + require.Nil(t, result.plan.openapi) +} + +// TestCommandsProduceTheSameFilesWhenRunTogether checks that gen and example +// produce the same bytes alone, repeatedly, and beside another command run. +func TestCommandsProduceTheSameFilesWhenRunTogether(t *testing.T) { + for _, command := range []string{"gen", "example"} { + t.Run(command, func(t *testing.T) { + first := codegen.RunDSL(t, commandIsolationDSL("first")) + second := codegen.RunDSL(t, commandIsolationDSL("second")) + firstExpected, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + firstAgain, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + require.Equal(t, firstExpected, firstAgain) + secondExpected, err := renderCommand(command, second, t.TempDir()) + require.NoError(t, err) + + start := make(chan struct{}) + results := make(chan commandResult, 2) + var ready sync.WaitGroup + ready.Add(2) + for _, input := range []struct { + root *expr.RootExpr + dir string + }{{first, t.TempDir()}, {second, t.TempDir()}} { + go runCommandTogether(input.root, input.dir, command, start, &ready, results) + } + ready.Wait() + close(start) + firstResult := <-results + secondResult := <-results + require.NoError(t, firstResult.err) + require.NoError(t, secondResult.err) + if !reflect.DeepEqual(firstResult.files, firstExpected) { + firstResult, secondResult = secondResult, firstResult + } + require.Equal(t, firstExpected, firstResult.files) + require.Equal(t, secondExpected, secondResult.files) + }) + } +} + +// observedGenerators wraps each selected generator and records the Plan passed +// to the function that chooses names and the function that builds files. +func observedGenerators(factories []generatorFactory, calls map[string]*generatorCall) []generatorFactory { + observed := make([]generatorFactory, len(factories)) + for index, factory := range factories { + generator := factory() + call := &generatorCall{} + calls[generator.name] = call + observed[index] = observedGenerator(generator, call) + } + return observed +} + +// observedGenerator returns a factory that records calls before running the +// selected generator's original functions. +func observedGenerator(generator coreGenerator, call *generatorCall) generatorFactory { + return func() coreGenerator { + return coreGenerator{ + name: generator.name, + Plan: func(plan *Plan) error { + call.planCalls++ + call.planned = plan + return generator.Plan(plan) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + call.generateCalls++ + call.generated = plan + return generator.Generate(plan) + }, + } + } +} + +// mapKeys returns the generator names recorded by one command. +func mapKeys(calls map[string]*generatorCall) []string { + keys := make([]string, 0, len(calls)) + for key := range calls { + keys = append(keys, key) + } + return keys +} + +// renderCommand builds and writes every file for one command. +func renderCommand(command string, root *expr.RootExpr, dir string) (map[string][]byte, error) { + run, err := newGenerationRun(command, newDefaultRegistry()) + if err != nil { + return nil, err + } + result, err := run.execute("generated.local/gen", []eval.Root{root}) + if err != nil { + return nil, err + } + files, err := mergeFilesByPath(result.files) + if err != nil { + return nil, err + } + written := make(map[string][]byte, len(files)) + for _, file := range files { + filename, err := file.Render(dir) + if err != nil { + return nil, err + } + content, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + relative, err := filepath.Rel(dir, filename) + if err != nil { + return nil, err + } + written[filepath.ToSlash(relative)] = content + } + return written, nil +} + +// runCommandTogether waits until both command runs are ready, then builds and +// writes one command's files. +func runCommandTogether(root *expr.RootExpr, dir, command string, start <-chan struct{}, ready *sync.WaitGroup, results chan<- commandResult) { + ready.Done() + <-start + files, err := renderCommand(command, root, dir) + results <- commandResult{files: files, err: err} +} + +// commandIsolationDSL defines one HTTP service whose names identify its output. +func commandIsolationDSL(name string) func() { + return func() { + serviceName := name + " service" + dsl.API(name, func() { + dsl.Server(name, func() { + dsl.Services(serviceName) + dsl.Host(name, func() { + dsl.URI("http://localhost") + }) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + } +} diff --git a/codegen/generator/design_snapshot.go b/codegen/generator/design_snapshot.go index 91e9aeb0a1..0f24512d0d 100644 --- a/codegen/generator/design_snapshot.go +++ b/codegen/generator/design_snapshot.go @@ -1,7 +1,6 @@ -// This file records prepared design state so generation can audit persistent -// semantic mutations after callbacks and completed file rendering. Snapshot -// entries retain pointer topology and use deterministic map ordering so the -// first changed path can be reported. +// This file records the prepared design so Goa can report if a generator +// changes it. Map entries are sorted so repeated runs report the same first +// changed field. package generator import ( @@ -18,7 +17,7 @@ import ( ) type ( - // designSnapshot is the immutable prepared-state reference for one run. + // designSnapshot stores the prepared design values for one run. designSnapshot struct { states []designState references []reflect.Value @@ -31,16 +30,16 @@ type ( value string } - // designSnapshotter walks one evaluated graph while preserving aliases and - // terminating cycles. + // designSnapshotter records each design value once, even when pointers form + // a cycle or several fields point to the same value. designSnapshotter struct { states []designState references []reflect.Value visited map[designVisit]struct{} } - // designVisit identifies one reference node. Slice capacity distinguishes - // overlapping views whose reachable backing-array ranges differ. + // designVisit identifies one pointer, map, or slice. Slice capacity separates + // slices that can reach different parts of the same underlying array. designVisit struct { typ reflect.Type kind reflect.Kind @@ -48,7 +47,7 @@ type ( extent int } - // mapSnapshotEntry retains a map pair after deterministic ordering. + // mapSnapshotEntry stores one map pair after the entries are sorted. mapSnapshotEntry struct { key reflect.Value value reflect.Value @@ -56,8 +55,8 @@ type ( order mapOrderValue } - // mapOrderValue is a structured shallow value used only for deterministic - // map traversal. References are compared by identity; values by exact bits. + // mapOrderValue stores enough of a map key or value to sort entries. Pointers + // are compared by address and other values by their exact contents. mapOrderValue struct { typ reflect.Type kind reflect.Kind @@ -73,13 +72,10 @@ type ( } ) -var ( - dslFuncType = reflect.TypeFor[eval.DSLFunc]() - typeMapType = reflect.TypeFor[expr.TypeMap]() -) +var typeMapType = reflect.TypeFor[expr.TypeMap]() -// snapshotPreparedDesign captures every value reachable from roots after -// preparation and normalization have completed. +// snapshotPreparedDesign records every value reachable from roots after the +// designs have been prepared. func snapshotPreparedDesign(roots []eval.Root) (*designSnapshot, error) { snapshotter := &designSnapshotter{visited: make(map[designVisit]struct{})} for i, root := range roots { @@ -93,8 +89,8 @@ func snapshotPreparedDesign(roots []eval.Root) (*designSnapshot, error) { }, nil } -// changedPath returns the first deterministic semantic path whose value or -// reference topology differs from the prepared snapshot. +// orderedMapEntries returns map entries in an order that does not depend on +// how Go stores the map. func orderedMapEntries(value reflect.Value) ([]mapSnapshotEntry, error) { entries := make([]mapSnapshotEntry, 0, value.Len()) iterator := value.MapRange() @@ -128,9 +124,8 @@ func orderedMapEntries(value reflect.Value) ([]mapSnapshotEntry, error) { return entries, nil } -// validateMapOrderTypes rejects reflected types that have no stable ordering. -// This can only arise when separately constructed dynamic types have the same -// printed identity; silently tying them would expose randomized map iteration. +// validateMapOrderTypes rejects two runtime types that print the same name but +// cannot be compared. Treating them as equal would make map order vary by run. func validateMapOrderTypes(entries []mapSnapshotEntry) error { for i := range entries { for j := i + 1; j < len(entries); j++ { @@ -145,8 +140,8 @@ func validateMapOrderTypes(entries []mapSnapshotEntry) error { return nil } -// validateMapOrderType checks exact reflected type identity recursively, -// including concrete values stored below interface map keys and values. +// validateMapOrderType checks a runtime type and the concrete values stored in +// interface map keys and values. func validateMapOrderType(left, right mapOrderValue) error { if left.typ != right.typ && stableTypeName(left.typ) == stableTypeName(right.typ) { return fmt.Errorf("cannot deterministically order distinct reflected map types %q", stableTypeName(left.typ)) @@ -160,8 +155,8 @@ func validateMapOrderType(left, right mapOrderValue) error { return nil } -// mapValueOrder encodes comparable map keys and shallow value identity without -// traversing mutable targets. It is used only to make map traversal stable. +// mapValueOrder records enough of a map key or value to sort entries without +// reading through pointers. func mapValueOrder(value reflect.Value) (mapOrderValue, error) { if !value.IsValid() { return mapOrderValue{}, nil @@ -255,7 +250,7 @@ func mapValueOrder(value reflect.Value) (mapOrderValue, error) { return order, nil } -// compareMapOrderValue provides a total order over structured reflected values. +// compareMapOrderValue sorts the recorded runtime values. func compareMapOrderValue(left, right mapOrderValue) int { if left.typ != right.typ { return strings.Compare(stableTypeName(left.typ), stableTypeName(right.typ)) @@ -311,8 +306,7 @@ func compareMapOrderValue(left, right mapOrderValue) int { return len(left.children) - len(right.children) } -// stableTypeName is used only when distinct reflected types need map order. -// Exact type identity remains in the snapshot state itself. +// stableTypeName returns the package path and name used to sort runtime types. func stableTypeName(typ reflect.Type) string { if typ == nil { return "" @@ -320,7 +314,7 @@ func stableTypeName(typ reflect.Type) string { return typ.PkgPath() + ":" + typ.String() } -// mapEntryPath returns a readable diagnostic path without using its label for ordering. +// mapEntryPath returns the field path shown when a map entry changes. func mapEntryPath(path string, index int, key reflect.Value) string { if key.Kind() == reflect.String { return path + "[" + strconv.Quote(key.String()) + "]" @@ -328,10 +322,12 @@ func mapEntryPath(path string, index int, key reflect.Value) string { return fmt.Sprintf("%s{%d}", path, index) } -// formatPointer renders reference identity without treating it as order data. +// formatPointer returns a pointer address as text for a change report. func formatPointer(pointer uintptr) string { return "0x" + strconv.FormatUint(uint64(pointer), 16) } + +// changedPath returns the first design field that differs from the saved copy. func (s *designSnapshot) changedPath(roots []eval.Root) (string, error) { defer runtime.KeepAlive(s.references) @@ -467,12 +463,9 @@ func (s *designSnapshotter) appendValue(path string, value reflect.Value) error s.append(path, typ, "nil") return nil } - if typ != dslFuncType { - return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil function %s", path, typ) - } - // DSL evaluation has already completed. The function body and captured - // environment are dormant input, so only nilness and code identity are - // part of the prepared semantic design audit. + // Design evaluation has finished, so functions stored by Goa or a plugin + // will not run here. Record their type and pointer address without invoking + // them. s.append(path, typ, formatPointer(value.Pointer())) case reflect.Chan: if !value.IsNil() { @@ -490,9 +483,8 @@ func (s *designSnapshotter) appendValue(path string, value reflect.Value) error return nil } -// appendExternalType records the only semantic fact carried by a conversion -// exemplar: its exact dynamic Go type. Conversion generators never inspect -// the exemplar's runtime fields, locks, channels, or other instance state. +// appendExternalType records the concrete Go type of a conversion example. +// Generators do not read fields or other runtime state from that value. func (s *designSnapshotter) appendExternalType(path string, value reflect.Value) { if value.IsNil() { s.append(path, value.Type(), "nil") @@ -515,5 +507,5 @@ func (s *designSnapshotter) seen(visit designVisit) bool { return false } -// orderedMapEntries returns map pairs in an order derived from exact key and -// shallow value facts rather than Go's randomized iteration order. +// orderedMapEntries returns map pairs in the same order on every run without +// reading through pointers stored in the map. diff --git a/codegen/generator/design_snapshot_test.go b/codegen/generator/design_snapshot_test.go index 9c283a7066..0881ddbd12 100644 --- a/codegen/generator/design_snapshot_test.go +++ b/codegen/generator/design_snapshot_test.go @@ -21,7 +21,6 @@ func TestPreparedDesignSnapshotRejectsUnsupportedState(t *testing.T) { value any want string }{ - {"function", func() {}, "unsupported non-nil function"}, {"channel", make(chan int), "unsupported non-nil channel"}, {"unsafe pointer", unsafe.Pointer(&value), "unsupported non-nil unsafe pointer"}, } @@ -41,14 +40,16 @@ func TestPreparedDesignSnapshotRejectsUnsupportedState(t *testing.T) { } } -// TestPreparedDesignSnapshotAcceptsEvaluatedDSLFunctions proves dormant DSL -// closures remain valid prepared input after evaluation completes. -func TestPreparedDesignSnapshotAcceptsEvaluatedDSLFunctions(t *testing.T) { +// TestPreparedDesignSnapshotTracksFunctions proves unchanged functions remain +// valid while replacement and nilness changes are reported as mutations. +func TestPreparedDesignSnapshotTracksFunctions(t *testing.T) { + first := func(string) string { return "first" } + second := func(string) string { return "second" } root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ TypeName: "Value", AttributeExpr: &expr.AttributeExpr{ - Type: expr.String, - DSLFunc: eval.DSLFunc(func() {}), + Type: expr.String, + DefaultValue: first, }, }}} @@ -57,6 +58,35 @@ func TestPreparedDesignSnapshotAcceptsEvaluatedDSLFunctions(t *testing.T) { changed, err := snapshot.changedPath([]eval.Root{root}) require.NoError(t, err) require.Empty(t, changed) + + root.Types[0].Attribute().DefaultValue = second + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) + + root.Types[0].Attribute().DefaultValue = (func(string) string)(nil) + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) +} + +// TestPreparedDesignSnapshotDetectsNilFunctionReplacement proves a function +// added where the prepared design stored nil is reported as a mutation. +func TestPreparedDesignSnapshotDetectsNilFunctionReplacement(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: (func(string) string)(nil), + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + root.Types[0].Attribute().DefaultValue = func(string) string { return "added" } + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) } // TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken proves that diff --git a/codegen/generator/example.go b/codegen/generator/example.go index f7151adac9..a6e8f3fc92 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -1,73 +1,129 @@ -// This file assembles example service, server, and client files from frozen -// service analysis without mutating imports across unrelated output files. +// This file collects example service, server, and client files from the copied +// server data and the package names already chosen for this generation. package generator import ( + "fmt" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" + grpccodegen "goa.design/goa/v3/grpc/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// exampleFiles returns example service, server, and client files described by -// plan's frozen package declarations and run-owned example state. +// exampleFiles returns the service, server, and client examples selected for +// this generation. func exampleFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File - generation := plan.Generation() - designRoots := serviceRoots(generation.Roots()) - for _, r := range designRoots { - servicePlan := plan.Service(r) - services := servicePlan.Services() + for _, entry := range plan.example { + services := entry.service.Services() // example service implementation - if fs := service.ExampleServiceFiles(servicePlan); len(fs) != 0 { + if fs := service.ExampleServiceFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(servicePlan); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // server main - if fs := example.ServerFiles(r, services); len(fs) != 0 { + if fs := example.ServerFiles(entry.root, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(r); len(fs) != 0 { + if fs := example.CLIFiles(entry.root); len(fs) != 0 { files = append(files, fs...) } // HTTP - if httpPlan := plan.http[r]; httpPlan != nil { - if plan.jsonrpc[r] == nil { - if fs := httpPlan.ExampleServerFiles(); len(fs) != 0 { + if entry.http != nil { + if entry.jsonrpc == nil { + if fs := entry.http.ServerFiles(); len(fs) != 0 { files = append(files, fs...) } } - if fs := httpPlan.ExampleCLIFiles(); len(fs) != 0 { + if fs := entry.http.CLIFiles(); len(fs) != 0 { files = append(files, fs...) } } // JSON-RPC - if jsonrpcPlan := plan.jsonrpc[r]; jsonrpcPlan != nil { - if fs := jsonrpcPlan.ExampleServerFiles(); len(fs) > 0 { + if entry.jsonrpc != nil { + if fs := entry.jsonrpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := jsonrpcPlan.ExampleCLIFiles(); len(fs) > 0 { + if fs := entry.jsonrpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } // GRPC - if grpcPlan := plan.grpc[r]; grpcPlan != nil { - if fs := grpcPlan.ExampleServerFiles(); len(fs) > 0 { + if entry.grpc != nil { + if fs := entry.grpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := grpcPlan.ExampleCLIFiles(); len(fs) > 0 { + if fs := entry.grpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } } return files, nil } + +// planExampleData copies the server information used by example programs and +// prepares the selected transports. +func planExampleData(plan *Plan) error { + if err := planTransportData(plan); err != nil { + return err + } + roots := serviceRoots(plan.preparedRoots) + services := make([]*service.Plan, len(roots)) + for index, root := range roots { + services[index] = plan.Service(root) + } + examplePlan, err := example.NewPlan(plan.Generation(), services...) + if err != nil { + return err + } + plan.example = make([]*examplePlanEntry, len(roots)) + for index, root := range roots { + var httpExamples *httpcodegen.ExamplePlan + if transport := plan.http[root]; transport != nil { + httpExamples, err = httpcodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var jsonrpcExamples *jsonrpccodegen.ExamplePlan + if transport := plan.jsonrpc[root]; transport != nil { + jsonrpcExamples, err = jsonrpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var grpcExamples *grpccodegen.ExamplePlan + if transport := plan.grpc[root]; transport != nil { + grpcExamples, err = grpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + rootData, ok := examplePlan.Root(services[index]) + if !ok { + return fmt.Errorf("example plan does not contain server data for API %q", root.API.Name) + } + plan.example[index] = &examplePlanEntry{ + source: root, + root: rootData, + service: services[index], + http: httpExamples, + jsonrpc: jsonrpcExamples, + grpc: grpcExamples, + } + } + return nil +} diff --git a/codegen/generator/example_cli_input_stream_compile_test.go b/codegen/generator/example_cli_input_stream_compile_test.go new file mode 100644 index 0000000000..980fdd4cbf --- /dev/null +++ b/codegen/generator/example_cli_input_stream_compile_test.go @@ -0,0 +1,73 @@ +// This file compiles generated HTTP and gRPC example clients whose methods +// require streamed input. The example client must reject those commands +// without leaving unused endpoint values in the generated program. +package generator + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleInputStreamClientsCompile generates client-streaming and +// bidirectional commands for HTTP and gRPC, then compiles the generated client. +func TestExampleInputStreamClientsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("gRPC input streams", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + dsl.URI("grpc://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("exchange", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.POST("/exchange") + }) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_cli_result_runtime_test.go b/codegen/generator/example_cli_result_runtime_test.go new file mode 100644 index 0000000000..84ebb4663d --- /dev/null +++ b/codegen/generator/example_cli_result_runtime_test.go @@ -0,0 +1,238 @@ +// This file runs the result writers emitted into generated example clients. +// The test covers values received from a server stream and the errors returned +// when receiving or writing fails. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleCLIResultWritersRun verifies the generated helpers against real +// endpoint functions, stream receive functions, and writers. +func TestExampleCLIResultWritersRun(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("example stream", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/events") + }) + }) + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/events") + }) + }) + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + testPath := filepath.Join(directory, "cmd", "stream-cli", "result_writer_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(exampleCLIResultWriterTest), 0o600)) + runGeneratedTests(t, directory) +} + +const exampleCLIResultWriterTest = `package main + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + goa "goa.design/goa/v3/pkg" +) + +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestWriteEndpointResult(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return map[string]string{"message": "hello"}, nil + }) + var output bytes.Buffer + if err := writeEndpointResult(context.Background(), &output, endpoint, nil); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "{\n \"message\": \"hello\"\n}\n"; got != want { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestWriteEndpointResultReturnsEncodingError(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return func() {}, nil + }) + err := writeEndpointResult(context.Background(), io.Discard, endpoint, nil) + if err == nil { + t.Fatal("expected JSON encoding error") + } +} + +func TestWriteStreamResults(t *testing.T) { + values := []string{"first", "second"} + next := 0 + recv := func(context.Context) (string, error) { + if next == len(values) { + return "", io.EOF + } + value := values[next] + next++ + return value, nil + } + var output bytes.Buffer + if err := writeStreamResults(context.Background(), &output, recv); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"first\"\n\"second\"\n"; got != want { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestWriteStreamResultsReturnsReceiveError(t *testing.T) { + want := errors.New("receive failed") + recv := func(context.Context) (string, error) { + return "", want + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped receive error", err) + } +} + +func TestWriteStreamResultsDoesNotHideFailureJoinedWithEOF(t *testing.T) { + want := errors.New("close failed") + recv := func(context.Context) (string, error) { + return "", errors.Join(io.EOF, want) + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped close error", err) + } +} + +func TestWriteStreamResultsReturnsOutputError(t *testing.T) { + want := errors.New("write failed") + called := false + recv := func(context.Context) (string, error) { + if called { + return "", io.EOF + } + called = true + return "value", nil + } + err := writeStreamResults(context.Background(), failingWriter{err: want}, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped output error", err) + } +} + +func TestInputStreamIsRejectedBeforeCallingEndpoint(t *testing.T) { + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "upload"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + err := doHTTP(context.Background(), "http", "127.0.0.1:1", 1, false, io.Discard) + want := "example client does not support streamed input for service \"events\" method \"upload\"" + if err == nil || err.Error() != want { + t.Fatalf("got %v, want %q", err, want) + } +} + +func TestMixedHTTPResultUsesNormalResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, "\"created\""); err != nil { + t.Error(err) + } + })) + defer server.Close() + + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "create"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + err := doHTTP(context.Background(), "http", strings.TrimPrefix(server.URL, "http://"), 1, false, &output) + if err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"created\"\n"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} +` diff --git a/codegen/generator/example_handler_args_integration_test.go b/codegen/generator/example_handler_args_integration_test.go new file mode 100644 index 0000000000..32d986e3a9 --- /dev/null +++ b/codegen/generator/example_handler_args_integration_test.go @@ -0,0 +1,48 @@ +// This file checks that generated starter servers pass transport arguments in +// the same order accepted by their generated helper functions. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestJSONRPCOnlyExampleServerCompiles checks a server whose two services are +// both exposed only through JSON-RPC. +func TestJSONRPCOnlyExampleServerCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + for _, name := range []string{"First", "Second"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_immutability_test.go b/codegen/generator/example_immutability_test.go new file mode 100644 index 0000000000..92d8e1cff8 --- /dev/null +++ b/codegen/generator/example_immutability_test.go @@ -0,0 +1,52 @@ +// This file checks that example generation uses only values copied before Go +// names are finalized. +package generator + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/eval" +) + +// TestExampleFilesDoNotReadChangedServerDesign checks that changing the API or +// server after planning cannot change any example file. +func TestExampleFilesDoNotReadChangedServerDesign(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + + beforeFiles, err := exampleFiles(plan) + require.NoError(t, err) + before := renderExampleFiles(t, beforeFiles) + + server := root.API.Servers[0] + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + root.API.Servers = nil + + afterFiles, err := exampleFiles(plan) + require.NoError(t, err) + require.Equal(t, before, renderExampleFiles(t, afterFiles)) +} + +// renderExampleFiles writes each section and indexes the complete text by file +// path. +func renderExampleFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/codegen/generator/example_output_preservation_integration_test.go b/codegen/generator/example_output_preservation_integration_test.go new file mode 100644 index 0000000000..4ed3d98a39 --- /dev/null +++ b/codegen/generator/example_output_preservation_integration_test.go @@ -0,0 +1,109 @@ +// This file checks that starter files are preserved relative to the requested +// output directory, even when generation starts in a different directory. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestExampleGenerationUsesOutputDirectoryForPreservation(t *testing.T) { + paths := []string{ + "status.go", + filepath.Join("interceptors", "status_server.go"), + filepath.Join("interceptors", "status_client.go"), + "multipart.go", + filepath.Join("cmd", "preserve", "main.go"), + filepath.Join("cmd", "preserve", "http.go"), + filepath.Join("cmd", "preserve", "grpc.go"), + filepath.Join("cmd", "preserve-cli", "main.go"), + filepath.Join("cmd", "preserve-cli", "http.go"), + filepath.Join("cmd", "preserve-cli", "grpc.go"), + } + preserved := map[string][]byte{ + "status.go": []byte("existing service\n"), + filepath.Join("interceptors", "status_server.go"): []byte("existing interceptor\n"), + "multipart.go": []byte("existing multipart helpers\n"), + filepath.Join("cmd", "preserve", "grpc.go"): []byte("existing server\n"), + filepath.Join("cmd", "preserve-cli", "http.go"): []byte("existing client\n"), + } + tests := []struct { + name string + existing map[string][]byte + }{ + {name: "output is empty"}, + {name: "output has starter files", existing: preserved}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + codegen.RunDSL(t, exampleOutputPreservationDSL) + launchDir := t.TempDir() + outputDir := t.TempDir() + writeGeneratedModule(t, filepath.Join(outputDir, codegen.Gendir), "generated.local/gen") + writeExampleFiles(t, launchDir, paths, []byte("file from launch directory\n")) + for path, content := range test.existing { + writeExampleFile(t, outputDir, path, content) + } + + t.Chdir(launchDir) + _, err := generate(outputDir, "example", false, newDefaultRegistry()) + require.NoError(t, err) + + for _, path := range paths { + content, err := os.ReadFile(filepath.Join(outputDir, path)) + require.NoError(t, err, path) + if existing, ok := test.existing[path]; ok { + require.Equal(t, existing, content, path) + } else { + require.NotEqual(t, []byte("file from launch directory\n"), content, path) + } + } + }) + } +} + +// exampleOutputPreservationDSL exercises every starter file producer that +// preserves user-written files. +func exampleOutputPreservationDSL() { + trace := d.Interceptor("trace") + d.API("preserve", func() {}) + d.Service("status", func() { + d.ServerInterceptor(trace) + d.ClientInterceptor(trace) + d.Method("upload", func() { + d.Payload(func() { + d.Field(1, "message", d.String) + }) + d.Result(d.String) + d.HTTP(func() { + d.POST("/upload") + d.MultipartRequest() + }) + d.GRPC(func() {}) + }) + }) +} + +// writeExampleFiles writes the same misleading content at every relative path +// in the directory where generation starts. +func writeExampleFiles(t *testing.T, dir string, paths []string, content []byte) { + t.Helper() + for _, path := range paths { + writeExampleFile(t, dir, path, content) + } +} + +// writeExampleFile writes one fixture file and creates its parent directory. +func writeExampleFile(t *testing.T, dir, path string, content []byte) { + t.Helper() + fullPath := filepath.Join(dir, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o750)) + require.NoError(t, os.WriteFile(fullPath, content, 0o600)) +} diff --git a/codegen/generator/example_plan_test.go b/codegen/generator/example_plan_test.go new file mode 100644 index 0000000000..61e066db76 --- /dev/null +++ b/codegen/generator/example_plan_test.go @@ -0,0 +1,79 @@ +// This file checks that plugins receive a separate copy of the example server +// description retained for the exact prepared design root. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" +) + +func TestExampleReturnsSeparateCopyForExactRoot(t *testing.T) { + root := &expr.RootExpr{} + transport := &example.TransportData{ + Type: example.TransportHTTP, + Name: "HTTP", + Services: []string{"calc"}, + } + variable := &example.VariableData{ + Name: "version", + Description: "API version", + DefaultValue: "v1", + Values: []string{"v1", "v2"}, + } + planned := &example.Root{ + APIName: "calc", + Services: []string{"calc"}, + Servers: []*example.Data{{ + Name: "edge", + Description: "public server", + Services: []string{"calc"}, + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + Transports: []*example.TransportData{transport}, + Hosts: []*example.HostData{{ + Name: "development", + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + URIs: []*example.URIData{{ + URL: "http://localhost/{version}", + Scheme: "http", + Port: "80", + Transport: transport, + HandlerArgs: []example.HandlerArg{{ + Service: "calc", + Endpoint: true, + }}, + }}, + }}, + }}, + } + plan := &Plan{example: []*examplePlanEntry{{source: root, root: planned}}} + + got, ok := plan.Example(root) + require.True(t, ok) + require.Equal(t, planned, got) + require.NotSame(t, planned, got) + require.NotSame(t, planned.Servers[0], got.Servers[0]) + require.NotSame(t, planned.Servers[0].Hosts[0], got.Servers[0].Hosts[0]) + require.NotSame(t, variable, got.Servers[0].Variables[0]) + require.Same(t, got.Servers[0].Variables[0], got.Servers[0].Hosts[0].Variables[0]) + require.NotSame(t, transport, got.Servers[0].Transports[0]) + require.Same(t, got.Servers[0].Transports[0], got.Servers[0].Hosts[0].URIs[0].Transport) + + got.Services[0] = "changed" + got.Servers[0].Services[0] = "changed" + got.Servers[0].Variables[0].Values[0] = "changed" + got.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service = "changed" + require.Equal(t, "calc", planned.Services[0]) + require.Equal(t, "calc", planned.Servers[0].Services[0]) + require.Equal(t, "v1", variable.Values[0]) + require.Equal(t, "calc", planned.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service) + + got, ok = plan.Example(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/example_snapshot.go b/codegen/generator/example_snapshot.go new file mode 100644 index 0000000000..39a7678227 --- /dev/null +++ b/codegen/generator/example_snapshot.go @@ -0,0 +1,121 @@ +// This file copies the example server description exposed to plugins so a +// plugin cannot change the values retained by Goa for the current run. +package generator + +import "goa.design/goa/v3/codegen/example" + +// copyExampleRoot copies every exported slice and nested value that a plugin +// can read or change through the example plan API. +func copyExampleRoot(source *example.Root) *example.Root { + if source == nil { + return nil + } + copy := &example.Root{ + APIName: source.APIName, + Services: append([]string(nil), source.Services...), + Servers: make([]*example.Data, len(source.Servers)), + } + for index, server := range source.Servers { + copy.Servers[index] = copyExampleServer(server) + } + return copy +} + +// copyExampleServer preserves shared variable and transport pointers within +// one server while separating them from the retained plan. +func copyExampleServer(source *example.Data) *example.Data { + if source == nil { + return nil + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copy.Schemes = append([]string(nil), source.Schemes...) + variables := make(map[*example.VariableData]*example.VariableData, len(source.Variables)) + copy.Variables = copyExampleVariables(source.Variables, variables) + transports := make(map[*example.TransportData]*example.TransportData, len(source.Transports)) + copy.Transports = copyExampleTransports(source.Transports, transports) + copy.Hosts = make([]*example.HostData, len(source.Hosts)) + for index, host := range source.Hosts { + copy.Hosts[index] = copyExampleHost(host, variables, transports) + } + return © +} + +// copyExampleHost copies one host and reuses the copied server values referred +// to by its variables and URLs. +func copyExampleHost( + source *example.HostData, + variables map[*example.VariableData]*example.VariableData, + transports map[*example.TransportData]*example.TransportData, +) *example.HostData { + if source == nil { + return nil + } + copy := *source + copy.Schemes = append([]string(nil), source.Schemes...) + copy.Variables = copyExampleVariables(source.Variables, variables) + copy.URIs = make([]*example.URIData, len(source.URIs)) + for index, uri := range source.URIs { + if uri == nil { + continue + } + uriCopy := *uri + uriCopy.HandlerArgs = append([]example.HandlerArg(nil), uri.HandlerArgs...) + uriCopy.Transport = copyExampleTransport(uri.Transport, transports) + copy.URIs[index] = &uriCopy + } + return © +} + +// copyExampleVariables copies variables once so server and host lists still +// refer to the same copied value. +func copyExampleVariables( + sources []*example.VariableData, + copies map[*example.VariableData]*example.VariableData, +) []*example.VariableData { + result := make([]*example.VariableData, len(sources)) + for index, source := range sources { + if source == nil { + continue + } + copy := copies[source] + if copy == nil { + value := *source + value.Values = append([]string(nil), source.Values...) + copy = &value + copies[source] = copy + } + result[index] = copy + } + return result +} + +// copyExampleTransports copies transports once so server and URL descriptions +// still refer to the same copied value. +func copyExampleTransports( + sources []*example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) []*example.TransportData { + result := make([]*example.TransportData, len(sources)) + for index, source := range sources { + result[index] = copyExampleTransport(source, copies) + } + return result +} + +// copyExampleTransport returns the copied form of one transport description. +func copyExampleTransport( + source *example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) *example.TransportData { + if source == nil { + return nil + } + if copy := copies[source]; copy != nil { + return copy + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copies[source] = © + return © +} diff --git a/codegen/generator/example_state_test.go b/codegen/generator/example_state_test.go index 3a75af0e20..a6e48e0c81 100644 --- a/codegen/generator/example_state_test.go +++ b/codegen/generator/example_state_test.go @@ -37,7 +37,7 @@ func TestGenerationRejectsFactoryMutationWhenStreamIsCreated(t *testing.T) { }} }) - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) require.ErrorContains(t, err, `core "examples" plan mutated prepared design`) } @@ -64,7 +64,7 @@ func TestGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { }) for range 2 { - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) require.NoError(t, err) } @@ -123,7 +123,7 @@ func TestConcurrentGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { runs.Add(1) go func(root *expr.RootExpr) { defer runs.Done() - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) errs <- err }(root) } diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index f416e4ef98..a2df02b250 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -1,7 +1,8 @@ // The goa command calls this file with an output directory, command, and debug // flag; it reads the evaluated design roots and returns the files it wrote. -// Every core generator and plugin plans against one Generation, which is frozen -// before any callback renders files or can add another declaration. +// Every core generator and plugin plans against one Generation. Before any +// callback renders files, Goa chooses every package and declaration name and +// rejects attempts to add another declaration. package generator import ( @@ -97,8 +98,9 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string } } - // 3. Prepare roots, build and freeze one plan, then render core and plugin - // files through the fresh run objects instantiated before root evaluation. + // 3. Prepare roots and build one plan. Choose every package and declaration + // name, then render core and plugin files through the fresh run objects + // created before root evaluation. startLifecycle := time.Now() result, err := run.execute(genpkg, roots) if err != nil { @@ -159,6 +161,9 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string for work := range workChan { renderStart := time.Now() filename, err := work.file.Render(dir) + if err != nil { + err = fmt.Errorf("render %s: %w", work.file.Path, err) + } resultChan <- renderResult{ index: work.index, filename: filename, @@ -237,7 +242,7 @@ func generate(dir, cmd string, debug bool, registry *registry) (outputs []string } // generatedPackageImportPath asks Go to identify the package in dir and -// returns the exact canonical path that generated files can import. +// returns its cleaned import path for generated files. func generatedPackageImportPath(dir string) (string, error) { pkgs, err := packages.Load(&packages.Config{ Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, @@ -277,7 +282,7 @@ func generatedPackageImportPath(dir string) (string, error) { } // gopathOwnsImportPath asks the Go command for its effective GOPATH and reports -// whether dir has the exact import identity it claims beneath a source root. +// whether dir appears at importPath beneath one of GOPATH's source directories. func gopathOwnsImportPath(dir, importPath string) (bool, error) { output, err := exec.Command("go", "env", "GOPATH").Output() if err != nil { @@ -459,9 +464,9 @@ func recordImportSpec(paths, names map[string]string, spec *codegen.ImportSpec) return false, nil } -// canonicalOutputFilePath returns the one portable relative spelling used to -// group and render a generated file. The second result is case-folded so two -// paths cannot overwrite one another on a case-insensitive filesystem. +// canonicalOutputFilePath cleans rawPath into the portable relative path used +// to group and render a generated file. The second result is case-folded so +// two paths cannot overwrite one another on a case-insensitive filesystem. func canonicalOutputFilePath(rawPath string) (string, string, error) { portable := filepath.ToSlash(rawPath) portable = strings.ReplaceAll(portable, `\`, "/") diff --git a/codegen/generator/generate_grpc_cli_collision_integration_test.go b/codegen/generator/generate_grpc_cli_collision_integration_test.go new file mode 100644 index 0000000000..ba672dadf3 --- /dev/null +++ b/codegen/generator/generate_grpc_cli_collision_integration_test.go @@ -0,0 +1,68 @@ +// This file verifies that a generated gRPC command parser calls the exact +// client constructor selected for its generated client package. +package generator + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +type grpcClientCollisionOrder string + +// ComparePackageName orders the declaration added by this collision test. +func (o grpcClientCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(grpcClientCollisionOrder))) +} + +func TestGeneratedGRPCCLIUsesFinalClientConstructorName(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + reserve := func(plan *Plan) error { + clientPackage, err := plan.Generation().ClaimPackage(path.Join( + "generated.local/gen", "grpc", "records", "client", + )) + if err != nil { + return err + } + return clientPackage.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "NewClient", + codegen.ExportedName, + grpcClientCollisionOrder("plugin-client-constructor"), + )) + } + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + reserve, + planTransportData, + ) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_required_array_alias_integration_test.go b/codegen/generator/generate_grpc_required_array_alias_integration_test.go new file mode 100644 index 0000000000..42f155d33d --- /dev/null +++ b/codegen/generator/generate_grpc_required_array_alias_integration_test.go @@ -0,0 +1,42 @@ +// This file verifies that generated gRPC codecs compile for required arrays +// whose service elements are primitive aliases. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +// TestGenerateGRPCRequiredPrimitiveAliasArray proves the service-to-protobuf +// and protobuf-to-service conversions preserve required string alias elements. +func TestGenerateGRPCRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + _ = codegen.RunDSL(t, func() { + alias := d.Type("Alias", d.String) + payload := d.Type("Payload", func() { + d.Field(1, "values", d.ArrayOfRequired(alias)) + d.Required("values") + }) + d.Service("Aliases", func() { + d.Method("Store", func() { + d.Payload(payload) + d.GRPC(func() {}) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + if _, err := generate(filepath.Dir(directory), "gen", false, registry); err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_required_union_validation_integration_test.go b/codegen/generator/generate_grpc_required_union_validation_integration_test.go index d50c124038..d258db396c 100644 --- a/codegen/generator/generate_grpc_required_union_validation_integration_test.go +++ b/codegen/generator/generate_grpc_required_union_validation_integration_test.go @@ -138,6 +138,7 @@ func TestClientResponseValidator(t *testing.T) { assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Blob{}}), "blob", "\"blob\" is missing from message.choice") } +// assertErrorName checks that generated validation returned the expected Goa error name. func assertErrorName(t *testing.T, err error, name string) { t.Helper() if err == nil { @@ -154,6 +155,7 @@ func assertErrorName(t *testing.T, err error, name string) { } } +// assertMissingField checks the error name, field, and message returned for a missing protobuf value. func assertMissingField(t *testing.T, err error, field, message string) { t.Helper() if err == nil { diff --git a/codegen/generator/generate_http_error_result_integration_test.go b/codegen/generator/generate_http_error_result_integration_test.go new file mode 100644 index 0000000000..3ad194c2a8 --- /dev/null +++ b/codegen/generator/generate_http_error_result_integration_test.go @@ -0,0 +1,45 @@ +// This file verifies that HTTP clients keep Goa's built-in service error type +// after the transport generator copies method error expressions. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPErrorResultAndCustomError(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + custom := dsl.Type("CustomError", func() { + dsl.ErrorName("name", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("name", "message") + }) + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Error("not_found") + dsl.Error("rejected", custom) + dsl.HTTP(func() { + dsl.GET("/records") + dsl.Response("not_found", dsl.StatusNotFound) + dsl.Response("rejected", dsl.StatusBadRequest) + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate HTTP errors: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_http_multipart_validation_integration_test.go b/codegen/generator/generate_http_multipart_validation_integration_test.go new file mode 100644 index 0000000000..77868f32d4 --- /dev/null +++ b/codegen/generator/generate_http_multipart_validation_integration_test.go @@ -0,0 +1,205 @@ +// This file checks complete multipart generation, including the starter +// decoder signatures and the validation that runs before payload construction. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateHTTPMultipartValidationCompilesAndRuns verifies generated +// object, array, and map bodies together with the generated starter decoder. +func TestGenerateHTTPMultipartValidationCompilesAndRuns(t *testing.T) { + root := codegen.RunDSL(t, multipartValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + writeMultipartValidationRuntimeTest(t, directory) + runGeneratedTests(t, directory) +} + +// multipartValidationDSL defines one object body with mapped request values +// and two composite bodies that exercise generated callback signatures. +func multipartValidationDSL() { + part := d.Type("Part", func() { + d.Attribute("code", d.String) + d.Required("code") + }) + objectPayload := d.Type("ObjectPayload", func() { + d.Attribute("name", d.String) + d.Attribute("part", part) + d.Attribute("site", d.String) + d.Attribute("count", d.Int) + d.Attribute("token", d.String) + d.Required("name", "part", "site", "count") + }) + d.Service("upload", func() { + d.Method("Object", func() { + d.Payload(objectPayload) + d.HTTP(func() { + d.POST("/objects/{site}") + d.Param("count") + d.Header("token:X-Token") + d.MultipartRequest() + }) + }) + d.Method("Array", func() { + d.Payload(d.ArrayOf(part)) + d.HTTP(func() { + d.POST("/array") + d.MultipartRequest() + }) + }) + d.Method("Map", func() { + d.Payload(d.MapOf(d.String, d.Int)) + d.HTTP(func() { + d.POST("/map") + d.MultipartRequest() + }) + }) + }) +} + +// writeMultipartValidationRuntimeTest adds assertions against the generated +// request decoder so validation order and payload construction are exercised. +func writeMultipartValidationRuntimeTest(t *testing.T, directory string) { + t.Helper() + const source = `package multiparttest_test + +import ( + "errors" + "net/http" + "testing" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + genserver "generated.local/gen/http/upload/server" +) + +type mux struct{} + +func (mux) Handle(string, string, http.HandlerFunc) {} +func (mux) ServeHTTP(http.ResponseWriter, *http.Request) {} +func (mux) Vars(*http.Request) map[string]string { return map[string]string{"site": "west"} } + +func TestObjectValidationRunsBeforeConstruction(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/objects/west?count=2", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Token", "secret") + + missingName := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Part = &genserver.PartRequestBody{Code: &code} + })) + _, err = missingName(request) + assertMissingField(t, err, "name", "body") + + name := "report" + missingCode := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{} + })) + _, err = missingCode(request) + assertMissingField(t, err, "code", "body.part") + + valid := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{Code: &code} + })) + payload, err := valid(request) + if err != nil { + t.Fatalf("valid multipart body failed: %v", err) + } + if payload.Name != name || payload.Part.Code != code || payload.Site != "west" || payload.Count != 2 { + t.Fatalf("unexpected payload: %#v", payload) + } + if payload.Token == nil || *payload.Token != "secret" { + t.Fatalf("mapped header was not preserved: %#v", payload.Token) + } +} + +func TestArrayAndMapBodiesConstructPayloads(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/", nil) + if err != nil { + t.Fatal(err) + } + decodeArray := genserver.DecodeArrayRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*[]*genserver.PartRequestBody) + *body = []*genserver.PartRequestBody{{Code: &code}} + return nil + }) + }) + array, err := decodeArray(request) + if err != nil || len(array) != 1 || array[0].Code != code { + t.Fatalf("unexpected array payload: %#v, %v", array, err) + } + + decodeMap := genserver.DecodeMapRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*map[string]int) + *body = map[string]int{"count": 2} + return nil + }) + }) + values, err := decodeMap(request) + if err != nil || values["count"] != 2 { + t.Fatalf("unexpected map payload: %#v, %v", values, err) + } +} + +func bodyDecoder(fill func(*genserver.ObjectRequestBody)) func(*http.Request) goahttp.Decoder { + return func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + fill(value.(*genserver.ObjectRequestBody)) + return nil + }) + } +} + +func assertMissingField(t *testing.T, err error, field, location string) { + t.Helper() + if err == nil { + t.Fatalf("expected missing field %q", field) + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Fatalf("expected Goa service error, got %T: %v", err, err) + } + if serviceError.Name != goa.MissingField || serviceError.Field == nil || *serviceError.Field != field { + t.Fatalf("unexpected missing field error: %#v", serviceError) + } + if serviceError.Message != "\""+field+"\" is missing from "+location { + t.Fatalf("unexpected message: %q", serviceError.Message) + } +} +` + dir := filepath.Join(directory, "multiparttest") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "multipart_validation_test.go"), []byte(source), 0o600)) +} diff --git a/codegen/generator/generate_http_required_array_alias_integration_test.go b/codegen/generator/generate_http_required_array_alias_integration_test.go new file mode 100644 index 0000000000..f07c4afc0e --- /dev/null +++ b/codegen/generator/generate_http_required_array_alias_integration_test.go @@ -0,0 +1,120 @@ +// This file verifies that HTTP validation keeps null array elements visible +// without changing primitive alias elements in service values. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestGenerateHTTPRequiredPrimitiveAliasArray checks the generated service and +// HTTP packages for an array whose string alias elements cannot be null. +func TestGenerateHTTPRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + alias := dsl.Type("Alias", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + nested := dsl.Type("Nested", func() { + dsl.Field(1, "values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + payload := dsl.Type("StorePayload", func() { + dsl.Field(1, "names", dsl.ArrayOfRequired(dsl.String)) + dsl.Field(2, "values", dsl.ArrayOfRequired(alias)) + dsl.Field(3, "nested", nested) + dsl.Required("names", "values", "nested") + }) + searchPayload := dsl.Type("SearchPayload", func() { + dsl.Attribute("values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + dsl.Service("Aliases", func() { + dsl.Method("Store", func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/aliases") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Search", func() { + dsl.Payload(searchPayload) + dsl.HTTP(func() { + dsl.GET("/aliases") + dsl.Param("values") + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + writeGeneratedContractTest( + t, + directory, + filepath.Join("http", "aliases", "server"), + requiredPrimitiveAliasArrayRuntimeTest, + ) + runGeneratedTests(t, directory) +} + +const requiredPrimitiveAliasArrayRuntimeTest = `package server + +import ( + "encoding/json" + "testing" + + aliases "generated.local/gen/aliases" + goa "goa.design/goa/v3/pkg" +) + +func TestRequiredPrimitiveArrayElements(t *testing.T) { + var valid StoreRequestBody + if err := json.Unmarshal([]byte("{\"names\":[\"\"],\"values\":[\"\"],\"nested\":{\"values\":[\"\"]}}"), &valid); err != nil { + t.Fatalf("decode valid body: %v", err) + } + if err := ValidateStoreRequestBody(&valid); err != nil { + t.Fatalf("validate empty strings: %v", err) + } + payload := NewStorePayload(&valid) + if len(payload.Names) != 1 || payload.Names[0] != "" { + t.Fatalf("converted names = %#v", payload.Names) + } + if len(payload.Values) != 1 || payload.Values[0] != aliases.Alias("") { + t.Fatalf("converted aliases = %#v", payload.Values) + } + if payload.Nested == nil || len(payload.Nested.Values) != 1 || payload.Nested.Values[0] != aliases.Alias("") { + t.Fatalf("converted nested aliases = %#v", payload.Nested) + } + + assertNullElement := func(body string, context string) { + t.Helper() + var decoded StoreRequestBody + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("decode null element: %v", err) + } + err := ValidateStoreRequestBody(&decoded) + if err == nil { + t.Fatal("null element passed validation") + } + want := goa.MissingFieldError(context, "[*]").Error() + if err.Error() != want { + t.Fatalf("validation error = %q, want %q", err, want) + } + } + assertNullElement("{\"names\":[null],\"values\":[\"ok\"],\"nested\":{\"values\":[\"ok\"]}}", "body.names") + assertNullElement("{\"names\":[\"ok\"],\"values\":[null],\"nested\":{\"values\":[\"ok\"]}}", "body.values") + assertNullElement("{\"names\":[\"ok\"],\"values\":[\"ok\"],\"nested\":{\"values\":[null]}}", "body.nested.values") +} +` diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go index bfa44284d7..cc2b252672 100644 --- a/codegen/generator/generate_http_union_shape_integration_test.go +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -82,20 +82,24 @@ func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { t.Fatalf("read generated server types: %v", err) } code := string(content) - if strings.Count(code, "type Scope struct {") != 1 { - t.Fatalf("expected one request Scope declaration:\n%s", code) - } - if strings.Count(code, "type Scope2 struct {") != 1 { - t.Fatalf("expected one response Scope2 declaration:\n%s", code) + if strings.Count(code, "type Scope struct {") != 1 || + strings.Count(code, "type Scope2 struct {") != 1 { + t.Fatalf("expected one request union and one response union:\n%s", code) } if strings.Contains(code, "type Scope3 struct {") { t.Fatalf("identical request derivation produced a third union declaration:\n%s", code) } - if strings.Contains(code, "SiteSetRequestBody") { - t.Fatalf("identical request derivation produced a second branch declaration:\n%s", code) + if strings.Count(code, "type SiteSetRequestBody struct {") != 1 || + strings.Count(code, "type SiteSetResponseBody struct {") != 1 { + t.Fatalf("expected one request branch and one response branch declaration:\n%s", code) + } + if !strings.Contains(code, "Scope *Scope `") || + !strings.Contains(code, "Scope Scope2 `") { + t.Fatalf("request and response bodies do not use their released union names:\n%s", code) } - if strings.Count(code, "\tSiteSet *SiteSet\n") != 1 { - t.Fatalf("request union does not reference its canonical branch declaration:\n%s", code) + if strings.Count(code, "\tSiteSet *SiteSetRequestBody\n") != 1 || + strings.Count(code, "\tSiteSet *SiteSetResponseBody\n") != 1 { + t.Fatalf("unions do not reference their request and response branches:\n%s", code) } } diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index 07ac0dfeb9..9eb5026454 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -22,7 +22,7 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { "gen", testGenerator(planServiceData, testServiceFiles), testGenerator(planTransportData, testTransportFiles), - testGenerator(planServiceData, testOpenAPIFiles), + testGenerator(planOpenAPIData, testOpenAPIFiles), ) dsl := func() { diff --git a/codegen/generator/generated_grpc_shared_package_integration_test.go b/codegen/generator/generated_grpc_shared_package_integration_test.go new file mode 100644 index 0000000000..c3fd778f32 --- /dev/null +++ b/codegen/generator/generated_grpc_shared_package_integration_test.go @@ -0,0 +1,128 @@ +// This file checks two gRPC designs that write service, client, and server +// files into the same directories. Every written file must use the names +// chosen for both designs before generation starts. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots checks both design +// orders because input order must not change the names in shared directories. +func TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := grpcSharedPackageRoot(t, "Shared", "First") + second := grpcSharedPackageRoot(t, "Shared", "Second") + roots := []eval.Root{first, second} + if test.reverse { + roots[0], roots[1] = roots[1], roots[0] + } + + reserveValidator := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/grpc/shared/server") + if err != nil { + return err + } + for _, name := range []string{"ValidateSyncRequest", "ValidateExchangeRequest", "ValidateExchangeStreamingRequest"} { + if err := pkg.DeclareName(codegen.NewExactName(codegen.NameFunction, name)); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", roots, planTransportData, reserveValidator) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + files = append(files, &codegen.File{ + Path: "gen/grpc/shared/server/validator_owner.go", + SectionTemplates: []*codegen.SectionTemplate{ + { + Name: "validator-owner", + Source: `package server + +func ValidateSyncRequest() {} +func ValidateExchangeRequest() {} +func ValidateExchangeStreamingRequest() {}`, + }, + }, + }) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// grpcSharedPackageRoot returns one design whose service name chooses the +// output directories. typePrefix keeps its values separate from the other +// design that writes into those directories. +func grpcSharedPackageRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) + node := dsl.Type(typePrefix+"Node", func() { + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "next", typePrefix+"Node") + dsl.Required("name") + }) + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Field(1, "id", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("id", "node") + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Field(1, "status", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("status", "node") + }) + failure := dsl.Type(typePrefix+"Failure", func() { + dsl.Field(1, "message", dsl.String) + dsl.Required("message") + }) + + dsl.Service(serviceName, func() { + dsl.Error("failed", failure) + dsl.GRPC(func() { + dsl.Response("failed", dsl.CodeInvalidArgument) + }) + dsl.Method("Sync", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + dsl.Method("Exchange", func() { + dsl.Payload(payload) + dsl.StreamingPayload(payload) + dsl.StreamingResult(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + }) + }) +} diff --git a/codegen/generator/generated_service_path_integration_test.go b/codegen/generator/generated_service_path_integration_test.go new file mode 100644 index 0000000000..16791f6208 --- /dev/null +++ b/codegen/generator/generated_service_path_integration_test.go @@ -0,0 +1,125 @@ +// This file checks that every generated transport and example uses the service +// directory selected by the shared service plan. +package generator + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedTransportsUsePlannedServicePaths checks both design orders so +// adding a suffix to one service directory cannot redirect another service's +// HTTP, JSON-RPC, gRPC, command-line, or example imports. +func TestGeneratedTransportsUsePlannedServicePaths(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := servicePathRoot(t, test.reverse) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + expected := map[string]string{ + "read-value": "read_value", + "read_value": "read_value3", + "read_value2": "read_value2", + } + for _, service := range root.Services { + require.Equal(t, expected[service.Name], plan.Service(root).Services().Get(service.Name).PathName) + } + + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + generatedPaths := make(map[string]struct{}, len(files)) + for _, file := range files { + generatedPaths[filepath.ToSlash(file.Path)] = struct{}{} + } + for _, servicePath := range expected { + for _, filePath := range []string{ + "gen/" + servicePath + "/service.go", + "gen/http/" + servicePath + "/server/server.go", + "gen/jsonrpc/" + servicePath + "/server/server.go", + "gen/grpc/" + servicePath + "/server/server.go", + } { + _, ok := generatedPaths[filePath] + require.True(t, ok, "missing generated file %s", filePath) + } + } + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// servicePathRoot returns one API whose services exercise every generated +// transport and both one-shot and streaming JSON-RPC output. +func servicePathRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + names := []string{"read-value", "read_value", "read_value2"} + if reverse { + names[0], names[2] = names[2], names[0] + } + servers := map[string]string{ + "read-value": "dash", + "read_value": "underscore", + "read_value2": "numbered", + } + dsl.API("path api", func() { + for _, serviceName := range names { + dsl.Server(servers[serviceName], func() { + dsl.Services(serviceName) + dsl.Host("local", func() { dsl.URI("http://localhost") }) + }) + } + }) + for _, serviceName := range names { + dsl.Service(serviceName, func() { + dsl.Method("HTTP call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/call") }) + }) + dsl.Method("JSON-RPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("JSON-RPC stream", func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + }) + dsl.Method("gRPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + } + }) +} diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go index f43bf3e072..ae30f3d228 100644 --- a/codegen/generator/generated_transport_alias_integration_test.go +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -22,7 +22,6 @@ type ( const ( jsonRPCUnary jsonRPCSharedPackageMode = iota jsonRPCSSE - jsonRPCWebSocket ) // ComparePackageName orders names added by the collision test. @@ -56,7 +55,7 @@ func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing. } }) - plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) files, err := testServiceFiles(plan) require.NoError(t, err) transport, err := testTransportFiles(plan) @@ -75,6 +74,76 @@ func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing. runGeneratedTests(t, dir) } +// TestGeneratedCLICompilesWhenImportsMatchLocalNames verifies that endpoint +// parsers keep using package imports when a generated local prefers the same name. +func TestGeneratedCLICompilesWhenImportsMatchLocalNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + trace := dsl.Interceptor("Trace", func() {}) + message := dsl.Type("Message", func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("C", func() { + dsl.Method("Read", func() { + dsl.Payload(message) + dsl.StreamingResult(message) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Data", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/data") }) + }) + }) + }) + + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} + +// TestGRPCOnlyExamplesCompile checks that a server with no HTTP service does +// not refer to an HTTP command-line flag. +func TestGRPCOnlyExamplesCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Echo", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + // TestGeneratedHTTPHelpersCompileWithPackageNameCollisions checks that file and // mixed-result stream helpers use their chosen names in definitions and calls. func TestGeneratedHTTPHelpersCompileWithPackageNameCollisions(t *testing.T) { @@ -121,12 +190,11 @@ func TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { }{ {name: "ordinary", mode: jsonRPCUnary}, {name: "server sent events", mode: jsonRPCSSE}, - {name: "web socket", mode: jsonRPCWebSocket}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - first := jsonRPCSharedPackageRoot(t, "Foo Bar", "First", test.mode) - second := jsonRPCSharedPackageRoot(t, "Foo-Bar", "Second", test.mode) + first := jsonRPCSharedPackageRoot(t, "Shared", "First", test.mode) + second := jsonRPCSharedPackageRoot(t, "Shared", "Second", test.mode) plan := mustTestPlan(t, "generated.local/gen", []eval.Root{first, second}, planTransportData) files, err := testServiceFiles(plan) require.NoError(t, err) @@ -158,6 +226,7 @@ func TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { func jsonRPCSharedPackageRoot(t *testing.T, serviceName, typePrefix string, mode jsonRPCSharedPackageMode) *expr.RootExpr { t.Helper() return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) payload := dsl.Type(typePrefix+"Payload", func() { dsl.Attribute("value", dsl.String) }) @@ -175,10 +244,6 @@ func jsonRPCSharedPackageRoot(t *testing.T, serviceName, typePrefix string, mode dsl.Payload(payload) dsl.StreamingResult(result) dsl.JSONRPC(func() { dsl.ServerSentEvents() }) - case jsonRPCWebSocket: - dsl.StreamingPayload(payload) - dsl.StreamingResult(result) - dsl.JSONRPC(func() {}) default: panic("unknown JSON-RPC test mode") } diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go index bf97b02f3e..2753343830 100644 --- a/codegen/generator/generation_test.go +++ b/codegen/generator/generation_test.go @@ -1,5 +1,5 @@ -// This file verifies that the generator plans every declaration before any -// core generator or plugin renders files from the frozen generation catalog. +// This file checks that every package name is chosen before core generators or +// plugins write files. package generator import ( @@ -110,7 +110,7 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { }, ) - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, command, registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, command, registry) require.NoError(t, err) require.ErrorContains(t, lateDeclare, "frozen") require.Equal(t, []string{ @@ -124,6 +124,51 @@ func TestGeneratePhasesShareOneGeneration(t *testing.T) { }, events) } +func TestCommandsPlanOnlyTheirFiles(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + + genRun, err := newGenerationRun("gen", newDefaultRegistry()) + require.NoError(t, err) + genResult, err := genRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, genResult.plan.example) + require.NotNil(t, genResult.plan.openapi) + + exampleRun, err := newGenerationRun("example", newDefaultRegistry()) + require.NoError(t, err) + exampleResult, err := exampleRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NotNil(t, exampleResult.plan.example) + require.Nil(t, exampleResult.plan.openapi) +} + +// TestRenderUsesRetainedPlans proves that file rendering does not look up +// services or transports from the prepared design after planning finishes. +func TestRenderUsesRetainedPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + planTransportData, + ) + + plan.preparedRoots = nil + plan.services = nil + plan.http = nil + plan.jsonrpcHTTP = nil + plan.jsonrpc = nil + plan.grpc = nil + + serviceFiles, err := serviceFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, serviceFiles) + transportFiles, err := transportFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, transportFiles) +} + // TestPreparedRootsRejectFileRenderMutation proves that persistent mutations // made by templates and file finalizers are rejected after rendering completes. func TestPreparedRootsRejectFileRenderMutation(t *testing.T) { diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 54f140b790..3f8148a338 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -1,76 +1,69 @@ -// This file defines the fresh core generator objects selected by each command. -// Factories are immutable; every run receives new callback values and retains -// one Plan from declaration planning through rendering. +// This file lists the generators used by each command. Every command run gets +// new functions, and both functions receive the same Plan. package generator -import "goa.design/goa/v3/codegen" +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) type ( - // coreGenerator plans and renders one core subsystem for a single run. + // Genfunc is the released signature of a standalone generator function. + // The current generator uses the run-wide Plan instead. + // + // Deprecated: Register a PluginFactory to add generated files. + Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) + + // coreGenerator chooses names and then builds one group of generated files. coreGenerator struct { - // name identifies the subsystem in lifecycle diagnostics. + // name identifies the file group in error messages. name string - // Plan declares package symbols and retains run-specific analysis. + // Plan chooses generated names and saves the data needed to build files. Plan func(*Plan) error - // Generate renders files from the same frozen plan. + // Generate builds files from that same Plan after all names are final. Generate func(*Plan) ([]*codegen.File, error) } - // generatorFactory creates one core generator instance for a run. + // generatorFactory returns a new pair of generator functions when called. generatorFactory func() coreGenerator ) -// genGeneratorFactories returns fresh service, transport, and OpenAPI factories. +// genGeneratorFactories returns the service, transport, and OpenAPI generators +// used by the gen command. func genGeneratorFactories() []generatorFactory { return []generatorFactory{ func() coreGenerator { return coreGenerator{ - name: "service", - Plan: func(plan *Plan) error { - return planServiceData(plan) - }, - Generate: func(plan *Plan) ([]*codegen.File, error) { - return serviceFiles(plan) - }, + name: "service", + Plan: planServiceData, + Generate: serviceFiles, } }, func() coreGenerator { return coreGenerator{ - name: "transport", - Plan: func(plan *Plan) error { - return planTransportData(plan) - }, - Generate: func(plan *Plan) ([]*codegen.File, error) { - return transportFiles(plan) - }, + name: "transport", + Plan: planTransportData, + Generate: transportFiles, } }, func() coreGenerator { return coreGenerator{ - name: "openapi", - Plan: func(plan *Plan) error { - return planServiceData(plan) - }, - Generate: func(plan *Plan) ([]*codegen.File, error) { - return openAPIFiles(plan) - }, + name: "openapi", + Plan: planOpenAPIData, + Generate: openAPIFiles, } }, } } -// exampleGeneratorFactories returns a fresh example generator factory. +// exampleGeneratorFactories returns the generator used by the example command. func exampleGeneratorFactories() []generatorFactory { return []generatorFactory{ func() coreGenerator { return coreGenerator{ - name: "example", - Plan: func(plan *Plan) error { - return planTransportData(plan) - }, - Generate: func(plan *Plan) ([]*codegen.File, error) { - return exampleFiles(plan) - }, + name: "example", + Plan: planExampleData, + Generate: exampleFiles, } }, } diff --git a/codegen/generator/http_plan_test.go b/codegen/generator/http_plan_test.go new file mode 100644 index 0000000000..bef382bf10 --- /dev/null +++ b/codegen/generator/http_plan_test.go @@ -0,0 +1,33 @@ +// This file checks that plugins can find only the ordinary HTTP plan belonging +// to the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestHTTPReturnsOnlyExactOrdinaryPlan(t *testing.T) { + root := &expr.RootExpr{} + sameContents := &expr.RootExpr{} + httpPlan := &httpcodegen.Plan{} + jsonrpcPlan := &httpcodegen.Plan{} + plan := &Plan{ + http: map[*expr.RootExpr]*httpcodegen.Plan{root: httpPlan}, + jsonrpcHTTP: map[*expr.RootExpr]*httpcodegen.Plan{sameContents: jsonrpcPlan}, + } + + got, ok := plan.HTTP(root) + require.True(t, ok) + require.Same(t, httpPlan, got) + got, ok = plan.HTTP(sameContents) + require.False(t, ok) + require.Nil(t, got) + got, ok = plan.HTTP(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/http_sse_retry_integration_test.go b/codegen/generator/http_sse_retry_integration_test.go new file mode 100644 index 0000000000..e6000ae1a3 --- /dev/null +++ b/codegen/generator/http_sse_retry_integration_test.go @@ -0,0 +1,120 @@ +// This file checks retry values in complete generated HTTP SSE transports. +// The server writes the designed integer field and the client rebuilds the +// service result or returns the exact parsing error for invalid event text. +package generator + +import ( + "testing" + + "goa.design/goa/v3/dsl" +) + +// httpSSERetryContractTest runs against the temporary generated module. +const httpSSERetryContractTest = `package integration + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_retry" + client "generated.local/gen/http/sse_retry/client" + server "generated.local/gen/http/sse_retry/server" + goahttp "goa.design/goa/v3/http" +) + +// retryService sends one event with both fields selected. +type retryService struct{} + +func (*retryService) Watch(_ context.Context, stream service.WatchServerStream) error { + data := "null" + retry := 2500 + return stream.Send(&service.Event{Data: &data, Retry: &retry}) +} + +func TestRetryRoundTrip(t *testing.T) { + handler := server.NewWatchHandler( + service.NewWatchEndpoint(&retryService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { t.Errorf("serve SSE: %v", err) }, + nil, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.Data) + require.Equal(t, "null", *event.Data) + require.NotNil(t, event.Retry) + require.Equal(t, 2500, *event.Retry) +} + +func TestMalformedRetryReturnsNumberError(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, err := io.WriteString(w, "retry: later\ndata: ready\n\n") + require.NoError(t, err) + })) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + _, err := stream.Recv() + var numberError *strconv.NumError + require.ErrorAs(t, err, &numberError) + require.Equal(t, "later", numberError.Num) +} + +// openWatch starts the generated client stream against url. +func openWatch(t *testing.T, url string, doer goahttp.Doer) client.WatchClientStream { + t.Helper() + transport := client.NewClient( + "http", + strings.TrimPrefix(url, "http://"), + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + false, + ) + raw, err := transport.Watch()(context.Background(), nil) + require.NoError(t, err) + return raw.(client.WatchClientStream) +} +` + +// TestGeneratedHTTPSSERetryParsing checks valid and invalid retry values using +// a generated server and client rather than template fragments. +func TestGeneratedHTTPSSERetryParsing(t *testing.T) { + dir := generateViewedTransportModule(t, httpSSERetryDSL) + writeGeneratedContractTest(t, dir, ".", httpSSERetryContractTest) + runGeneratedPackageTests(t, dir, ".") +} + +// httpSSERetryDSL defines one event with optional data and retry fields. The +// generated service uses pointers so nil and selected values remain distinct. +func httpSSERetryDSL() { + event := dsl.Type("Event", func() { + dsl.Attribute("data", dsl.String) + dsl.Attribute("retry", dsl.Int) + }) + dsl.Service("SSE Retry", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventRetry("retry") + }) + }) + }) + }) +} diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go index bf4d41c57a..1ac8724307 100644 --- a/codegen/generator/lifecycle.go +++ b/codegen/generator/lifecycle.go @@ -1,6 +1,5 @@ -// This file executes the prepare, plan, freeze, and render phases for explicit -// roots. The public filesystem-facing generator and isolated tests both use -// this path, so lifecycle behavior has one implementation. +// This file runs plugin preparation, name selection, file planning, and file +// writing in one order shared by production generation and tests. package generator import ( @@ -11,40 +10,39 @@ import ( ) type ( - // generationRun owns every fresh core and plugin instance for one execution. + // generationRun stores the new core generators and plugins used by one run. generationRun struct { cores []coreGenerator plugins []runPlugin } - // runPlugin retains one plugin's registered owner name with its fresh callbacks. + // runPlugin stores one plugin's registered name and its Prepare, Plan, and + // Generate functions for this run. runPlugin struct { name string Plugin } - // generationResult retains the exact plan needed to verify later file renders. + // generationResult stores the generation state and files produced by one + // run. generationResult struct { plan *Plan files []*codegen.File } ) -// executeGeneration instantiates fresh core and plugin objects, prepares roots, -// and produces file descriptions from one retained frozen plan. -func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) ([]*codegen.File, error) { +// executeGeneration creates new core generators and plugins, prepares the +// designs, chooses all names, and reports whether generation succeeded. +func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) error { run, err := newGenerationRun(command, registry) if err != nil { - return nil, err - } - result, err := run.execute(genpkg, roots) - if err != nil { - return nil, err + return err } - return result.files, nil + _, err = run.execute(genpkg, roots) + return err } -// newGenerationRun snapshots immutable factories and invokes each exactly once. +// newGenerationRun copies the registered factories and calls each one once. func newGenerationRun(command string, registry *registry) (*generationRun, error) { coreFactories, pluginDescriptors, err := registry.snapshot(command) if err != nil { @@ -61,7 +59,7 @@ func newGenerationRun(command string, registry *registry) (*generationRun, error return &generationRun{cores: cores, plugins: plugins}, nil } -// execute runs all phases for explicit prepared-root inputs. +// execute prepares the supplied designs, chooses names, and builds files. func (r *generationRun) execute(genpkg string, roots []eval.Root) (*generationResult, error) { for _, plugin := range r.plugins { if plugin.Prepare != nil { diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 106cfe5306..ac0a0cec57 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -1,5 +1,5 @@ -// This file renders OpenAPI documents from the same evaluated roots and frozen -// service declaration data used by the transport generators. +// This file builds each requested OpenAPI document while the evaluated design +// is available. File generation later returns the documents already built. package generator import ( @@ -7,17 +7,33 @@ import ( httpcodegen "goa.design/goa/v3/http/codegen" ) -// openAPIFiles returns OpenAPI files described by plan's frozen package -// declarations and run-owned example state. +// openAPIFiles returns the OpenAPI files built during planning. func openAPIFiles(plan *Plan) ([]*codegen.File, error) { - generation := plan.Generation() - designRoots := serviceRoots(generation.Roots()) - for _, root := range designRoots { - plan.Service(root).Services() + if len(plan.openapiReplacements) > 0 { + var files []*codegen.File + for _, openapi := range plan.openapiReplacements { + files = append(files, openapi.Files()...) + } + return files, nil } - if len(designRoots) > 0 { - root := designRoots[0] - return httpcodegen.OpenAPIFiles(root, plan.exampleGenerator(root)) + return plan.openapi.Files(), nil +} + +// planOpenAPIData builds the OpenAPI files for the application's design root. +// Later roots contain generated support services and do not describe another +// application API. +func planOpenAPIData(plan *Plan) error { + roots := serviceRoots(plan.Generation().Roots()) + if len(roots) == 0 { + plan.openapi = new(httpcodegen.OpenAPIPlan) + plan.openapiRoot = nil + return nil + } + openapi, err := httpcodegen.NewOpenAPIPlan(roots[0], plan.exampleGenerator(roots[0])) + if err != nil { + return err } - return nil, nil + plan.openapi = openapi + plan.openapiRoot = roots[0] + return nil } diff --git a/codegen/generator/openapi_replace_test.go b/codegen/generator/openapi_replace_test.go new file mode 100644 index 0000000000..812633a68e --- /dev/null +++ b/codegen/generator/openapi_replace_test.go @@ -0,0 +1,86 @@ +// This file verifies that a plugin can replace the OpenAPI documents for the +// exact application design before generation names become final. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + "goa.design/goa/v3/http/codegen/openapi" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +func TestReplaceOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + second, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "localized/openapi"}}, + openapi.Values{}, + ) + require.NoError(t, err) + + require.NoError(t, plan.ReplaceOpenAPI(root, replacement, second)) + files, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, append(replacement.Files(), second.Files()...), files) +} + +func TestReplaceOpenAPIRejectsInvalidOwnerPhaseAndPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + other := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + original, err := openAPIFiles(plan) + require.NoError(t, err) + + require.ErrorContains(t, plan.ReplaceOpenAPI(nil, replacement), "root is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(other, replacement), "not the application design root") + require.ErrorContains(t, plan.ReplaceOpenAPI(root), "at least one") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, nil), "plan 0 is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement, replacement), "same output path") + upper, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "docs/API"}}, + openapi.Values{}, + ) + require.NoError(t, err) + lower, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version30, Path: "docs/api"}}, + openapi.Values{}, + ) + require.NoError(t, err) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, upper, lower), "case-insensitive filesystem") + unchanged, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, original, unchanged) + + require.NoError(t, generation.Freeze()) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement), "after generation freeze") +} diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go index fc7a1a80ad..61844ad383 100644 --- a/codegen/generator/plan.go +++ b/codegen/generator/plan.go @@ -4,8 +4,10 @@ package generator import ( "fmt" + "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" @@ -18,16 +20,42 @@ type ( // Plan holds the input designs, chosen Go names, and generated files for one // run. Code that writes files receives it after all Go names are known. Plan struct { - generation *codegen.Generation - preparedRoots []eval.Root - examples map[*expr.RootExpr]*expr.ExampleGenerator - services map[*expr.RootExpr]*service.Plan - http map[*expr.RootExpr]*httpcodegen.Plan - jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan - jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan - grpc map[*expr.RootExpr]*grpccodegen.Plan - transportDone bool - design *designSnapshot + generation *codegen.Generation + preparedRoots []eval.Root + examples map[*expr.RootExpr]*expr.ExampleGenerator + example []*examplePlanEntry + openapi *httpcodegen.OpenAPIPlan + openapiRoot *expr.RootExpr + openapiReplacements []*httpcodegen.OpenAPIPlan + services map[*expr.RootExpr]*service.Plan + serviceOrder []*service.Plan + http map[*expr.RootExpr]*httpcodegen.Plan + jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan + jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan + grpc map[*expr.RootExpr]*grpccodegen.Plan + transports []*transportPlanEntry + transportDone bool + design *designSnapshot + } + + // transportPlanEntry keeps the transport plans for one service design in + // the order chosen during planning. + transportPlanEntry struct { + http *httpcodegen.Plan + jsonrpcHTTP *httpcodegen.Plan + jsonrpc *jsonrpccodegen.Plan + grpc *grpccodegen.Plan + } + + // examplePlanEntry keeps one copied example root with the plans that write + // files for that same design. + examplePlanEntry struct { + source *expr.RootExpr + root *example.Root + service *service.Plan + http *httpcodegen.ExamplePlan + jsonrpc *jsonrpccodegen.ExamplePlan + grpc *grpccodegen.ExamplePlan } ) @@ -47,6 +75,67 @@ func (p *Plan) Service(root *expr.RootExpr) *service.Plan { return plan } +// HTTP returns the ordinary HTTP plan created for root. It returns false for a +// different root value and for designs exposed only through JSON-RPC. +func (p *Plan) HTTP(root *expr.RootExpr) (*httpcodegen.Plan, bool) { + plan, ok := p.http[root] + return plan, ok +} + +// GRPC returns the gRPC plan created for the exact design root. It returns +// false when the root was not included in gRPC planning. +func (p *Plan) GRPC(root *expr.RootExpr) (*grpccodegen.Plan, bool) { + plan, ok := p.grpc[root] + return plan, ok +} + +// JSONRPC returns the JSON-RPC plan created for the exact design root. It +// returns false when the root was not included in JSON-RPC planning. +func (p *Plan) JSONRPC(root *expr.RootExpr) (*jsonrpccodegen.Plan, bool) { + plan, ok := p.jsonrpc[root] + return plan, ok +} + +// Example returns a separate copy of the example server description created +// for the exact design root. It returns false when the root was not included +// in example planning. +func (p *Plan) Example(root *expr.RootExpr) (*example.Root, bool) { + for _, entry := range p.example { + if entry.source == root { + return copyExampleRoot(entry.root), true + } + } + return nil, false +} + +// ReplaceOpenAPI replaces the OpenAPI documents for the application root with +// files already built by plans. It must be called during plugin planning, +// before generated names become final. +func (p *Plan) ReplaceOpenAPI(root *expr.RootExpr, plans ...*httpcodegen.OpenAPIPlan) error { + if root == nil { + return fmt.Errorf("OpenAPI replacement root is nil") + } + if root != p.openapiRoot { + return fmt.Errorf("root %q is not the application design root", root.API.Name) + } + if p.generation.Frozen() { + return fmt.Errorf("OpenAPI documents cannot be replaced after generation freeze") + } + if len(plans) == 0 { + return fmt.Errorf("OpenAPI replacement requires at least one plan") + } + for index, plan := range plans { + if plan == nil { + return fmt.Errorf("OpenAPI replacement plan %d is nil", index) + } + } + if err := validateOpenAPIPlanPaths(plans); err != nil { + return err + } + p.openapiReplacements = append([]*httpcodegen.OpenAPIPlan(nil), plans...) + return nil +} + // exampleGenerator returns the example values created for root. It panics when // root was not included in this run. func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { @@ -59,32 +148,28 @@ func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { // link completes each service and then builds the protocol files that use it. func (p *Plan) link() error { - for _, root := range serviceRoots(p.preparedRoots) { - plan, ok := p.services[root] - if !ok { - continue - } + for _, plan := range p.serviceOrder { if err := plan.Link(); err != nil { return err } } - for _, root := range serviceRoots(p.preparedRoots) { - if plan := p.http[root]; plan != nil { + for _, transport := range p.transports { + if plan := transport.http; plan != nil { if err := plan.Link(); err != nil { return err } } - if plan := p.jsonrpcHTTP[root]; plan != nil { + if plan := transport.jsonrpcHTTP; plan != nil { if err := plan.Link(); err != nil { return err } } - if plan := p.jsonrpc[root]; plan != nil { + if plan := transport.jsonrpc; plan != nil { if err := plan.Link(); err != nil { return err } } - if plan := p.grpc[root]; plan != nil { + if plan := transport.grpc; plan != nil { if err := plan.Link(); err != nil { return err } @@ -105,3 +190,27 @@ func (p *Plan) verifyPreparedDesign(operation string) error { } return nil } + +// validateOpenAPIPlanPaths rejects two OpenAPI files that use the same path, +// including paths that differ only by letter case. +func validateOpenAPIPlanPaths(plans []*httpcodegen.OpenAPIPlan) error { + var paths []string + for _, plan := range plans { + for _, file := range plan.Files() { + for _, existing := range paths { + if existing == file.Path { + return fmt.Errorf("OpenAPI plans use the same output path %q", file.Path) + } + if strings.EqualFold(existing, file.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existing, + file.Path, + ) + } + } + paths = append(paths, file.Path) + } + } + return nil +} diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go index e46ab2397d..2c4dab0ec1 100644 --- a/codegen/generator/plugin.go +++ b/codegen/generator/plugin.go @@ -1,6 +1,6 @@ -// This file owns immutable plugin factories and creates fresh callback objects -// for every generation run. The registry seals when its first run snapshots -// factories, preventing process history from changing later runs. +// This file stores the generator functions registered for each command and +// creates a separate plugin value for each run. Registration closes when +// generation first starts. package generator import ( @@ -10,37 +10,34 @@ import ( "sync" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/eval" + "goa.design/goa/v3/codegen/internal/pluginregistry" ) type ( - // PrepareFunc may amend evaluated roots before normalization and planning. - // Preparation is the only plugin phase allowed to mutate design expressions. - PrepareFunc func(genpkg string, roots []eval.Root) error - - // Plugin contains the optional callbacks run on one fresh plugin instance. - // Plan and Generate receive the same retained Plan pointer. + // Plugin contains the optional functions run for one new plugin instance. + // Plan and Generate receive the same Plan pointer. Plugin struct { - // Prepare may amend roots before the Generation snapshot is created. - Prepare PrepareFunc - // Plan declares plugin-owned package symbols before generation freeze. + // Prepare may change designs before Goa records their prepared values. + Prepare codegen.PrepareFunc + // Plan adds the plugin's package-level names before all names are final. Plan func(*Plan) error - // Generate appends or transforms files using the frozen retained plan. + // Generate adds or changes files after all names are final. Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) } // PluginFactory creates one independent plugin instance for each run. PluginFactory func() Plugin - // registry owns core and plugin factories used by one command namespace. + // registry stores the core and plugin factories used by each command. registry struct { - mu sync.Mutex - commands map[string][]generatorFactory - plugins []pluginDescriptor - sealed bool + mu sync.Mutex + commands map[string][]generatorFactory + plugins []pluginDescriptor + registeredPlugins func() []registeredPluginDescriptor + sealed bool } - // pluginDescriptor is immutable registration metadata retained globally. + // pluginDescriptor stores one plugin registration. pluginDescriptor struct { name string command string @@ -48,7 +45,17 @@ type ( factory PluginFactory } - // pluginPosition defines the three stable registration groups. + // registeredPluginDescriptor copies one plugin registered through the + // released Goa v3 API before adapting it to a per-run plugin. + registeredPluginDescriptor struct { + name string + command string + position pluginPosition + prepare codegen.PrepareFunc + generate codegen.GenerateFunc + } + + // pluginPosition defines the three plugin ordering groups. pluginPosition uint8 ) @@ -79,7 +86,7 @@ func RegisterPluginLast(name, command string, factory PluginFactory) { defaultRegistry.registerPlugin(name, command, pluginLast, factory) } -// newRegistry creates an empty mutable registry for init-time setup or tests. +// newRegistry creates an empty list of commands and plugins for setup or tests. func newRegistry() *registry { return ®istry{commands: make(map[string][]generatorFactory)} } @@ -90,10 +97,11 @@ func newDefaultRegistry() *registry { registry := newRegistry() registry.commands["gen"] = genGeneratorFactories() registry.commands["example"] = exampleGeneratorFactories() + registry.registeredPlugins = snapshotRegisteredPlugins return registry } -// addCommand installs private core factories in an isolated test registry. +// addCommand adds core generators to a command used by a test. func (r *registry) addCommand(command string, factories ...generatorFactory) { r.mu.Lock() defer r.mu.Unlock() @@ -103,8 +111,8 @@ func (r *registry) addCommand(command string, factories ...generatorFactory) { r.commands[command] = slices.Clone(factories) } -// registerPlugin records one named factory for a known command before the -// first snapshot. Plugin names uniquely identify their owner within a command. +// registerPlugin adds one named factory to a known command before generation +// starts. A command cannot contain two plugins with the same name. func (r *registry) registerPlugin(name, command string, position pluginPosition, factory PluginFactory) { if factory == nil { panic("plugin factory is nil") @@ -133,7 +141,7 @@ func (r *registry) registerPlugin(name, command string, position pluginPosition, }) } -// snapshot seals the registry and returns copied factories in stable order. +// snapshot closes registration and returns copied factories in a repeatable order. func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescriptor, error) { r.mu.Lock() defer r.mu.Unlock() @@ -142,17 +150,72 @@ func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescrip return nil, nil, fmt.Errorf("unknown command %q", command) } r.sealed = true - plugins := make([]pluginDescriptor, 0, len(r.plugins)) + selected := make([]pluginDescriptor, 0, len(r.plugins)) + factoryNames := make(map[string]struct{}, len(r.plugins)) for _, plugin := range r.plugins { - if plugin.command == command { - plugins = append(plugins, plugin) + if plugin.command != command { + continue + } + selected = append(selected, plugin) + factoryNames[plugin.name] = struct{}{} + } + if r.registeredPlugins != nil { + for _, registered := range r.registeredPlugins() { + if registered.command != command { + continue + } + if _, ok := factoryNames[registered.name]; ok { + return nil, nil, fmt.Errorf("plugin %q is already registered for command %q", registered.name, command) + } + selected = append(selected, registered.pluginDescriptor()) } } - slices.SortFunc(plugins, func(left, right pluginDescriptor) int { + slices.SortStableFunc(selected, func(left, right pluginDescriptor) int { if left.position != right.position { return int(left.position) - int(right.position) } return strings.Compare(left.name, right.name) }) - return slices.Clone(factories), plugins, nil + return slices.Clone(factories), selected, nil +} + +// snapshotRegisteredPlugins stops further callback registration and copies +// each registered callback into the list used by this generation run. +func snapshotRegisteredPlugins() []registeredPluginDescriptor { + plugins := pluginregistry.Snapshot[codegen.PrepareFunc, codegen.GenerateFunc]() + descriptors := make([]registeredPluginDescriptor, len(plugins)) + for index, plugin := range plugins { + position := pluginNormal + if plugin.Position == pluginregistry.First { + position = pluginFirst + } else if plugin.Position == pluginregistry.Last { + position = pluginLast + } + descriptors[index] = registeredPluginDescriptor{ + name: plugin.Name, + command: plugin.Command, + position: position, + prepare: plugin.Prepare, + generate: plugin.Generate, + } + } + return descriptors +} + +// pluginDescriptor adapts a released callback pair to the same factory and +// per-run Plan used by newer plugins. +func (p registeredPluginDescriptor) pluginDescriptor() pluginDescriptor { + return pluginDescriptor{ + name: p.name, + command: p.command, + position: p.position, + factory: func() Plugin { + return Plugin{ + Prepare: p.prepare, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + return p.generate(plan.Generation().GenPkg(), plan.preparedRoots, files) + }, + } + }, + } } diff --git a/codegen/generator/plugin_public_integration_test.go b/codegen/generator/plugin_public_integration_test.go new file mode 100644 index 0000000000..a5ed4f9d12 --- /dev/null +++ b/codegen/generator/plugin_public_integration_test.go @@ -0,0 +1,381 @@ +// This file runs the public plugin registration APIs in fresh child processes. +// Each child uses the real default registry, so the tests cover rejecting late +// registrations and running released callbacks across generation commands. +package generator + +import ( + "cmp" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // publicHTTPPluginOrder gives declarations in the compile test a stable + // order within the generated server package. + publicHTTPPluginOrder string + + // publicHTTPPluginData supplies three generated names to the plugin source + // template after Goa has chosen all generated names. + publicHTTPPluginData struct { + // Wrapper is the handler wrapper name chosen with core server names. + Wrapper *codegen.NameDeclaration + // EndpointWrapper is the private wrapper chosen for the Read endpoint. + EndpointWrapper *codegen.NameDeclaration + // Mount is the extra mount function name chosen with core server names. + Mount *codegen.NameDeclaration + } +) + +const publicPluginChildMode = "GOA_PUBLIC_PLUGIN_CHILD" + +// TestPublicPluginRegistrationUsesDefaultGenerationRun verifies released and +// factory registrations using fresh package globals in separate processes. +func TestPublicPluginRegistrationUsesDefaultGenerationRun(t *testing.T) { + switch os.Getenv(publicPluginChildMode) { + case "run": + runPublicPluginChild(t) + return + case "duplicate": + runPublicPluginDuplicateChild(t) + return + case "repeat": + runPublicPluginRepeatedRunChild(t) + return + case "http-extension": + runPublicHTTPServerExtensionChild(t) + return + case "legacy-http-endpoint": + runPublicLegacyHTTPEndpointChild(t) + return + } + + for _, mode := range []string{"run", "duplicate", "repeat", "http-extension", "legacy-http-endpoint"} { + t.Run(mode, func(t *testing.T) { + command := exec.Command(os.Args[0], "-test.run=^TestPublicPluginRegistrationUsesDefaultGenerationRun$") + command.Env = append(os.Environ(), publicPluginChildMode+"="+mode) + output, err := command.CombinedOutput() + require.NoErrorf(t, err, "child process failed:\n%s", output) + }) + } +} + +// runPublicLegacyHTTPEndpointChild checks that a released plugin can add an +// endpoint using the public handler name without knowing Goa's private plan. +func runPublicLegacyHTTPEndpointChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) + }) + codegen.RegisterPlugin("legacy-http-endpoint", "gen", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + for _, file := range files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + for _, section := range file.SectionTemplates { + if section.Name != "server-init" { + continue + } + data := section.Data.(*httpcodegen.ServiceData) + data.Endpoints = append(data.Endpoints, &httpcodegen.EndpointData{ + Method: &servicecodegen.MethodData{VarName: "CORS"}, + MountHandler: "MountCORSHandler", + HandlerInit: "NewCORSHandler", + }) + section.Source = strings.Replace( + section.Source, + `e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }})`, + `{{ if ne .Method.VarName "CORS" }}e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}{{ end }})`, + -1, + ) + } + } + return files, nil + }) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + for _, file := range result.files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + code := codegen.SectionsCode(t, file.Section("server-init")) + require.Contains(t, code, "CORS: NewCORSHandler()") + mount := codegen.SectionsCode(t, file.Section("server-mount")) + require.Contains(t, mount, "MountCORSHandler(mux, h.CORS)") + return + } + t.Fatal("generated HTTP server file is missing") +} + +// runPublicPluginRepeatedRunChild registers through the released API once and +// checks that the same functions receive each later run's package and root. +func runPublicPluginRepeatedRunChild(t *testing.T) { + var prepared, generated []string + codegen.RegisterPlugin( + "repeat", + "gen", + func(genpkg string, roots []eval.Root) error { + prepared = append(prepared, genpkg+":"+roots[0].(*expr.RootExpr).API.Name) + return nil + }, + func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + name := roots[0].(*expr.RootExpr).API.Name + generated = append(generated, genpkg+":"+name) + return append(files, &codegen.File{Path: "released-" + name}), nil + }, + ) + + packages := []string{"generated.local/first", "generated.local/second"} + for index, name := range []string{"first", "second"} { + root := expr.RunDSL(t, func() { + dsl.API(name, func() { + }) + }) + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+name, result.files[len(result.files)-1].Path) + } + + require.Equal(t, []string{ + "generated.local/first:first", + "generated.local/second:second", + }, prepared) + require.Equal(t, prepared, generated) +} + +// runPublicHTTPServerExtensionChild generates and compiles an HTTP service with +// a public per-run plugin that defines both extension function bodies. +func runPublicHTTPServerExtensionChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("id", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/items/{id}") + }) + }) + }) + }) + RegisterPlugin("http-server-extension", "gen", func() Plugin { + data := &publicHTTPPluginData{} + return Plugin{ + Plan: func(plan *Plan) error { + httpPlan, ok := plan.HTTP(root) + if !ok { + return fmt.Errorf("ordinary HTTP plan is missing") + } + service := root.API.HTTP.Services[0] + var err error + data.Wrapper, err = httpPlan.DeclareServerHandlerWrapper(service, "WrapExtension", publicHTTPPluginOrder("wrapper")) + if err != nil { + return err + } + data.EndpointWrapper, err = httpPlan.DeclareServerEndpointHandlerWrapper(service.HTTPEndpoints[0], "wrapReadExtension", publicHTTPPluginOrder("endpoint wrapper")) + if err != nil { + return err + } + data.Mount, err = httpPlan.DeclareServerMount(service, "MountExtension", publicHTTPPluginOrder("mount"), []httpcodegen.ServerMountPoint{{ + Method: "Extension preflight", + Verb: "OPTIONS", + Pattern: "/items/{id}", + }}) + return err + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, publicHTTPServerExtensionFile(data)), nil + }, + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + serverSource, err := os.ReadFile(filepath.Join(genDir, "http", "calc", "server", "server.go")) + require.NoError(t, err) + require.Contains(t, string(serverSource), "h = WrapExtension(wrapReadExtension(h))") + require.Contains(t, string(serverSource), "MountReadHandler(mux, h.Read)") + require.NotContains(t, string(serverSource), "MountReadHandler(mux, WrapExtension") + runGeneratedTests(t, genDir) +} + +// runPublicPluginChild mixes both public APIs and checks the arguments and +// files passed through the real default generation run. +func runPublicPluginChild(t *testing.T) { + root := expr.RunDSL(t, func() {}) + var events []string + registerPublicFactoryPlugin("a-first", pluginFirst, &events) + registerPublicReleasedPlugin("z-first", pluginFirst, root, &events) + registerPublicReleasedPlugin("a-normal", pluginNormal, root, &events) + registerPublicFactoryPlugin("z-normal", pluginNormal, &events) + registerPublicReleasedPlugin("a-last", pluginLast, root, &events) + registerPublicFactoryPlugin("z-last", pluginLast, &events) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + codegen.RegisterPlugin("late", "gen", nil, publicUnchangedFiles) + }) + require.PanicsWithValue(t, "generator plugin registry is sealed", func() { + RegisterPlugin("late", "gen", func() Plugin { + return Plugin{} + }) + }) +} + +// runPublicPluginDuplicateChild proves that registrations for another command +// do not block or run during this command. +func runPublicPluginDuplicateChild(t *testing.T) { + called := false + codegen.RegisterPlugin("duplicate", "example", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }) + RegisterPlugin("duplicate", "example", func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + require.False(t, called) +} + +// registerPublicReleasedPlugin adds one old-style callback pair through the +// exact API used by released Goa v3 plugins. +func registerPublicReleasedPlugin(name string, position pluginPosition, root eval.Root, events *[]string) { + prepare := func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + } + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + *events = append(*events, "generate:"+name+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: "released:" + name}), nil + } + switch position { + case pluginFirst: + codegen.RegisterPluginFirst(name, "gen", prepare, generate) + case pluginNormal: + codegen.RegisterPlugin(name, "gen", prepare, generate) + case pluginLast: + codegen.RegisterPluginLast(name, "gen", prepare, generate) + } +} + +// registerPublicFactoryPlugin adds one planning-aware plugin through the new +// API and records the file left by the preceding plugin. +func registerPublicFactoryPlugin(name string, position pluginPosition, events *[]string) { + factory := func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + event := "generate:" + name + if len(files) > 0 { + event += ":" + files[len(files)-1].Path + } + *events = append(*events, event) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + } + switch position { + case pluginFirst: + RegisterPluginFirst(name, "gen", factory) + case pluginNormal: + RegisterPlugin(name, "gen", factory) + case pluginLast: + RegisterPluginLast(name, "gen", factory) + } +} + +// publicUnchangedFiles is a valid callback used to test late registration. +func publicUnchangedFiles(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + return files, nil +} + +// publicHTTPServerExtensionFile writes the two functions promised during +// plugin planning into the generated Calc server package. +func publicHTTPServerExtensionFile(data *publicHTTPPluginData) *codegen.File { + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "http", "calc", "server", "plugin.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Calc HTTP server plugin", "server", []*codegen.ImportSpec{ + codegen.SimpleImport("net/http"), + codegen.GoaNamedImport("http", "goahttp"), + }), + { + Name: "http-server-extension", + Source: `// {{ .Wrapper.Name }} wraps a handler mounted from the Calc design. +func {{ .Wrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .EndpointWrapper.Name }} wraps only the Read endpoint handler. +func {{ .EndpointWrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .Mount.Name }} adds the Calc preflight route. +func {{ .Mount.Name }}(mux goahttp.Muxer) { + mux.Handle("OPTIONS", "/items/{id}", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) +}`, + Data: data, + }, + }, + } +} + +// ComparePackageName gives public plugin declarations a stable order. +func (o publicHTTPPluginOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(publicHTTPPluginOrder))) +} diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go index baa5ea2b17..ebabd86bb8 100644 --- a/codegen/generator/plugin_test.go +++ b/codegen/generator/plugin_test.go @@ -3,7 +3,9 @@ package generator import ( + "errors" "fmt" + "path/filepath" "sync" "testing" @@ -12,6 +14,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" httpdata "goa.design/goa/v3/http/codegen/testdata" ) @@ -58,7 +61,7 @@ func TestPluginFactoryOrderAndPlan(t *testing.T) { register(pluginNormal, "a-normal") register(pluginLast, "a-last") - _, err := executeGeneration("generated.local/gen", nil, "test", registry) + err := executeGeneration("generated.local/gen", nil, "test", registry) require.NoError(t, err) require.Equal(t, []string{ "prepare:a-first", "prepare:b-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", @@ -72,6 +75,151 @@ func TestPluginFactoryOrderAndPlan(t *testing.T) { require.NotNil(t, plans[0].Generation()) } +// TestPluginPlannedHTTPDataIsAccepted checks that a factory plugin may declare +// a constructor during Plan and use it as direct HTTP data during Generate. +func TestPluginPlannedHTTPDataIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + registry.registerPlugin("planned-http-data", "gen", pluginNormal, func() Plugin { + var declaration *codegen.NameDeclaration + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + return pkg.DeclareName(declaration) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "plugin-init", + Data: &httpcodegen.InitData{ + Declaration: declaration, + Name: declaration.Name(), + }, + }}, + }), nil + }, + } + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) +} + +// TestPluginOwnedHTTPDeclarationReplacementIsAccepted checks that a later +// plugin may replace a declaration with another name planned by the same run. +func TestPluginOwnedHTTPDeclarationReplacementIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + var ( + init *httpcodegen.InitData + declaration *codegen.NameDeclaration + replacement *codegen.NameDeclaration + laterRan bool + ) + registry.registerPlugin("a-add-init", "gen", pluginNormal, func() Plugin { + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + replacement = codegen.NewExactName(codegen.NameFunction, "BuildOtherBody") + if err := pkg.DeclareName(declaration); err != nil { + return err + } + return pkg.DeclareName(replacement) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init = &httpcodegen.InitData{Declaration: declaration, Name: declaration.Name()} + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{Name: "plugin-init", Data: init}}, + }), nil + }, + } + }) + registry.registerPlugin("b-replace-init", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init.Declaration = replacement + init.Name = replacement.Name() + return files, nil + }} + }) + registry.registerPlugin("c-later", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.True(t, laterRan) +} + +// TestPluginCallbackErrorIsPreserved checks that an ordinary callback failure +// is returned unchanged and stops later plugins. +func TestPluginCallbackErrorIsPreserved(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "callback-error", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + laterRan := false + registry.registerPlugin("fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return files, errors.New("callback failed") + }} + }) + registry.registerPlugin("z-later", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.EqualError(t, err, "callback failed") + require.False(t, laterRan) +} + +// TestPluginDesignMutationErrorTakesPrecedence checks that Goa reports a +// forbidden design change even when the callback also returns its own error. +// The changed root would otherwise remain visible to later generation runs. +func TestPluginDesignMutationErrorTakesPrecedence(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("mutate-and-fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + root.API.Name = "after" + return files, errors.New("callback failed") + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.ErrorContains(t, err, `plugin "mutate-and-fail" generate mutated prepared design`) + require.NotEqual(t, "callback failed", err.Error()) +} + // TestPluginFactorySequentialIsolation verifies that every run invokes the // factory again and no mutable callback state survives from an earlier run. func TestPluginFactorySequentialIsolation(t *testing.T) { @@ -82,7 +230,7 @@ func TestPluginFactorySequentialIsolation(t *testing.T) { Name: fmt.Sprintf("run-%d", i), RandomizerFactory: expr.NewDeterministicRandomizerFactory(), }} - _, err := executeGeneration( + err := executeGeneration( fmt.Sprintf("generated.local/gen%d", i), []eval.Root{root}, "test", @@ -106,7 +254,7 @@ func TestPluginFactoryConcurrentIsolation(t *testing.T) { Name: fmt.Sprintf("run-%d", index), RandomizerFactory: expr.NewDeterministicRandomizerFactory(), }} - _, err := executeGeneration( + err := executeGeneration( fmt.Sprintf("generated.local/gen%d", index), []eval.Root{root}, "test", @@ -150,7 +298,7 @@ func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { }} }) - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) require.NoError(t, err) } @@ -255,7 +403,7 @@ func TestPreparedRootsRejectNonAttributeMutations(t *testing.T) { followingRan = true }) - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) require.ErrorContains(t, err, test.phase+" mutated prepared design") require.False(t, followingRan) }) @@ -266,12 +414,219 @@ func TestPreparedRootsRejectNonAttributeMutations(t *testing.T) { // factories registered after the registry's immutable snapshot is established. func TestPluginRegistrySealsOnFirstSnapshot(t *testing.T) { registry := newRegistry() - registry.addCommand("test", func() coreGenerator { return coreGenerator{} }) - _, err := executeGeneration("generated.local/gen", nil, "test", registry) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + err := executeGeneration("generated.local/gen", nil, "test", registry) require.NoError(t, err) require.Panics(t, func() { - registry.registerPlugin("late", "test", pluginNormal, func() Plugin { return Plugin{} }) + registry.registerPlugin("late", "test", pluginNormal, func() Plugin { + return Plugin{} + }) + }) +} + +// TestReleasedAndFactoryPluginsShareOneRun verifies that plugins registered +// through either API run in one order and receive the same prepared design and +// current file list. +func TestReleasedAndFactoryPluginsShareOneRun(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "prepared", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + releasedPluginForTest("z-first", "test", pluginFirst, root, &events), + releasedPluginForTest("a-normal", "test", pluginNormal, root, &events), + releasedPluginForTest("a-last", "test", pluginLast, root, &events), + } + } + registerFactoryPluginForTest(registry, "a-first", pluginFirst, &events) + registerFactoryPluginForTest(registry, "z-normal", pluginNormal, &events) + registerFactoryPluginForTest(registry, "z-last", pluginLast, &events) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first:core", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) +} + +// TestReleasedDuplicatePluginsKeepRegistrationOrder verifies that callbacks +// with the same released command and name still run in registration order. +func TestReleasedDuplicatePluginsKeepRegistrationOrder(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} + }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + duplicate := func(event string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: "same", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, event+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: event}), nil + }, + } + } + return []registeredPluginDescriptor{duplicate("first"), duplicate("second")} + } + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, []string{"first:core", "second:first"}, events) +} + +// TestReleasedPluginCallbackReceivesEachRun checks that the same registered +// function can run twice. Each call receives only that run's generated package, +// design roots, and files. +func TestReleasedPluginCallbackReceivesEachRun(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(plan *Plan) ([]*codegen.File, error) { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + return []*codegen.File{{Path: "core-" + root.API.Name}}, nil + }} + }) + + var ( + preparedPackages []string + preparedRoots [][]eval.Root + generatedPackages []string + generatedRoots [][]eval.Root + generatedFiles [][]*codegen.File + ) + prepare := func(genpkg string, roots []eval.Root) error { + preparedPackages = append(preparedPackages, genpkg) + preparedRoots = append(preparedRoots, append([]eval.Root(nil), roots...)) + return nil + } + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + generatedPackages = append(generatedPackages, genpkg) + generatedRoots = append(generatedRoots, append([]eval.Root(nil), roots...)) + generatedFiles = append(generatedFiles, append([]*codegen.File(nil), files...)) + return append(files, &codegen.File{Path: "released-" + roots[0].(*expr.RootExpr).API.Name}), nil + } + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "released", + command: "test", + position: pluginNormal, + prepare: prepare, + generate: generate, + }} + } + + packages := []string{"generated.local/first", "generated.local/second"} + roots := []*expr.RootExpr{ + {API: &expr.APIExpr{Name: "first", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + {API: &expr.APIExpr{Name: "second", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + } + for index, root := range roots { + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+root.API.Name, result.files[1].Path) + } + + require.Equal(t, packages, preparedPackages) + require.Equal(t, packages, generatedPackages) + for index, root := range roots { + require.Len(t, preparedRoots[index], 1) + require.Same(t, root, preparedRoots[index][0]) + require.Len(t, generatedRoots[index], 1) + require.Same(t, root, generatedRoots[index][0]) + require.Len(t, generatedFiles[index], 1) + require.Equal(t, "core-"+root.API.Name, generatedFiles[index][0].Path) + } +} + +// TestReleasedPluginNilFileRemainsVisibleUntilMerge checks that one plugin may +// return a one-item list containing nil. The next plugin receives that list +// unchanged, and Goa omits nil before writing files. Released Goa accidentally +// panicked when nil was the only file; generation now handles every list size +// consistently. +func TestReleasedPluginNilFileRemainsVisibleUntilMerge(t *testing.T) { + codegen.RunDSL(t, func() { + }) + registry := newRegistry() + registry.addCommand("test") + observedNil := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + { + name: "a-return-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, _ []*codegen.File) ([]*codegen.File, error) { + return []*codegen.File{nil}, nil + }, + }, + { + name: "b-observe-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + observedNil = len(files) == 1 && files[0] == nil + return files, nil + }, + }, + } + } + directory := t.TempDir() + writeGeneratedModule(t, filepath.Join(directory, codegen.Gendir), "generated.local/gen") + + outputs, err := generate(directory, "test", false, registry) + require.NoError(t, err) + require.True(t, observedNil) + require.Empty(t, outputs) +} + +// TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks verifies that a +// command/name pair cannot be registered once through each API. +func TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + called := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "duplicate", + command: "test", + position: pluginFirst, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }, + }} + } + registry.registerPlugin("duplicate", "test", pluginNormal, func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("test", registry) + require.ErrorContains(t, err, `plugin "duplicate" is already registered for command "test"`) + require.False(t, called) } // isolatedPluginRegistry builds a factory whose private phase counter must @@ -346,3 +701,52 @@ func isolatedPluginRegistry(t *testing.T) *registry { }) return registry } + +// releasedPluginForTest creates an old-style callback that checks the exact +// package, root, and file list passed from the shared generation run. +func releasedPluginForTest(name, command string, position pluginPosition, root eval.Root, events *[]string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: name, + command: command, + position: position, + prepare: func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + }, + generate: func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "released:" + name}), nil + }, + } +} + +// registerFactoryPluginForTest adds a factory plugin that records its current +// input file and appends one file for the following plugin. +func registerFactoryPluginForTest(registry *registry, name string, position pluginPosition, events *[]string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + }) +} diff --git a/codegen/generator/public_api_compatibility_test.go b/codegen/generator/public_api_compatibility_test.go new file mode 100644 index 0000000000..d449b7a076 --- /dev/null +++ b/codegen/generator/public_api_compatibility_test.go @@ -0,0 +1,22 @@ +// This file protects released generator types that do not expose or run the +// internal generation sequence. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// TestReleasedGeneratorFunctionType checks the released function signature. +func TestReleasedGeneratorFunctionType(t *testing.T) { + var generate Genfunc = func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "generated.go"}}, nil + } + files, err := generate("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, "generated.go", files[0].Path) +} diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index c6d0156d04..b9e9c9723d 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpcdata "goa.design/goa/v3/grpc/codegen/testdata" @@ -31,6 +32,7 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { }{ {"alias-chains", httpdata.AliasTypeDSL}, {"result-views", httpdata.ResultBodyMultipleViewsDSL}, + {"result-collection-custom-view", resultCollectionCustomViewDSL}, {"websocket-bidirectional", httpdata.BidirectionalStreamingDSL}, {"sse-anonymous-object", httpdata.SSEObjectDSL}, {"jsonrpc-mixed-transport", jsonrpcdata.JSONRPCKitchenSinkDSL}, @@ -39,16 +41,43 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) + expr.RunDSL(t, c.DSL) + roots, err := eval.Context.Roots() + require.NoError(t, err) for _, cmd := range []string{"gen", "example"} { - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, cmd, newDefaultRegistry()) + err := executeGeneration("generated.local/gen", roots, cmd, newDefaultRegistry()) require.NoError(t, err) } }) } } +// resultCollectionCustomViewDSL defines a generated collection whose method +// result selects a non-default view. Copying this result during planning must +// not add another collection to the evaluated design. +func resultCollectionCustomViewDSL() { + item := dsl.ResultType("application/vnd.item", func() { + dsl.Attribute("name", expr.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("items", func() { + dsl.Method("list", func() { + dsl.Result(dsl.CollectionOf(item), func() { + dsl.View("tiny") + }) + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) +} + // TestPreparedRootsRejectAttributeMutation proves that generation stops when // a core planner changes an attribute after the mutable lifecycle phase. func TestPreparedRootsRejectAttributeMutation(t *testing.T) { @@ -72,7 +101,7 @@ func TestPreparedRootsRejectAttributeMutation(t *testing.T) { }, ) - _, err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) require.ErrorContains(t, err, `core "attribute-mutator" plan mutated prepared design`) require.False(t, followingRan) } diff --git a/codegen/generator/run_examples.go b/codegen/generator/run_examples.go index be3d75a51d..1665ebb5b9 100644 --- a/codegen/generator/run_examples.go +++ b/codegen/generator/run_examples.go @@ -1,6 +1,6 @@ -// This file creates the mutable example state owned by one generation plan. -// Evaluated API roots retain only immutable factories, so repeated and -// concurrent runs never share consumed streams or recursion caches. +// This file creates a separate example generator for each evaluated Goa root +// in one command. Repeated or concurrent commands therefore do not share the +// random value sequence or the record of types currently being visited. package generator import ( diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 0de2938a5b..348914c3a3 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -1,5 +1,5 @@ -// This file assembles service-owned generated files after every participating -// Goa design root has planned and frozen its package declarations. +// This file assembles generated service files after every participating Goa +// design root has submitted its declarations and all package names are final. package generator import ( @@ -9,15 +9,10 @@ import ( "goa.design/goa/v3/expr" ) -// serviceFiles returns the service files described by plan's frozen package -// declarations and run-owned example state. +// serviceFiles returns the service files described by plan's completed package +// declarations and the example generator created for this run. func serviceFiles(plan *Plan) ([]*codegen.File, error) { - designRoots := serviceRoots(plan.Generation().Roots()) - plans := make([]*service.Plan, len(designRoots)) - for index, root := range designRoots { - plans[index] = plan.Service(root) - } - return service.Files(plans...) + return service.Files(plan.serviceOrder...) } // planServiceData declares service-owned generated package types for every Goa @@ -39,6 +34,7 @@ func planServiceData(plan *Plan) error { for index, root := range roots { plan.services[root] = servicePlans[index] } + plan.serviceOrder = servicePlans return nil } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go index bb82e6d4ba..82deb40dbe 100644 --- a/codegen/generator/service_union_package_scope_test.go +++ b/codegen/generator/service_union_package_scope_test.go @@ -795,9 +795,9 @@ func TestTransportSectionsOwnTheirImports(t *testing.T) { require.NotContains(t, header.String(), `"generated.local/gen/request/shared"`) } -// TestRelocatedStreamingUnionReferencesCompile verifies WebSocket and SSE -// files resolve relocated streaming declarations through the frozen service -// packages while their event and frame bodies remain transport-owned. +// TestRelocatedStreamingUnionReferencesCompile verifies ordinary HTTP +// WebSocket and SSE files and JSON-RPC SSE files resolve relocated streaming +// declarations through the frozen service packages. func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { registry := testRegistryFromGenfuncs([]testGenfunc{ {Plan: planServiceData, Generate: testServiceFiles}, @@ -826,13 +826,6 @@ func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { }) }) }) - dsl.Service("JSONSockets", func() { - dsl.Method("Socket", func() { - dsl.StreamingPayload(streamInput) - dsl.StreamingResult(streamOutput) - dsl.JSONRPC(func() {}) - }) - }) dsl.Service("JSONEvents", func() { dsl.Method("Events", func() { dsl.StreamingResult(sseEvent) @@ -851,7 +844,6 @@ func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { for _, path := range []string{ filepath.Join("http", "http_streams", "server", "websocket.go"), filepath.Join("http", "http_streams", "server", "sse.go"), - filepath.Join("jsonrpc", "json_sockets", "server", "websocket.go"), filepath.Join("jsonrpc", "json_events", "server", "sse.go"), } { require.FileExists(t, filepath.Join(genDir, path)) diff --git a/codegen/generator/test_helpers_test.go b/codegen/generator/test_helpers_test.go index 800f9a6d97..37ab10cd66 100644 --- a/codegen/generator/test_helpers_test.go +++ b/codegen/generator/test_helpers_test.go @@ -1,5 +1,4 @@ -// This file provides strict construction helpers for generator lifecycle tests -// whose package roots and planning claims are deliberately valid. +// This file builds complete generator plans for tests. package generator import ( @@ -11,8 +10,8 @@ import ( "goa.design/goa/v3/eval" ) -// mustTestPlan runs the production declaration, freeze, and link lifecycle for -// focused assembler tests and fails the calling test on any invalid phase. +// mustTestPlan chooses package names, finishes each selected generator, and +// fails the calling test if any step is invalid. func mustTestPlan(t *testing.T, genpkg string, roots []eval.Root, planners ...func(*Plan) error) *Plan { t.Helper() generation, err := codegen.NewGeneration(genpkg, roots) @@ -30,22 +29,22 @@ func mustTestPlan(t *testing.T, genpkg string, roots []eval.Root, planners ...fu return plan } -// testServiceFiles renders service files from the retained plan under test. +// testServiceFiles returns service files from the plan under test. func testServiceFiles(plan *Plan) ([]*codegen.File, error) { return serviceFiles(plan) } -// testTransportFiles renders transport files from the retained plan under test. +// testTransportFiles returns transport files from the plan under test. func testTransportFiles(plan *Plan) ([]*codegen.File, error) { return transportFiles(plan) } -// testOpenAPIFiles renders OpenAPI files from the retained plan under test. +// testOpenAPIFiles returns OpenAPI files from the plan under test. func testOpenAPIFiles(plan *Plan) ([]*codegen.File, error) { return openAPIFiles(plan) } -// assembleExampleFilesForTest renders example files from the retained plan. +// assembleExampleFilesForTest returns example files from the plan under test. func assembleExampleFilesForTest(plan *Plan) ([]*codegen.File, error) { return exampleFiles(plan) } diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 87dac948d2..6e1c6ccb71 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -4,7 +4,6 @@ package generator import ( "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" @@ -14,11 +13,9 @@ import ( // transportFiles returns all HTTP, gRPC, and JSON-RPC files for one run. func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File - generation := plan.Generation() - designRoots := serviceRoots(generation.Roots()) - for _, r := range designRoots { + for _, transport := range plan.transports { // HTTP - if httpPlan := plan.http[r]; httpPlan != nil { + if httpPlan := transport.http; httpPlan != nil { files = append(files, httpPlan.ServerFiles()...) files = append(files, httpPlan.ClientFiles()...) files = append(files, httpPlan.ServerTypeFiles()...) @@ -28,7 +25,7 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { } // GRPC - if grpcPlan := plan.grpc[r]; grpcPlan != nil { + if grpcPlan := transport.grpc; grpcPlan != nil { files = append(files, grpcPlan.ProtoFiles()...) files = append(files, grpcPlan.ServerFiles()...) files = append(files, grpcPlan.ClientFiles()...) @@ -38,7 +35,7 @@ func transportFiles(plan *Plan) ([]*codegen.File, error) { } // JSON-RPC - if jsonrpcPlan := plan.jsonrpc[r]; jsonrpcPlan != nil { + if jsonrpcPlan := transport.jsonrpc; jsonrpcPlan != nil { files = append(files, jsonrpcPlan.ServerFiles()...) files = append(files, jsonrpcPlan.ClientFiles()...) files = append(files, jsonrpcPlan.ServerTypeFiles()...) @@ -60,9 +57,6 @@ func planTransportData(plan *Plan) error { return nil } generation := plan.Generation() - if err := example.Plan(generation); err != nil { - return err - } roots := serviceRoots(generation.Roots()) if err := planHTTPTransports(plan, roots); err != nil { return err @@ -93,6 +87,15 @@ func planTransportData(plan *Plan) error { plan.grpc[root] = grpcPlans[index] } } + plan.transports = make([]*transportPlanEntry, len(roots)) + for index, root := range roots { + plan.transports[index] = &transportPlanEntry{ + http: plan.http[root], + jsonrpcHTTP: plan.jsonrpcHTTP[root], + jsonrpc: plan.jsonrpc[root], + grpc: plan.grpc[root], + } + } plan.transportDone = true return nil } diff --git a/codegen/generator/transport_plan_test.go b/codegen/generator/transport_plan_test.go new file mode 100644 index 0000000000..3cd8f74dbc --- /dev/null +++ b/codegen/generator/transport_plan_test.go @@ -0,0 +1,38 @@ +// This file checks that plugins can find the gRPC and JSON-RPC plans retained +// for the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +func TestTransportPlansUseExactRoot(t *testing.T) { + grpcRoot := &expr.RootExpr{} + jsonrpcRoot := &expr.RootExpr{} + grpcPlan := &grpccodegen.Plan{} + jsonrpcPlan := &jsonrpccodegen.Plan{} + plan := &Plan{ + grpc: map[*expr.RootExpr]*grpccodegen.Plan{grpcRoot: grpcPlan}, + jsonrpc: map[*expr.RootExpr]*jsonrpccodegen.Plan{jsonrpcRoot: jsonrpcPlan}, + } + + gotGRPC, ok := plan.GRPC(grpcRoot) + require.True(t, ok) + require.Same(t, grpcPlan, gotGRPC) + gotGRPC, ok = plan.GRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotGRPC) + + gotJSONRPC, ok := plan.JSONRPC(jsonrpcRoot) + require.True(t, ok) + require.Same(t, jsonrpcPlan, gotJSONRPC) + gotJSONRPC, ok = plan.JSONRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotJSONRPC) +} diff --git a/codegen/generator/viewed_transport_representation_integration_test.go b/codegen/generator/viewed_transport_representation_integration_test.go index 06abc7302b..c7edeef636 100644 --- a/codegen/generator/viewed_transport_representation_integration_test.go +++ b/codegen/generator/viewed_transport_representation_integration_test.go @@ -17,178 +17,6 @@ import ( "goa.design/goa/v3/expr" ) -const jsonRPCViewedWebSocketServerTest = `package server - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/stretchr/testify/require" - - service "generated.local/gen/jsonrpc_web_socket" - goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" -) - -type viewedService struct { - releaseWatch chan struct{} - sendErrors chan error -} - -func (*viewedService) HandleStream(context.Context, service.Stream) error { - return nil -} - -func (s *viewedService) Watch(ctx context.Context, stream service.WatchServerStream) error { - stream.SetView("summary") - go func() { - <-s.releaseWatch - s.sendErrors <- stream.SendResponse(ctx, viewedEvent("watch-event")) - }() - return nil -} - -func (s *viewedService) Inspect(ctx context.Context, stream service.InspectServerStream) error { - stream.SetView("detailed") - s.sendErrors <- stream.SendResponse(ctx, viewedEvent("inspect-event")) - close(s.releaseWatch) - return nil -} - -func (*viewedService) Fixed(context.Context, service.FixedServerStream) error { - return nil -} - -type wireMessage struct { - ID any ` + "`" + `json:"id"` + "`" + ` - Method string ` + "`" + `json:"method"` + "`" + ` - Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` - Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` -} - -func TestViewedWebSocketServerUsesPerMessageView(t *testing.T) { - svc := &viewedService{ - releaseWatch: make(chan struct{}), - sendErrors: make(chan error, 2), - } - directError := make(chan error, 1) - acknowledged := make(chan struct{}) - var acknowledge sync.Once - releaseServer := func() { - acknowledge.Do(func() { close(acknowledged) }) - } - handler := func(ctx context.Context, stream service.Stream) error { - if err := stream.SendWatchNotification(ctx, viewedEvent("direct-watch"), "summary"); err != nil { - return err - } - if err := stream.SendInspectNotification(ctx, viewedEvent("direct-inspect"), "detailed"); err != nil { - return err - } - if err := stream.SendFixedNotification(ctx, viewedEvent("direct-fixed")); err != nil { - return err - } - directError <- stream.SendWatchNotification(ctx, viewedEvent("invalid"), "unknown") - for range 2 { - if err := stream.Recv(ctx); err != nil { - return err - } - } - <-acknowledged - return nil - } - server := New( - handler, - service.NewEndpoints(svc), - goahttp.NewMuxer(), - goahttp.RequestDecoder, - goahttp.ResponseEncoder, - func(_ context.Context, _ http.ResponseWriter, err error) { t.Errorf("serve WebSocket: %v", err) }, - &websocket.Upgrader{}, - nil, - ) - httpServer := httptest.NewServer(server) - defer httpServer.Close() - conn, _, err := websocket.DefaultDialer.Dial( - "ws"+strings.TrimPrefix(httpServer.URL, "http")+"/stream", - nil, - ) - require.NoError(t, err) - defer func() { require.NoError(t, conn.Close()) }() - defer releaseServer() - require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) - - directWatch := readWireMessage(t, conn) - require.Equal(t, "watch", directWatch.Method) - require.JSONEq(t, - ` + "`" + `{"view":"summary","body":{"event_id":"direct-watch"}}` + "`" + `, - string(directWatch.Params), - ) - directInspect := readWireMessage(t, conn) - require.Equal(t, "inspect", directInspect.Method) - require.JSONEq(t, - ` + "`" + `{"view":"detailed","body":{"event_id":"direct-inspect","profile":{"display_name":"Ada"}}}` + "`" + `, - string(directInspect.Params), - ) - directFixed := readWireMessage(t, conn) - require.Equal(t, "fixed", directFixed.Method) - require.JSONEq(t, - ` + "`" + `{"event_id":"direct-fixed","profile":{"display_name":"Ada"}}` + "`" + `, - string(directFixed.Params), - ) - requireBoundaryError(t, <-directError, goa.InvalidEnumValue, "view") - - require.NoError(t, conn.WriteJSON(map[string]any{ - "jsonrpc": "2.0", "id": "watch-id", "method": "watch", "params": map[string]any{"key": "watch"}, - })) - require.NoError(t, conn.WriteJSON(map[string]any{ - "jsonrpc": "2.0", "id": "inspect-id", "method": "inspect", "params": map[string]any{"key": "inspect"}, - })) - inspect := readWireMessage(t, conn) - require.Equal(t, "inspect-id", inspect.ID) - require.JSONEq(t, - ` + "`" + `{"view":"detailed","body":{"event_id":"inspect-event","profile":{"display_name":"Ada"}}}` + "`" + `, - string(inspect.Result), - ) - watch := readWireMessage(t, conn) - require.Equal(t, "watch-id", watch.ID) - require.JSONEq(t, - ` + "`" + `{"view":"summary","body":{"event_id":"watch-event"}}` + "`" + `, - string(watch.Result), - ) - for range 2 { - require.NoError(t, <-svc.sendErrors) - } - releaseServer() -} - -func readWireMessage(t *testing.T, conn *websocket.Conn) wireMessage { - t.Helper() - var message wireMessage - require.NoError(t, conn.ReadJSON(&message)) - return message -} - -func viewedEvent(id string) *service.Event { - return &service.Event{EventID: id, Profile: &service.Profile{DisplayName: "Ada"}} -} - -func requireBoundaryError(t *testing.T, err error, name, field string) { - t.Helper() - var serviceError *goa.ServiceError - require.ErrorAs(t, err, &serviceError) - require.Equal(t, name, serviceError.Name) - require.NotNil(t, serviceError.Field) - require.Equal(t, field, *serviceError.Field) -} -` - // TestGeneratedHTTPViewedSSEServerUsesRequestView checks that an SSE request // uses the view selected by the service call. A method with one fixed view does // not choose a view while it runs. @@ -225,9 +53,16 @@ func TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation(t *testing.T) { runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/server") } +// TestGeneratedJSONRPCViewedServiceNameCompiles checks that the selected-view +// value does not hide the generated service package with the same Go name. +func TestGeneratedJSONRPCViewedServiceNameCompiles(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCQualifierCollisionDSL) + runGeneratedPackageTests(t, dir, "./jsonrpc/viewed/client") +} + // TestGeneratedJSONRPCSSEViewedRepresentation checks that JSON-RPC SSE pairs -// every view name with its matching body and rebuilds service results for both -// notifications and final responses. +// every view name with its matching body and rebuilds service results from +// notifications before the terminal response ends the stream. func TestGeneratedJSONRPCSSEViewedRepresentation(t *testing.T) { dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "client"), jsonRPCViewedSSEClientTest) @@ -235,41 +70,14 @@ func TestGeneratedJSONRPCSSEViewedRepresentation(t *testing.T) { } // TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation checks that SSE -// notifications and final responses contain the same view name and body that -// clients read. Methods with one fixed view contain only the body. +// notifications contain the same view name and body that clients read. +// Methods with one fixed view contain only the body. func TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation(t *testing.T) { dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "server"), jsonRPCViewedSSEServerTest) runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/server") } -// TestGeneratedJSONRPCWebSocketDirectSendsRequireView checks that each direct -// send chooses a view when several are legal. A method with one fixed view does -// not accept a view argument, and only methods that need a choice have SetView. -func TestGeneratedJSONRPCWebSocketDirectSendsRequireView(t *testing.T) { - dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) - writeGeneratedContractTest(t, dir, "jsonrpc_web_socket", jsonRPCViewedWebSocketInterfaceTest) - runGeneratedPackageTests(t, dir, "./jsonrpc_web_socket") -} - -// TestGeneratedJSONRPCWebSocketRoutesResponsesByRequestID checks that two -// methods can share one connection, write at the same time, and receive -// responses in reverse order without either method receiving the wrong result. -func TestGeneratedJSONRPCWebSocketRoutesResponsesByRequestID(t *testing.T) { - dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) - writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_web_socket", "client"), jsonRPCViewedWebSocketRuntimeTest) - runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_web_socket/client") -} - -// TestGeneratedJSONRPCWebSocketServerUsesPerCallViews checks that each request -// and direct send writes the body for its own view, even when two methods share -// one connection and finish out of order. -func TestGeneratedJSONRPCWebSocketServerUsesPerCallViews(t *testing.T) { - dir := generateViewedTransportModule(t, viewedJSONRPCWebSocketDSL) - writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_web_socket", "server"), jsonRPCViewedWebSocketServerTest) - runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_web_socket/server") -} - // generateViewedTransportModule generates a temporary Go module for one test. func generateViewedTransportModule(t *testing.T, design func()) string { t.Helper() @@ -386,6 +194,21 @@ func viewedJSONRPCUnaryDSL() { }) } +// viewedJSONRPCQualifierCollisionDSL uses the service name that previously +// matched the local selected-view value in the generated decoder. +func viewedJSONRPCQualifierCollisionDSL() { + event := viewedResultType() + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + }) +} + // viewedJSONRPCSSEDSL creates JSON-RPC SSE methods with selectable and fixed // views. func viewedJSONRPCSSEDSL() { @@ -410,40 +233,3 @@ func viewedJSONRPCSSEDSL() { }) }) } - -// viewedJSONRPCWebSocketDSL creates JSON-RPC WebSocket methods with selectable -// and fixed views on one service. -func viewedJSONRPCWebSocketDSL() { - event := viewedResultType() - dsl.Service("JSON RPC WebSocket", func() { - dsl.JSONRPC(func() { - dsl.Path("/stream") - }) - dsl.Method("watch", func() { - dsl.StreamingPayload(func() { - dsl.Attribute("key", dsl.String) - dsl.Required("key") - }) - dsl.StreamingResult(event) - dsl.JSONRPC(func() {}) - }) - dsl.Method("inspect", func() { - dsl.StreamingPayload(func() { - dsl.Attribute("key", dsl.String) - dsl.Required("key") - }) - dsl.StreamingResult(event) - dsl.JSONRPC(func() {}) - }) - dsl.Method("fixed", func() { - dsl.StreamingPayload(func() { - dsl.Attribute("key", dsl.String) - dsl.Required("key") - }) - dsl.StreamingResult(event, func() { - dsl.View("detailed") - }) - dsl.JSONRPC(func() {}) - }) - }) -} diff --git a/codegen/generator/viewed_transport_runtime_sources_test.go b/codegen/generator/viewed_transport_runtime_sources_test.go index 3b8541abe1..5fb8cfaba5 100644 --- a/codegen/generator/viewed_transport_runtime_sources_test.go +++ b/codegen/generator/viewed_transport_runtime_sources_test.go @@ -234,7 +234,7 @@ func TestMixedResultSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { require.Equal(t, []int{http.StatusOK}, recorder.statuses) require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) require.JSONEq(t, - ` + "`" + `{"EventID":"event-1","Profile":{"DisplayName":"Ada"}}` + "`" + `, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, sseData(t, recorder.Body.String()), ) require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) @@ -659,12 +659,10 @@ func TestViewedSSENotificationReconstructsTransportBody(t *testing.T) { require.Equal(t, "Ada", event.Profile.DisplayName) } -func TestViewedSSEFinalResponseReconstructsTransportBody(t *testing.T) { - data := ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"event_id":"event-1"}}}` + "`" + ` - event, err := recvWatch("response", data) - require.NoError(t, err) - require.Equal(t, "event-1", event.EventID) - require.Nil(t, event.Profile) +func TestViewedSSEFinalResponseEndsStream(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + _, err := recvWatch("response", data) + require.ErrorIs(t, err, io.EOF) } func TestViewedSSERejectsInvalidRepresentation(t *testing.T) { @@ -768,66 +766,102 @@ type unknownViewService struct { sendError chan error } -func (*viewedService) Watch(ctx context.Context, stream service.WatchServerStream) error { +type changedViewService struct { + sendError chan error +} + +func (*viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { stream.SetView("summary") - if err := stream.Send(ctx, viewedEvent()); err != nil { + if err := stream.Send(viewedEvent()); err != nil { return err } - stream.SetView("detailed") - return stream.SendAndClose(ctx, viewedEvent()) + return stream.Send(viewedEvent()) } -func (*viewedService) Fixed(ctx context.Context, stream service.FixedServerStream) error { - if err := stream.Send(ctx, viewedEvent()); err != nil { +func (*viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { return err } - return stream.SendAndClose(ctx, viewedEvent()) + return stream.Send(viewedEvent()) } -func (s *unknownViewService) Watch(ctx context.Context, stream service.WatchServerStream) error { +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { stream.SetView("unknown") - err := stream.Send(ctx, viewedEvent()) + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changedViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) s.sendError <- err return err } -func (*unknownViewService) Fixed(ctx context.Context, stream service.FixedServerStream) error { - return stream.SendAndClose(ctx, viewedEvent()) +func (*changedViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) } func TestVariableViewedSSEServerEmitsRepresentation(t *testing.T) { recorder := serveSSE(t, "watch") records := jsonRPCSSERecords(t, recorder.Body.String()) - require.Len(t, records, 2) + require.Len(t, records, 3) require.JSONEq(t, ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, string(records[0].Params), ) require.JSONEq(t, - ` + "`" + `{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, - string(records[1].Result), + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[1].Params), + ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) +} + +func TestChangedViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &changedViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 2) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), ) + require.NotEmpty(t, records[1].Error) } func TestFixedViewedSSEServerEmitsBodyOnly(t *testing.T) { recorder := serveSSE(t, "fixed") records := jsonRPCSSERecords(t, recorder.Body.String()) - require.Len(t, records, 2) + require.Len(t, records, 3) require.JSONEq(t, ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, string(records[0].Params), ) require.JSONEq(t, ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, - string(records[1].Result), + string(records[1].Params), ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) } func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { sendError := make(chan error, 1) recorder := serveSSEService(t, "watch", &unknownViewService{sendError: sendError}) requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") - require.Empty(t, recorder.Body.String()) + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 1) + require.Empty(t, records[0].Params) + require.NotEmpty(t, records[0].Error) } func serveSSE(t *testing.T, method string) *httptest.ResponseRecorder { @@ -854,6 +888,7 @@ func serveSSEService(t *testing.T, method string, svc service.Service) *httptest type sseRecord struct { Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + Error json.RawMessage ` + "`" + `json:"error"` + "`" + ` } func jsonRPCSSERecords(t *testing.T, event string) []sseRecord { @@ -886,373 +921,3 @@ func requireBoundaryError(t *testing.T, err error, name, field string) { require.Equal(t, field, *serviceError.Field) } ` - -const jsonRPCViewedWebSocketInterfaceTest = `package jsonrpcWebSocket - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDirectStreamViewSelectorsMatchMethodContract(t *testing.T) { - stream := reflect.TypeOf((*Stream)(nil)).Elem() - cases := []struct { - name string - count int - hasView bool - }{ - {"SendWatchNotification", 3, true}, - {"SendWatchResponse", 4, true}, - {"SendInspectNotification", 3, true}, - {"SendInspectResponse", 4, true}, - {"SendFixedNotification", 2, false}, - {"SendFixedResponse", 3, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if tc.hasView { - assertLastStringParameter(t, stream, tc.name, tc.count) - return - } - assertParameterCount(t, stream, tc.name, tc.count) - }) - } -} - -func TestRequestWrapperSetViewMatchesMethodContract(t *testing.T) { - for _, stream := range []reflect.Type{ - reflect.TypeOf((*WatchServerStream)(nil)).Elem(), - reflect.TypeOf((*InspectServerStream)(nil)).Elem(), - } { - _, hasSetView := stream.MethodByName("SetView") - require.True(t, hasSetView) - } - fixed := reflect.TypeOf((*FixedServerStream)(nil)).Elem() - _, hasSetView := fixed.MethodByName("SetView") - require.False(t, hasSetView) -} - -func assertLastStringParameter(t *testing.T, stream reflect.Type, name string, count int) { - t.Helper() - method, ok := stream.MethodByName(name) - require.True(t, ok) - require.Equal(t, count, method.Type.NumIn()) - require.Equal(t, reflect.String, method.Type.In(count-1).Kind()) -} - -func assertParameterCount(t *testing.T, stream reflect.Type, name string, count int) { - t.Helper() - method, ok := stream.MethodByName(name) - require.True(t, ok) - require.Equal(t, count, method.Type.NumIn()) -} -` - -const jsonRPCViewedWebSocketRuntimeTest = `package client - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/stretchr/testify/require" - - service "generated.local/gen/jsonrpc_web_socket" - goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" -) - -type wireRequest struct { - JSONRPC string ` + "`" + `json:"jsonrpc"` + "`" + ` - Method string ` + "`" + `json:"method"` + "`" + ` - Params map[string]any ` + "`" + `json:"params"` + "`" + ` - ID any ` + "`" + `json:"id"` + "`" + ` -} - -func TestConcurrentMethodStreamsDemultiplexReverseResponses(t *testing.T) { - requests := make(chan []wireRequest, 1) - serverErrors := make(chan error, 4) - acknowledged := make(chan struct{}) - var acknowledge sync.Once - releaseServer := func() { - acknowledge.Do(func() { close(acknowledged) }) - } - defer releaseServer() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err != nil { - serverErrors <- err - return - } - defer func() { - if err := conn.Close(); err != nil { - serverErrors <- err - } - }() - got := make([]wireRequest, 2) - for i := range got { - if err := conn.ReadJSON(&got[i]); err != nil { - serverErrors <- err - return - } - } - if fmt.Sprint(got[0].ID) == fmt.Sprint(got[1].ID) { - serverErrors <- fmt.Errorf("JSON-RPC request IDs are not distinct: %v", got[0].ID) - return - } - requests <- got - for i := len(got) - 1; i >= 0; i-- { - var result any - switch got[i].Method { - case "watch": - result = map[string]any{ - "view": "summary", - "body": map[string]any{"event_id": "watch-event"}, - } - case "inspect": - result = map[string]any{ - "view": "detailed", - "body": map[string]any{ - "event_id": "inspect-event", - "profile": map[string]any{"display_name": "Ada"}, - }, - } - default: - serverErrors <- fmt.Errorf("unexpected method %q", got[i].Method) - return - } - response := map[string]any{ - "jsonrpc": "2.0", - "id": got[i].ID, - "result": result, - } - if err := conn.WriteJSON(response); err != nil { - serverErrors <- err - return - } - } - <-acknowledged - })) - t.Cleanup(server.Close) - - host := strings.TrimPrefix(server.URL, "http://") - client := NewClient( - "http", host, http.DefaultClient, - goahttp.RequestEncoder, goahttp.ResponseDecoder, false, - websocket.DefaultDialer, nil, - ) - t.Cleanup(func() { - require.NoError(t, client.Close()) - }) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - rawWatch, err := client.Watch()(ctx, nil) - require.NoError(t, err) - rawInspect, err := client.Inspect()(ctx, nil) - require.NoError(t, err) - watch := rawWatch.(*WatchClientStream) - inspect := rawInspect.(*InspectClientStream) - - sendErrors := make(chan error, 2) - var sends sync.WaitGroup - sends.Add(2) - go func() { - defer sends.Done() - sendErrors <- watch.Send(&service.WatchPayload{Key: "watch"}) - }() - go func() { - defer sends.Done() - sendErrors <- inspect.Send(&service.InspectPayload{Key: "inspect"}) - }() - sends.Wait() - close(sendErrors) - for err := range sendErrors { - require.NoError(t, err) - } - - select { - case got := <-requests: - require.ElementsMatch(t, []string{"watch", "inspect"}, []string{got[0].Method, got[1].Method}) - case err := <-serverErrors: - require.NoError(t, err) - case <-ctx.Done(): - t.Errorf("server did not receive both requests: %v", ctx.Err()) - } - - type received struct { - method string - event *service.Event - err error - } - receivedEvents := make(chan received, 2) - go func() { - event, err := watch.Recv() - receivedEvents <- received{method: "watch", event: event, err: err} - }() - go func() { - event, err := inspect.Recv() - receivedEvents <- received{method: "inspect", event: event, err: err} - }() - for range 2 { - select { - case result := <-receivedEvents: - require.NoError(t, result.err) - require.NotNil(t, result.event) - require.Equal(t, result.method+"-event", result.event.EventID) - if result.method == "inspect" { - require.Equal(t, "Ada", result.event.Profile.DisplayName) - } else { - require.Nil(t, result.event.Profile) - } - case err := <-serverErrors: - require.NoError(t, err) - case <-ctx.Done(): - t.Errorf("clients did not receive both responses: %v", ctx.Err()) - } - } - releaseServer() -} - -func TestVariableViewRejectsInvalidRepresentation(t *testing.T) { - cases := []struct { - name string - result any - errorName string - field string - }{ - { - name: "missing view", - result: map[string]any{"body": map[string]any{"event_id": "event-1"}}, - errorName: goa.MissingField, - field: "view", - }, - { - name: "null view", - result: map[string]any{"view": nil, "body": map[string]any{"event_id": "event-1"}}, - errorName: goa.MissingField, - field: "view", - }, - { - name: "missing body", - result: map[string]any{"view": "summary"}, - errorName: goa.MissingField, - field: "body", - }, - { - name: "null body", - result: map[string]any{"view": "summary", "body": nil}, - errorName: goa.MissingField, - field: "body", - }, - { - name: "unknown view", - errorName: goa.InvalidEnumValue, - field: "view", - result: map[string]any{ - "view": "unknown", - "body": map[string]any{"event_id": "event-1"}, - }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := receiveWatchResult(t, tc.result) - requireBoundaryError(t, err, tc.errorName, tc.field) - }) - } -} - -func receiveWatchResult(t *testing.T, result any) error { - t.Helper() - requestRead := make(chan any, 1) - respond := make(chan struct{}) - acknowledged := make(chan struct{}) - defer close(acknowledged) - serverErrors := make(chan error, 4) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err != nil { - serverErrors <- err - return - } - defer func() { - if err := conn.Close(); err != nil { - serverErrors <- err - } - }() - var request wireRequest - if err := conn.ReadJSON(&request); err != nil { - serverErrors <- err - return - } - requestRead <- request.ID - <-respond - if err := conn.WriteJSON(map[string]any{ - "jsonrpc": "2.0", - "id": request.ID, - "result": result, - }); err != nil { - serverErrors <- err - return - } - <-acknowledged - })) - t.Cleanup(server.Close) - - client := NewClient( - "http", strings.TrimPrefix(server.URL, "http://"), http.DefaultClient, - goahttp.RequestEncoder, goahttp.ResponseDecoder, false, - websocket.DefaultDialer, nil, - ) - t.Cleanup(func() { - require.NoError(t, client.Close()) - }) - raw, err := client.Watch()(context.Background(), nil) - if err != nil { - return err - } - stream := raw.(*WatchClientStream) - if err := stream.Send(&service.WatchPayload{Key: "watch"}); err != nil { - return err - } - select { - case <-requestRead: - case err := <-serverErrors: - return err - case <-time.After(5 * time.Second): - return fmt.Errorf("server did not receive request") - } - received := make(chan error, 1) - go func() { - _, err := stream.Recv() - received <- err - }() - close(respond) - select { - case err := <-received: - return err - case err := <-serverErrors: - return err - case <-time.After(5 * time.Second): - return fmt.Errorf("client did not receive response") - } -} - -func requireBoundaryError(t *testing.T, err error, name, field string) { - t.Helper() - var serviceError *goa.ServiceError - require.ErrorAs(t, err, &serviceError) - require.Equal(t, name, serviceError.Name) - require.NotNil(t, serviceError.Field) - require.Equal(t, field, *serviceError.Field) -} -` diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 51e7656baf..90dc1c57a6 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -1,6 +1,6 @@ // This file generates Go transformations between compatible design types. -// Recursive helpers carry each side's package owner through nested named -// declarations so emitted references select the planned Go package. +// Recursive helpers carry the package path for each side through nested named +// declarations so emitted references use the package selected during planning. package codegen import ( @@ -8,12 +8,67 @@ import ( "fmt" "reflect" "slices" + "strconv" "strings" "text/template" "goa.design/goa/v3/expr" ) +type ( + // transformSnapshot copies one expression graph without merging user types + // that happen to come from the same authored declaration. + transformSnapshot struct { + attributes map[*expr.AttributeExpr]*expr.AttributeExpr + originals map[*expr.AttributeExpr]*expr.AttributeExpr + types map[expr.DataType]expr.DataType + } + + // transformSnapshotAttributor passes the original expression to name + // lookups that recorded expressions before the transform copied them. + transformSnapshotAttributor struct { + attributor Attributor + originals map[*expr.AttributeExpr]*expr.AttributeExpr + } + + // transformAttributePair identifies one exact pair in a plan. + transformAttributePair struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + } + + // transformUnwrapChoice is the result of one planned UnwrapPair call. + transformUnwrapChoice struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + directive *WrapDirective + } + + // transformStructuralChoices remembers what the two structural hooks + // returned while the transform was planned. + transformStructuralChoices struct { + unwrap func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) + fieldPair func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + planUnionHelpers func(*expr.AttributeExpr, *expr.AttributeExpr, func(*expr.AttributeExpr, *expr.AttributeExpr)) + unchanged func() bool + mutationErr error + sourceSnapshot *transformSnapshot + targetSnapshot *transformSnapshot + unwrapPairs map[transformAttributePair]transformUnwrapChoice + fieldPairs map[transformAttributePair]transformAttributePair + planned bool + } + + // transformValueReference identifies one mutable value on the active copy + // path. Slice length and capacity distinguish separate views of one array. + transformValueReference struct { + typeOf reflect.Type + pointer uintptr + length int + capacity int + } +) + var transformGoArrayT, transformGoMapT, transformGoUnionT *template.Template // NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 @@ -27,6 +82,423 @@ func init() { transformGoUnionT = template.Must(template.New("transformGoUnion").Funcs(fm).Parse(codegenTemplates.Read(transformGoUnionTmplName))) } +// newTransformSnapshot creates an exact, cycle-safe expression copier for one +// side of a transform. +func newTransformSnapshot() *transformSnapshot { + return &transformSnapshot{ + attributes: make(map[*expr.AttributeExpr]*expr.AttributeExpr), + originals: make(map[*expr.AttributeExpr]*expr.AttributeExpr), + types: make(map[expr.DataType]expr.DataType), + } +} + +// attribute copies attribute and every expression reachable from it. The +// placeholder is recorded before child types are copied so a true recursive +// edge points back to the same copied value. +func (s *transformSnapshot) attribute(attribute *expr.AttributeExpr) *expr.AttributeExpr { + if attribute == nil { + return nil + } + if _, copied := s.originals[attribute]; copied { + return attribute + } + if copied, ok := s.attributes[attribute]; ok { + return copied + } + copied := &expr.AttributeExpr{} + s.attributes[attribute] = copied + s.originals[copied] = attribute + copied.Type = s.dataType(attribute.Type) + copied.Bases = s.dataTypes(attribute.Bases) + copied.References = s.dataTypes(attribute.References) + copied.Description = attribute.Description + if attribute.Docs != nil { + docs := *attribute.Docs + copied.Docs = &docs + } + if attribute.Validation != nil { + copied.Validation = attribute.Validation.Dup() + copied.Validation.Values = copyTransformValue(attribute.Validation.Values).([]any) + } + copied.Meta = copyTransformMeta(attribute.Meta) + copied.DefaultValue = copyTransformValue(attribute.DefaultValue) + copied.DSLFunc = attribute.DSLFunc + if len(attribute.UserExamples) > 0 { + copied.UserExamples = make([]*expr.ExampleExpr, len(attribute.UserExamples)) + for index, example := range attribute.UserExamples { + if example == nil { + continue + } + value := *example + value.Value = copyTransformValue(example.Value) + copied.UserExamples[index] = &value + } + } + return copied +} + +// original returns the caller expression that was copied into the plan. +func (a *transformSnapshotAttributor) original(attribute *expr.AttributeExpr) *expr.AttributeExpr { + if original := a.originals[attribute]; original != nil { + return original + } + return attribute +} + +// Name gives the wrapped resolver the expression it used during name planning. +func (a *transformSnapshotAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + return a.attributor.Name(a.original(attribute), pkg, pointer, useDefault) +} + +// Ref gives the wrapped resolver the expression it used during name planning. +func (a *transformSnapshotAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { + return a.attributor.Ref(a.original(attribute), pkg) +} + +// Field gives the wrapped resolver the field it used during name planning. +func (a *transformSnapshotAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return a.attributor.Field(a.original(attribute), name, firstUpper) +} + +// Package gives the wrapped resolver the expression it used during planning. +func (a *transformSnapshotAttributor) Package(attribute *expr.AttributeExpr) string { + return a.attributor.Package(a.original(attribute)) +} + +// Enter retains the translation while entering the caller's planned child. +func (a *transformSnapshotAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformSnapshotAttributor{ + attributor: a.attributor.Enter(a.original(attribute)), + originals: a.originals, + } +} + +// IsSumType reports the layout selected by the caller's attributor. +func (a *transformSnapshotAttributor) IsSumType() bool { + return a.attributor.IsSumType() +} + +// ValidatorCall gives the wrapped resolver the expression it used during +// validation name planning. +func (a *transformSnapshotAttributor) ValidatorCall(attribute *expr.AttributeExpr, view, target, path string) string { + return a.attributor.ValidatorCall(a.original(attribute), view, target, path) +} + +// Scope returns the name scope owned by the caller's resolver. +func (a *transformSnapshotAttributor) Scope() *NameScope { + return a.attributor.Scope() +} + +// OneofWrapper forwards the gRPC oneof lookup when the wrapped resolver owns +// that lookup. A different resolver cannot render a protobuf oneof. +func (a *transformSnapshotAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + resolver, ok := a.attributor.(interface { + OneofWrapper(*expr.AttributeExpr) string + }) + if !ok { + panic("transform name resolver cannot resolve a protobuf oneof wrapper") // bug + } + return resolver.OneofWrapper(a.original(attribute)) +} + +// dataTypes copies a list through the same graph so shared and recursive +// declarations remain shared in the snapshot. +func (s *transformSnapshot) dataTypes(dataTypes []expr.DataType) []expr.DataType { + if dataTypes == nil { + return nil + } + copied := make([]expr.DataType, len(dataTypes)) + for index, dataType := range dataTypes { + copied[index] = s.dataType(dataType) + } + return copied +} + +// dataType copies one type while preserving its exact graph identity. +func (s *transformSnapshot) dataType(dataType expr.DataType) expr.DataType { + if dataType == nil || dataType == expr.Empty { + return dataType + } + if copied, ok := s.types[dataType]; ok { + return copied + } + switch actual := dataType.(type) { + case expr.Primitive: + return actual + case *expr.Array: + copied := &expr.Array{NonNullableElems: actual.NonNullableElems} + s.types[dataType] = copied + copied.ElemType = s.attribute(actual.ElemType) + return copied + case *expr.Object: + copied := &expr.Object{} + s.types[dataType] = copied + for _, named := range *actual { + copied.Set(named.Name, s.attribute(named.Attribute)) + } + return copied + case *expr.Map: + copied := &expr.Map{} + s.types[dataType] = copied + copied.KeyType = s.attribute(actual.KeyType) + copied.ElemType = s.attribute(actual.ElemType) + return copied + case *expr.Union: + copied := &expr.Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*expr.NamedAttributeExpr, len(actual.Values)), + } + s.types[dataType] = copied + for index, named := range actual.Values { + copied.Values[index] = &expr.NamedAttributeExpr{ + Name: named.Name, + Attribute: s.attribute(named.Attribute), + } + } + return copied + case expr.UserType: + copied := actual.Dup(nil) + s.types[dataType] = copied + copied.SetAttribute(s.attribute(actual.Attribute())) + return copied + default: + panic(fmt.Sprintf("cannot snapshot transform type %T", dataType)) // bug + } +} + +// copyTransformMeta copies both the metadata map and its value slices. +func copyTransformMeta(meta expr.MetaExpr) expr.MetaExpr { + if meta == nil { + return nil + } + copied := meta.Dup() + for name, values := range copied { + copied[name] = slices.Clone(values) + } + return copied +} + +// copyTransformValue copies the values accepted by Goa defaults, validations, +// and examples without changing their concrete Go type. +func copyTransformValue(value any) any { + if value == nil { + return nil + } + return copyTransformReflectValue( + reflect.ValueOf(value), + make(map[transformValueReference]struct{}), + ).Interface() +} + +// copyTransformReflectValue copies mutable JSON-compatible containers. +// Values with unsupported mutable kinds are rejected instead of shared. +func copyTransformReflectValue(value reflect.Value, active map[transformValueReference]struct{}) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copied := reflect.New(value.Type()).Elem() + copied.Set(copyTransformReflectValue(value.Elem(), active)) + return copied + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.New(value.Type().Elem()) + copied.Elem().Set(copyTransformReflectValue(value.Elem(), active)) + return copied + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for index := range value.Len() { + copied.Index(index).Set(copyTransformReflectValue(value.Index(index), active)) + } + return copied + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copied.SetMapIndex( + copyTransformReflectValue(iterator.Key(), active), + copyTransformReflectValue(iterator.Value(), active), + ) + } + return copied + case reflect.Array: + copied := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copied.Index(index).Set(copyTransformReflectValue(value.Index(index), active)) + } + return copied + case reflect.Struct: + copied := reflect.New(value.Type()).Elem() + copied.Set(value) + for index := range value.NumField() { + field := value.Type().Field(index) + if !field.IsExported() { + if transformTypeContainsReference(field.Type) { + panic(fmt.Sprintf( + "cannot copy transform value of type %s: unexported field %s contains mutable data", + value.Type(), + field.Name, + )) + } + continue + } + copied.Field(index).Set(copyTransformReflectValue(value.Field(index), active)) + } + return copied + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return value + default: + panic(fmt.Sprintf("cannot copy transform value of type %s", value.Type())) + } +} + +// enterTransformValueReference rejects a value that points back to one already +// being copied. Repeated values outside the active path are copied separately. +func enterTransformValueReference(value reflect.Value, active map[transformValueReference]struct{}) transformValueReference { + reference := transformValueReference{ + typeOf: value.Type(), + pointer: value.Pointer(), + } + if value.Kind() == reflect.Slice { + reference.length = value.Len() + reference.capacity = value.Cap() + } + if _, exists := active[reference]; exists { + panic(fmt.Sprintf("cannot copy cyclic transform value of type %s", value.Type())) + } + active[reference] = struct{}{} + return reference +} + +// transformTypeContainsReference reports whether a private field could share +// mutable state with the expression supplied by the caller. +func transformTypeContainsReference(valueType reflect.Type) bool { + switch valueType.Kind() { + case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface, + reflect.Func, reflect.Chan, reflect.UnsafePointer: + return true + case reflect.Array: + return transformTypeContainsReference(valueType.Elem()) + case reflect.Struct: + for index := range valueType.NumField() { + if transformTypeContainsReference(valueType.Field(index).Type) { + return true + } + } + } + return false +} + +// captureTransformStructuralHooks copies the hook set and replaces planning +// callbacks with memoized versions. Planning may add a choice; rendering may +// only read one that planning already made. unchanged checks that a hook left +// the plan-owned expression graphs intact. +func captureTransformStructuralHooks(hooks *TransformHooks, sourceSnapshot, targetSnapshot *transformSnapshot, unchanged func() bool) (*TransformHooks, *transformStructuralChoices) { + if hooks == nil { + return nil, nil + } + copied := *hooks + choices := &transformStructuralChoices{ + unwrap: copied.UnwrapPair, + fieldPair: copied.FieldPairAttrs, + planUnionHelpers: copied.PlanUnionHelpers, + unchanged: unchanged, + sourceSnapshot: sourceSnapshot, + targetSnapshot: targetSnapshot, + unwrapPairs: make(map[transformAttributePair]transformUnwrapChoice), + fieldPairs: make(map[transformAttributePair]transformAttributePair), + } + if choices.unwrap != nil { + copied.UnwrapPair = choices.unwrapPair + } + if choices.fieldPair != nil { + copied.FieldPairAttrs = choices.fieldPairAttrs + } + if choices.planUnionHelpers != nil { + copied.PlanUnionHelpers = choices.planUnionHelpersForPlan + } + return &copied, choices +} + +// unwrapPair returns the first attributes and wrapper instruction chosen for +// pair. After planning, a missing choice means rendering took a new path. +func (c *transformStructuralChoices) unwrapPair(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.unwrapPairs[pair]; ok { + return choice.source, choice.target, choice.directive + } + if c.planned { + panic("transform render requested an unplanned unwrap choice") // bug + } + source, target, directive := c.unwrap(source, target) + c.recordMutation("UnwrapPair") + source = c.sourceSnapshot.attribute(source) + target = c.targetSnapshot.attribute(target) + if directive != nil { + copy := *directive + copy.Target = c.targetSnapshot.attribute(directive.Target) + directive = © + } + c.unwrapPairs[pair] = transformUnwrapChoice{ + source: source, + target: target, + directive: directive, + } + return source, target, directive +} + +// fieldPairAttrs returns the first attributes chosen for pair. +func (c *transformStructuralChoices) fieldPairAttrs(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.fieldPairs[pair]; ok { + return choice.source, choice.target + } + if c.planned { + panic("transform render requested an unplanned field-pair choice") // bug + } + source, target = c.fieldPair(source, target) + c.recordMutation("FieldPairAttrs") + source = c.sourceSnapshot.attribute(source) + target = c.targetSnapshot.attribute(target) + c.fieldPairs[pair] = transformAttributePair{source: source, target: target} + return source, target +} + +// planUnionHelpersForPlan records the union helper choices and rejects a hook +// that changed a source or target expression while deciding those choices. +func (c *transformStructuralChoices) planUnionHelpersForPlan(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + c.planUnionHelpers(source, target, record) + c.recordMutation("PlanUnionHelpers") +} + +// recordMutation preserves the first planning-hook violation so +// NewTransformPlan can return it instead of a downstream compatibility error. +func (c *transformStructuralChoices) recordMutation(name string) { + if c.mutationErr == nil && !c.unchanged() { + c.mutationErr = fmt.Errorf("transform planning hook %s changed the retained plan", name) + } +} + // GoTransform produces Go code that initializes the data structure defined // by target from an instance of the data structure described by source. // The data structures can be objects, arrays or maps. The algorithm @@ -67,44 +539,125 @@ func GoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string } // GoTransformWithAttrs is GoTransform with a caller built TransformAttrs. It -// lets generators customize the transformation via TransformAttrs.Hooks, see +// plans the conversion before writing it so structural hooks run once. Returned +// helpers keep their released Name and a nil Declaration for existing plugins. +// Generators may customize the conversion through TransformAttrs.Hooks; see // TransformHooks. func GoTransformWithAttrs(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *TransformAttrs, newVar bool) (string, []*TransformFunctionData, error) { - code, err := TransformAttribute(source, target, sourceVar, targetVar, newVar, ta) + plan, err := NewTransformPlan(source, target, ta.Prefix, ta.Hooks) if err != nil { return "", nil, err } - helpers, err := collectLegacyHelpers(source, target, true, true, ta, make(map[string]*TransformFunctionData)) + if err := plan.BindContexts(ta.SourceCtx, ta.TargetCtx); err != nil { + return "", nil, err + } + + // Existing callers choose exact helper names while writing a file. Use one + // declaration for every occurrence of the same released name so Render can + // verify that the shared function body is identical. + legacyPackage := newGeneratedPackage("legacy transform helpers", "goa.design/goa/v3/codegen/transform", "") + declarations := make(map[string]*NameDeclaration, len(plan.helpers)) + nameAttrs := &TransformAttrs{ + SourceCtx: plan.sourceCtx, + TargetCtx: plan.targetCtx, + Prefix: plan.prefix, + } + for _, helper := range plan.helpers { + name := legacyTransformHelperName(helper.Source, helper.Target, nameAttrs) + declaration := declarations[name] + if declaration == nil { + declaration = NewExactName(NameFunction, name) + if err := legacyPackage.DeclareName(declaration); err != nil { + return "", nil, err + } + declarations[name] = declaration + } + if err := plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return "", nil, err + } + } + if err := legacyPackage.freeze(); err != nil { + return "", nil, err + } + code, helpers, err := plan.Render(sourceVar, targetVar, newVar) if err != nil { return "", nil, err } - return strings.TrimRight(code, "\n"), helpers, nil + for _, helper := range helpers { + helper.ID = TransformHelperID{} + helper.Declaration = nil + } + return code, helpers, nil } -// NewTransformPlan selects the exact recursive helper operations required to -// transform source into target. It does not resolve generated names. -func NewTransformPlan(source, target *expr.AttributeExpr) (*TransformPlan, error) { +// NewTransformPlan copies the source and target expression graphs and records +// every recursive conversion needed to turn one into the other. Distinct user +// types remain distinct even when they were copied from one declaration, and a +// true recursive edge points back to the same copied type. The plan walks only +// these copies during Render. It retains the original expression identities so +// name resolvers can find names they recorded before this call. +// +// NewTransformPlan calls UnwrapPair, FieldPairAttrs, and PlanUnionHelpers while +// planning and keeps their returned choices. The rendering hooks are called +// during Render. The caller may reuse or change source, target, and the +// TransformHooks value after this function returns; the plan owns its +// expression copies and its copied hook fields. A planning hook must inspect +// those copies without changing them; this function returns an error if it +// detects a mutation. +func NewTransformPlan(source, target *expr.AttributeExpr, prefix string, hooks *TransformHooks) (*TransformPlan, error) { + sourceSnapshot := newTransformSnapshot() + targetSnapshot := newTransformSnapshot() + source = sourceSnapshot.attribute(source) + target = targetSnapshot.attribute(target) + baselineSource := newTransformSnapshot() + baselineTarget := newTransformSnapshot() plan := &TransformPlan{ - source: source, - target: target, - operations: []*transformOperation{{}}, + source: source, + target: target, + sourceBaseline: baselineSource.attribute(source), + targetBaseline: baselineTarget.attribute(target), + sourceOriginals: sourceSnapshot.originals, + targetOriginals: targetSnapshot.originals, + prefix: prefix, + operations: []*transformOperation{{}}, + renders: make(map[transformRenderRequest]transformRenderResult), + } + retainedHooks, structuralChoices := captureTransformStructuralHooks(hooks, sourceSnapshot, targetSnapshot, func() bool { + return !plan.changed() + }) + plan.hooks = retainedHooks + err := planTransformOperation(source, target, true, true, plan.operations[0], make(map[transformPair]TransformHelperID), plan) + if structuralChoices != nil && structuralChoices.mutationErr != nil { + return nil, structuralChoices.mutationErr } - if err := planTransformOperation(source, target, true, true, plan.operations[0], make(map[transformPair]TransformHelperID), plan); err != nil { + if err != nil { return nil, err } + if structuralChoices != nil { + structuralChoices.planned = true + } return plan, nil } -// Helpers returns the recursive helper operations selected by the plan. The -// returned slice is independent of the plan; each descriptor and its ID are -// the exact values Render uses for calls and definitions. +// Helpers returns the recursive conversion functions selected by the plan so a +// generator can declare their names before writing code. Changing the returned +// slice or its Source and Target attributes does not change the plan. Source +// and Target identify the caller attributes that the plan copied; Render keeps +// and uses separate private attributes. func (p *TransformPlan) Helpers() []TransformHelper { - return slices.Clone(p.helpers) + helpers := slices.Clone(p.helpers) + for index := range helpers { + sourceSnapshot := newTransformSnapshot() + targetSnapshot := newTransformSnapshot() + helpers[index].Source = sourceSnapshot.attribute(helpers[index].Source) + helpers[index].Target = targetSnapshot.attribute(helpers[index].Target) + } + return helpers } -// BindHelperDeclaration assigns the package-level function that defines one -// retained helper operation. Binding happens during declaration planning so -// calls and definitions cannot choose names independently at render time. +// BindHelperDeclaration assigns the package-level function declared for one +// value returned by Helpers. Equivalent conversions may share a declaration; +// Render verifies that their generated definitions match. func (p *TransformPlan) BindHelperDeclaration(id TransformHelperID, declaration *NameDeclaration) error { if id.plan != p || id.index < 0 || id.index >= len(p.helpers) { return fmt.Errorf("transform helper does not belong to this plan") @@ -119,18 +672,13 @@ func (p *TransformPlan) BindHelperDeclaration(id TransformHelperID, declaration if helper.Declaration != nil && helper.Declaration != declaration { return fmt.Errorf("transform helper already has a different declaration") } - for index := range p.helpers { - if index != id.index && p.helpers[index].Declaration == declaration { - return fmt.Errorf("transform helper declaration is already bound to a different operation") - } - } helper.Declaration = declaration return nil } -// BindContexts assigns the frozen source and target type resolvers used by -// every call and helper definition in the retained plan. Contexts may be bound -// only once so later renders cannot change pointer or package-name policy. +// BindContexts copies the source and target type resolvers used by every call +// and helper definition. Call it after helper declarations and package names +// are final. It may be called once. func (p *TransformPlan) BindContexts(source, target *AttributeContext) error { if source == nil || target == nil { return fmt.Errorf("transform contexts must not be nil") @@ -139,19 +687,57 @@ func (p *TransformPlan) BindContexts(source, target *AttributeContext) error { return fmt.Errorf("transform contexts are already bound") } p.sourceCtx = source.Dup() + p.sourceCtx.Scope = &transformSnapshotAttributor{ + attributor: source.Scope, + originals: p.sourceOriginals, + } p.targetCtx = target.Dup() + p.targetCtx.Scope = &transformSnapshotAttributor{ + attributor: target.Scope, + originals: p.targetOriginals, + } return nil } -// Render formats the transformation and its retained recursive helpers using -// the contexts and declarations bound to the plan. -func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (string, []*TransformFunctionData, error) { +// Render writes the top-level conversion and its recursive function bodies. +// Every helper must have a declaration and BindContexts must have been called. +// Repeating the same source variable, target variable, and new-variable choice +// returns the exact first result without calling hooks again. Different +// variables are separate generation requests and can produce different code. +func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (code string, helpers []*TransformFunctionData, err error) { if p.sourceCtx == nil || p.targetCtx == nil { return "", nil, fmt.Errorf("transform contexts are not bound") } + request := transformRenderRequest{sourceVar: sourceVar, targetVar: targetVar, newVar: newVar} + if cached, ok := p.renders[request]; ok { + return cached.code, copyTransformFunctionData(cached.helpers), cached.err + } + if p.changed() { + return "", nil, fmt.Errorf("transform render hook changed the retained plan") + } + defer func() { + if p.changed() { + code = "" + helpers = nil + err = fmt.Errorf("transform render hook changed the retained plan") + } + p.renders[request] = transformRenderResult{ + code: code, + helpers: copyTransformFunctionData(helpers), + err: err, + } + helpers = copyTransformFunctionData(helpers) + }() + var hooks *TransformHooks + if p.hooks != nil { + copied := *p.hooks + hooks = &copied + } renderAttrs := TransformAttrs{ SourceCtx: p.sourceCtx.Dup(), TargetCtx: p.targetCtx.Dup(), + Prefix: p.prefix, + Hooks: hooks, } renderAttrs.helpers = make(map[TransformHelperID]TransformHelper, len(p.helpers)) for _, planned := range p.helpers { @@ -161,29 +747,157 @@ func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (string renderAttrs.helpers[planned.ID] = planned } renderAttrs.calls = &transformCallCursor{calls: p.operations[0].calls} - code, err := TransformAttribute(p.source, p.target, sourceVar, targetVar, newVar, &renderAttrs) + code, err = TransformAttribute(p.source, p.target, sourceVar, targetVar, newVar, &renderAttrs) if err != nil { return "", nil, err } if err := renderAttrs.calls.complete("top-level transform"); err != nil { return "", nil, err } - helpers := make([]*TransformFunctionData, 0, len(p.helpers)) + helpers = make([]*TransformFunctionData, 0, len(p.helpers)) + definitions := make(map[*NameDeclaration]*TransformFunctionData, len(p.helpers)) for index, planned := range p.helpers { entered := enterTransformAttrs(planned.Source, planned.Target, &renderAttrs) entered.calls = &transformCallCursor{calls: p.operations[index+1].calls} - helper, err := generateRetainedHelper(planned, entered) + helper, err := generateTransformHelper(planned, entered) if err != nil { return "", nil, err } if err := entered.calls.complete(fmt.Sprintf("transform helper occurrence %d", planned.Occurrence)); err != nil { return "", nil, err } + if previous := definitions[planned.Declaration]; previous != nil { + if !transformFunctionDefinitionsEqual(previous, helper) { + return "", nil, fmt.Errorf("transform helper declaration %q has different definitions", planned.Declaration.Name()) + } + continue + } + definitions[planned.Declaration] = helper helpers = append(helpers, helper) } return strings.TrimRight(code, "\n"), helpers, nil } +// copyTransformFunctionData copies generated helper descriptions before they +// cross the plan boundary. Declaration is intentionally shared: it is the +// immutable package-level name selected before rendering. +func copyTransformFunctionData(helpers []*TransformFunctionData) []*TransformFunctionData { + if helpers == nil { + return nil + } + copied := make([]*TransformFunctionData, len(helpers)) + for index, helper := range helpers { + if helper == nil { + continue + } + value := *helper + copied[index] = &value + } + return copied +} + +// changed reports whether code generation would read an expression different +// from the one retained when planning completed. Render hooks receive these +// expressions for inspection only; a mutation invalidates the render attempt. +func (p *TransformPlan) changed() bool { + return !transformAttributesEqual(p.source, p.sourceBaseline, make(map[transformAttributePair]struct{})) || + !transformAttributesEqual(p.target, p.targetBaseline, make(map[transformAttributePair]struct{})) +} + +// transformAttributesEqual compares the expression facts that conversion +// planning and rendering consume. It follows paired recursive attributes once. +func transformAttributesEqual(left, right *expr.AttributeExpr, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + pair := transformAttributePair{source: left, target: right} + if _, compared := seen[pair]; compared { + return true + } + seen[pair] = struct{}{} + if left.Description != right.Description || !reflect.DeepEqual(left.Docs, right.Docs) || + !reflect.DeepEqual(left.Validation, right.Validation) || !reflect.DeepEqual(left.Meta, right.Meta) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || len(left.Bases) != len(right.Bases) || + len(left.References) != len(right.References) || len(left.UserExamples) != len(right.UserExamples) { + return false + } + for index := range left.Bases { + if !transformDataTypesEqual(left.Bases[index], right.Bases[index], seen) { + return false + } + } + for index := range left.References { + if !transformDataTypesEqual(left.References[index], right.References[index], seen) { + return false + } + } + for index := range left.UserExamples { + if left.UserExamples[index] == nil || right.UserExamples[index] == nil { + if left.UserExamples[index] != right.UserExamples[index] { + return false + } + continue + } + if left.UserExamples[index].Summary != right.UserExamples[index].Summary || + left.UserExamples[index].Description != right.UserExamples[index].Description || + !reflect.DeepEqual(left.UserExamples[index].Value, right.UserExamples[index].Value) { + return false + } + } + return transformDataTypesEqual(left.Type, right.Type, seen) +} + +// transformDataTypesEqual compares the expression type graph below two +// attributes without comparing DSL functions or expression pointer identities. +func transformDataTypesEqual(left, right expr.DataType, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + if reflect.TypeOf(left) != reflect.TypeOf(right) { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case *expr.Array: + right := right.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, attribute := range *left { + other := (*right)[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Map: + right := right.(*expr.Map) + return transformAttributesEqual(left.KeyType, right.KeyType, seen) && + transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, attribute := range left.Values { + other := right.Values[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case expr.UserType: + right := right.(expr.UserType) + return left.Name() == right.Name() && transformAttributesEqual(left.Attribute(), right.Attribute(), seen) + default: + panic(fmt.Sprintf("cannot compare transform type %T", left)) // bug + } +} + // TransformAttribute returns the code to transform source attribute to target // attribute. It returns an error if source and target are not compatible for // transformation. It is exported so that TransformHooks implementations can @@ -193,7 +907,7 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar if h := ta.Hooks; h != nil && h.UnwrapPair != nil { var dir *WrapDirective source, target, dir = h.UnwrapPair(source, target) - prelude = dir.apply(&sourceVar, &targetVar, &newVar) + prelude = dir.apply(&sourceVar, &targetVar, &newVar, ta) } ta = enterTransformAttrs(source, target, ta) if err := IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { @@ -233,15 +947,15 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar return prelude + code, nil } -// TransformHelperName returns the retained or one-pass helper function used to -// initialize target from source. Retained transforms call it only for named -// object pairs; one-pass transform hooks retain their legacy naming contract. +// TransformHelperName returns the recursive function used to initialize target +// from source. A TransformPlan calls it only for named object pairs. +// GoTransformWithAttrs still chooses its helper name while writing code. func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { if ta.calls != nil { call := ta.calls.consume() helper, ok := ta.helpers[call.helper] if !ok { - panic("retained transform call references an unknown helper") // bug + panic("planned transform call references an unknown helper") // bug } return helper.Declaration.Name() } @@ -257,9 +971,6 @@ func legacyTransformHelperName(source, target *expr.AttributeExpr, ta *Transform prefix string ) { - if h := ta.Hooks; h != nil && h.HelperNameAttrs != nil { - source, target = h.HelperNameAttrs(source, target) - } ta = enterTransformAttrs(source, target, ta) sname = Goify(ta.SourceCtx.Scope.Name(source, ta.SourceCtx.Pkg(source), ta.SourceCtx.Pointer, ta.SourceCtx.UseDefault), true) tname = Goify(ta.TargetCtx.Scope.Name(target, ta.TargetCtx.Pkg(target), ta.TargetCtx.Pointer, ta.TargetCtx.UseDefault), true) @@ -280,6 +991,14 @@ func usesTransformHelper(source, target *expr.AttributeExpr) bool { return sourceNamed && targetNamed && expr.IsObject(source.Type) && expr.IsObject(target.Type) } +// transformFunctionDefinitionsEqual reports whether one function can serve +// every call assigned to the same declaration. +func transformFunctionDefinitionsEqual(left, right *TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code +} + // transformPrimitive returns the code to transform source primitive type to // target primitive type. The caller (TransformAttribute) already verified that // source and target are compatible. @@ -405,13 +1124,15 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // iterate through attributes to initialize rest of the struct fields and // handle default values walkMatches(source, target, func(srcMatt, tgtMatt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { + srcField := ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) + tgtField := ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) h := ta.Hooks if h != nil && h.FieldPairAttrs != nil { srcc, tgtc = h.FieldPairAttrs(srcc, tgtc) } var ( - srcVar = sourceVar + "." + ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) - tgtVar = targetVar + "." + ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) + srcVar = sourceVar + "." + srcField + tgtVar = targetVar + "." + tgtField ) var dir *WrapDirective if h != nil && h.UnwrapPair != nil { @@ -420,6 +1141,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st if err = IsCompatible(srcc.Type, tgtc.Type, sourceVar, targetVar); err != nil { return } + fieldAttrs := enterTransformAttrs(srcc, tgtc, ta) var code string { @@ -427,11 +1149,11 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // transforming the field value: nil guards and default value // handling keep using the unwrapped field variables. dispatchSrcVar, dispatchTgtVar, dispatchNewVar := srcVar, tgtVar, false - prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar) + prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar, ta) var postlude string if expr.IsUnion(tgtc.Type) && ta.TargetCtx.IsFieldPointer(n, tgtMatt.AttributeExpr) { unionVar := Goify(tgtMatt.ElemName(n), false) + "Value" - unionRef := ta.TargetCtx.Scope.Name(tgtc, ta.TargetCtx.Pkg(tgtc), false, ta.TargetCtx.UseDefault) + unionRef := fieldAttrs.TargetCtx.Scope.Name(tgtc, fieldAttrs.TargetCtx.Pkg(tgtc), false, fieldAttrs.TargetCtx.UseDefault) prelude += fmt.Sprintf("var %s %s\n", unionVar, unionRef) dispatchTgtVar = unionVar postlude = fmt.Sprintf("%s = &%s\n", tgtVar, unionVar) @@ -439,21 +1161,21 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st switch { case expr.IsArray(srcc.Type): if h != nil && h.TransformArray != nil { - code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsMap(srcc.Type): if h != nil && h.TransformMap != nil { - code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsUnion(srcc.Type): if h != nil && h.TransformUnion != nil { - code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, ta) + code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, fieldAttrs) } else { - code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case usesTransformHelper(srcc, tgtc): code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) @@ -551,20 +1273,16 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // source attribute is a primitive with default value // (the field is not a pointer in this case) code += "{\n\t" - var ( - zeroName string - nilable bool - ) + var zeroName string + nilable := IsNilable(tgtc.Type) || valueIsNilable(tdef) if h != nil && h.ZeroTypeName != nil { - var ok bool - if zeroName, ok = h.ZeroTypeName(tgtc); ok { - nilable = typeStringIsNilable(zeroName) + if name, ok := h.ZeroTypeName(tgtc); ok { + zeroName = name } } if zeroName == "" { if typeName, _ := GetMetaType(tgtc); typeName != "" { zeroName = typeName - nilable = typeStringIsNilable(typeName) } else if _, ok := tgtc.Type.(expr.UserType); ok { // aliased primitive zeroName = ta.TargetCtx.Scope.Ref(tgtc, ta.TargetCtx.Pkg(tgtc)) @@ -591,10 +1309,13 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st return buffer.String(), nil } -// typeStringIsNilable takes a go type as a string and checks for a '[]' or -// 'map[' prefix to see if it's a nilable primitive type. -func typeStringIsNilable(typeName string) bool { - return strings.HasPrefix(typeName, "[]") || strings.HasPrefix(typeName, "map[") +// valueIsNilable reports whether a typed default can be compared only with +// nil. It preserves named slices, maps, pointers, functions, and channels +// without inspecting the Go name supplied by field metadata. +func valueIsNilable(value any) bool { + kind := reflect.TypeOf(value).Kind() + return kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || + kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice } // transformArray generates Go code to transform source array to target array. @@ -602,17 +1323,24 @@ func transformArray(source, target *expr.Array, sourceVar, targetVar string, new if err := IsCompatible(source.ElemType.Type, target.ElemType.Type, sourceVar+"[0]", targetVar+"[0]"); err != nil { return "", err } + sourceElement := "val" + if ta.SourceCtx.IsArrayElementPointer(source) { + sourceElement = "*val" + } + loopVar, childAttrs := ta.EnterCollection() data := map[string]any{ - "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), - "SourceElem": source.ElemType, - "TargetElem": target.ElemType, - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), - "SourceIsObject": expr.IsObject(source.ElemType.Type), - "UseHelper": usesTransformHelper(source.ElemType, target.ElemType), + "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), + "SourceElem": source.ElemType, + "SourceElement": sourceElement, + "TargetElem": target.ElemType, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TransformAttrs": childAttrs, + "LoopVar": loopVar, + "SourceIsObject": expr.IsObject(source.ElemType.Type), + "TargetElemPointer": ta.TargetCtx.IsArrayElementPointer(target), + "UseHelper": usesTransformHelper(source.ElemType, target.ElemType), } var buf bytes.Buffer if err := transformGoArrayT.Execute(&buf, data); err != nil { @@ -681,13 +1409,15 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str unionPkg := ta.TargetCtx.Pkg(target) typeRef := ta.TargetCtx.Scope.Ref(target, unionPkg) - // Use deterministic temp var: 'obj' at top-level, 'tmp' for nested - // assignments. A "obj." prefix means this transform is emitted inside a - // case body of an enclosing union transform which already declared 'obj'. + // The outer union keeps Goa's released local spelling. Nested unions use + // numbered locals selected from traversal depth, never from caller code. tempVarName := "obj" - if strings.HasPrefix(targetVar, "obj.") { + if ta.unionDepth > 0 { tempVarName = "tmp" + tempVarName += strconv.Itoa(ta.unionDepth + 1) } + childAttrs := *ta + childAttrs.unionDepth++ cases := make([]map[string]any, 0, len(srcUnion.Values)) for i, st := range srcUnion.Values { @@ -707,21 +1437,21 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str "SourceAttr": st.Attribute, "TargetAttr": tt.Attribute, "TargetCastType": branchAttrs.TargetCtx.Scope.Ref(tt.Attribute, branchAttrs.TargetCtx.Pkg(tt.Attribute)), + "SourceNilable": IsNilable(st.Attribute.Type), "UseHelper": useHelper, "HelperName": helperName, }) } data := map[string]any{ - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TypeRef": typeRef, - "TargetIsPointer": strings.HasPrefix(typeRef, "*"), - "ValueTypeRef": strings.TrimPrefix(typeRef, "*"), - "TempVarName": tempVarName, - "Cases": cases, - "TransformAttrs": ta, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TypeRef": typeRef, + "ValueTypeRef": ta.TargetCtx.Scope.Name(target, unionPkg, false, ta.TargetCtx.UseDefault), + "TempVarName": tempVarName, + "Cases": cases, + "TransformAttrs": &childAttrs, } var buf bytes.Buffer @@ -731,20 +1461,41 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str return buf.String(), nil } -// planTransformOperation retains the helper call edges emitted by one -// top-level transform or helper body. Every nonrecursive named object -// occurrence gets a new helper. Only a source-target pair already active in -// the current helper body becomes a back-edge to its ancestor helper. +// planTransformOperation records the recursive calls made by the main +// conversion or one generated function. It reuses a function when that same +// source and target pair is already being converted. func planTransformOperation(source, target *expr.AttributeExpr, required, topLevel bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + return planTransformOperationWithHelper(source, target, required, topLevel, false, operation, active, plan) +} + +// planTransformOperationWithHelper records one conversion. forceHelper is true +// when a custom union renderer declares that it calls TransformHelperName for +// this pair, including named arrays and aliases that the default renderer +// writes inline. +func planTransformOperationWithHelper(source, target *expr.AttributeExpr, required, topLevel, forceHelper bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + helperSource, helperTarget := source, target + if forceHelper { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + if !sourceNamed && !targetNamed { + return fmt.Errorf("custom union transform helper requires a named source or target type") + } + } + if plan.hooks != nil && plan.hooks.UnwrapPair != nil { + source, target, _ = plan.hooks.UnwrapPair(source, target) + } + if !forceHelper { + helperSource, helperTarget = source, target + } if err := IsCompatible(source.Type, target.Type, "source", "target"); err != nil { return err } if topLevel { required = true - } else if usesTransformHelper(source, target) { + } else if forceHelper || usesTransformHelper(source, target) { pair := transformPair{ - source: transformIdentity(source.Type), - target: transformIdentity(target.Type), + source: source.Type, + target: target.Type, } if ancestor, recursive := active[pair]; recursive { operation.calls = append(operation.calls, transformCall{ @@ -756,8 +1507,8 @@ func planTransformOperation(source, target *expr.AttributeExpr, required, topLev id := TransformHelperID{plan: plan, index: len(plan.helpers)} plan.helpers = append(plan.helpers, TransformHelper{ ID: id, - Source: source, - Target: target, + Source: helperSource, + Target: helperTarget, Required: required, Occurrence: id.index + 1, }) @@ -767,7 +1518,15 @@ func planTransformOperation(source, target *expr.AttributeExpr, required, topLev body := &transformOperation{} plan.operations = append(plan.operations, body) active[pair] = id - err := planTransformChildren(source, target, required, body, active, plan) + bodySource, bodyTarget := source, target + if !forceHelper && plan.hooks != nil && plan.hooks.UnwrapPair != nil { + bodySource, bodyTarget, _ = plan.hooks.UnwrapPair(bodySource, bodyTarget) + } + if err := IsCompatible(bodySource.Type, bodyTarget.Type, "source", "target"); err != nil { + delete(active, pair) + return err + } + err := planTransformChildren(bodySource, bodyTarget, required, body, active, plan) delete(active, pair) return err } @@ -780,21 +1539,46 @@ func planTransformChildren(source, target *expr.AttributeExpr, required bool, op collect := func(source, target *expr.AttributeExpr, childRequired bool, top bool) error { return planTransformOperation(source, target, childRequired, top, operation, active, plan) } + elementTop := plan.hooks != nil && plan.hooks.InlineCompositeElems switch { case expr.IsArray(source.Type): - return collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, false) + return collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, elementTop) case expr.IsMap(source.Type): sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) - if err := collect(sourceMap.KeyType, targetMap.KeyType, required, false); err != nil { + if err := collect(sourceMap.KeyType, targetMap.KeyType, required, elementTop); err != nil { return err } - return collect(sourceMap.ElemType, targetMap.ElemType, required, false) + return collect(sourceMap.ElemType, targetMap.ElemType, required, elementTop) case expr.IsUnion(source.Type): targetUnion := expr.AsUnion(target.Type) if targetUnion == nil { return nil } - for index, branch := range expr.AsUnion(source.Type).Values { + sourceUnion := expr.AsUnion(source.Type) + if plan.hooks != nil && plan.hooks.TransformUnion != nil { + if plan.hooks.PlanUnionHelpers == nil { + return nil + } + var planErr error + plan.hooks.PlanUnionHelpers(source, target, func(sourceBranch, targetBranch *expr.AttributeExpr) { + if planErr != nil { + return + } + planErr = planTransformOperationWithHelper(sourceBranch, targetBranch, required, false, true, operation, active, plan) + }) + return planErr + } + if len(sourceUnion.Values) != len(targetUnion.Values) { + return fmt.Errorf("cannot transform union: number of union types differ (%s has %d, %s has %d)", + source.Type.Name(), len(sourceUnion.Values), target.Type.Name(), len(targetUnion.Values)) + } + for index, branch := range sourceUnion.Values { + if err := IsCompatible(branch.Attribute.Type, targetUnion.Values[index].Attribute.Type, "source", "target"); err != nil { + return fmt.Errorf("cannot transform union %s to %s: type at index %d: %w", + source.Type.Name(), target.Type.Name(), index, err) + } + } + for index, branch := range sourceUnion.Values { if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false); err != nil { return err } @@ -806,6 +1590,9 @@ func planTransformChildren(source, target *expr.AttributeExpr, required bool, op var walkErr error walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { if walkErr == nil { + if plan.hooks != nil && plan.hooks.FieldPairAttrs != nil { + sourceChild, targetChild = plan.hooks.FieldPairAttrs(sourceChild, targetChild) + } walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false) } }) @@ -814,17 +1601,7 @@ func planTransformChildren(source, target *expr.AttributeExpr, required bool, op return nil } -// transformIdentity returns the authored origin used only to detect a -// source-target pair already active in the current recursive helper body. -func transformIdentity(dataType expr.DataType) expr.DataType { - if userType, ok := dataType.(expr.UserType); ok { - return userType.Origin() - } - return dataType -} - -// consume returns the next retained helper call. Cursor position and the -// edge's typed helper ID select the exact operation. +// consume returns the next recursive call recorded by NewTransformPlan. func (c *transformCallCursor) consume() transformCall { if c.next >= len(c.calls) { panic("transform render consumed more helper calls than the plan retained") // bug @@ -834,7 +1611,7 @@ func (c *transformCallCursor) consume() transformCall { return call } -// complete reports a render that skipped retained helper calls. +// complete reports whether Render skipped any recorded recursive calls. func (c *transformCallCursor) complete(owner string) error { if c.next != len(c.calls) { return fmt.Errorf("%s rendered %d of %d retained helper calls", owner, c.next, len(c.calls)) @@ -842,8 +1619,8 @@ func (c *transformCallCursor) complete(owner string) error { return nil } -// enterTransformAttrs returns transform attributes whose source and target -// resolvers independently own the attributes being transformed. +// enterTransformAttrs returns a copy that looks up fields and types beneath the +// supplied source and target. func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *TransformAttrs) *TransformAttrs { entered := *attributes entered.SourceCtx = attributes.SourceCtx.Enter(source) @@ -851,9 +1628,9 @@ func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *Transfo return &entered } -// generateRetainedHelper formats one helper operation retained by +// generateTransformHelper writes one recursive conversion function selected by // TransformPlan. -func generateRetainedHelper(helper TransformHelper, ta *TransformAttrs) (*TransformFunctionData, error) { +func generateTransformHelper(helper TransformHelper, ta *TransformAttrs) (*TransformFunctionData, error) { code, err := TransformAttribute(helper.Source, helper.Target, "v", "res", true, ta) if err != nil { return nil, err @@ -864,6 +1641,7 @@ func generateRetainedHelper(helper TransformHelper, ta *TransformAttrs) (*Transf tfd := &TransformFunctionData{ ID: helper.ID, Declaration: helper.Declaration, + Name: helper.Declaration.Name(), ParamTypeRef: ta.SourceCtx.Scope.Ref(helper.Source, ta.SourceCtx.Pkg(helper.Source)), ResultTypeRef: ta.TargetCtx.Scope.Ref(helper.Target, ta.TargetCtx.Pkg(helper.Target)), Code: code, @@ -871,91 +1649,6 @@ func generateRetainedHelper(helper TransformHelper, ta *TransformAttrs) (*Transf return tfd, nil } -// collectLegacyHelpers renders the unbound helper definitions used by -// GoTransformWithAttrs. Hook-aware transports keep this one-pass contract -// until they acquire an explicit retained planning API. -func collectLegacyHelpers(source, target *expr.AttributeExpr, required, topLevel bool, attrs *TransformAttrs, seen map[string]*TransformFunctionData) (helpers []*TransformFunctionData, err error) { - if hooks := attrs.Hooks; hooks != nil && hooks.UnwrapPair != nil { - source, target, _ = hooks.UnwrapPair(source, target) - } - attrs = enterTransformAttrs(source, target, attrs) - if topLevel { - required = true - } else if usesTransformHelper(source, target) { - name := legacyTransformHelperName(source, target, attrs) - if _, exists := seen[name]; exists { - return nil, nil - } - helper, helperErr := generateLegacyHelper(source, target, required, attrs, seen) - if helperErr != nil { - return nil, helperErr - } - helpers = append(helpers, helper) - } - - elementTop := attrs.Hooks != nil && attrs.Hooks.InlineCompositeElems - collect := func(childSource, childTarget *expr.AttributeExpr, childRequired bool, childTop bool) error { - other, collectErr := collectLegacyHelpers(childSource, childTarget, childRequired, childTop, attrs, seen) - helpers = append(helpers, other...) - return collectErr - } - switch { - case expr.IsArray(source.Type): - return helpers, collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, elementTop) - case expr.IsMap(source.Type): - sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) - if err := collect(sourceMap.ElemType, targetMap.ElemType, required, elementTop); err != nil { - return helpers, err - } - return helpers, collect(sourceMap.KeyType, targetMap.KeyType, required, elementTop) - case expr.IsUnion(source.Type): - targetUnion := expr.AsUnion(target.Type) - if targetUnion == nil { - return helpers, nil - } - for index, branch := range expr.AsUnion(source.Type).Values { - if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false); err != nil { - return helpers, err - } - } - case expr.IsObject(source.Type): - if expr.IsUnion(target.Type) { - return helpers, nil - } - var walkErr error - walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { - if walkErr == nil { - walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false) - } - }) - if walkErr != nil { - return helpers, walkErr - } - } - return helpers, nil -} - -// generateLegacyHelper formats one helper definition and records its name -// before rendering its body so recursive calls stop at that definition. -func generateLegacyHelper(source, target *expr.AttributeExpr, required bool, attrs *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - name := legacyTransformHelperName(source, target, attrs) - helper := &TransformFunctionData{ - Name: name, - ParamTypeRef: attrs.SourceCtx.Scope.Ref(source, attrs.SourceCtx.Pkg(source)), - ResultTypeRef: attrs.TargetCtx.Scope.Ref(target, attrs.TargetCtx.Pkg(target)), - } - seen[name] = helper - code, err := TransformAttribute(source, target, "v", "res", true, attrs) - if err != nil { - return nil, err - } - if !required && !expr.IsPrimitive(source.Type) { - code = "if v == nil {\n\treturn nil\n}\n" + code - } - helper.Code = code - return helper, nil -} - // walkMatches iterates through the attributes of source and looks for // attributes with identical names in target. walkMatches calls the walker // function for each pair of matched attributes. Both source and target must be @@ -963,11 +1656,25 @@ func generateLegacyHelper(source, target *expr.AttributeExpr, required bool, att func walkMatches(source, target *expr.AttributeExpr, walker func(src, tgt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string)) { srcMatt := expr.NewMappedAttributeExpr(source) tgtMatt := expr.NewMappedAttributeExpr(target) + srcFields := originalMappedFields(source) + tgtFields := originalMappedFields(target) srcObj := expr.AsObject(srcMatt.Type) tgtObj := expr.AsObject(tgtMatt.Type) for _, nat := range *srcObj { if att := tgtObj.Attribute(nat.Name); att != nil { - walker(srcMatt, tgtMatt, nat.Attribute, att, nat.Name) + walker(srcMatt, tgtMatt, srcFields[nat.Name], tgtFields[nat.Name], nat.Name) } } } + +// originalMappedFields returns each child under the name used for matching. +// The returned children are the values supplied by the caller, not copies. +func originalMappedFields(attribute *expr.AttributeExpr) map[string]*expr.AttributeExpr { + object := expr.AsObject(attribute.Type) + fields := make(map[string]*expr.AttributeExpr, len(*object)) + for _, named := range *object { + name := strings.SplitN(named.Name, ":", 2)[0] + fields[name] = named.Attribute + } + return fields +} diff --git a/codegen/go_transform_hooks.go b/codegen/go_transform_hooks.go index 16432a7f33..a7f9ec612f 100644 --- a/codegen/go_transform_hooks.go +++ b/codegen/go_transform_hooks.go @@ -1,3 +1,6 @@ +// This file lets generators change the few conversion steps that differ from +// Goa's normal Go type conversion. The gRPC generator uses these functions for +// protobuf wrapper messages, fields, collections, unions, and nil checks. package codegen import ( @@ -7,37 +10,23 @@ import ( ) type ( - // TransformHooks are optional extension points consulted by the Go - // transform engine (GoTransformWithAttrs and the functions it drives). - // They let transport-specific generators—today the gRPC generator—alter - // well-defined aspects of the generated transformation code while - // sharing the engine driver (attribute walking, struct initialization, - // nil guards, default value handling and helper function collection). - // - // All fields are optional: a nil hook (or a nil Hooks pointer - // altogether) selects the engine default so that consumers which do not - // set hooks generate exactly the same code as before the hooks were - // introduced. + // TransformHooks lets a generator change specific parts of + // GoTransformWithAttrs. A nil function uses Goa's normal conversion. Hooks + // may inspect their expression arguments but must not mutate or retain them + // for later mutation. NewTransformPlan rejects a planning hook that changes + // its copied expressions. Render rejects later rendering mutations and caches + // each exact Render request, so hook state cannot produce different code when + // that request is repeated. TransformHooks struct { - // UnwrapPair adapts a source/target attribute pair before - // compatibility checks and code generation. It returns the - // attributes to use in place of src and tgt and a non-nil - // WrapDirective when one side referenced a synthetic wrapper - // message that was unwrapped (the gRPC generator wraps - // non-object protobuf message types in single-field messages). - // The engine applies the directive by initializing the wrapper - // and redirecting the source or target variable to the wrapper - // field. UnwrapPair is consulted by TransformAttribute, by - // transformObject for each matched field pair and by - // collectHelpers for each attribute pair it recurses into. + // UnwrapPair replaces a source and target before Goa checks or + // converts them. It also tells Goa whether generated code must + // create or read a protobuf wrapper message. UnwrapPair func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) - // FieldPairAttrs normalizes a matched object field attribute - // pair before the engine generates the field transformation, - // the nil guard and the default value handling. The gRPC - // generator resolves primitive alias user types to their - // underlying primitive attribute. The hook runs before - // UnwrapPair when transforming object fields. + // FieldPairAttrs changes a matched pair after their field names + // are known and before their values are converted. The gRPC + // generator replaces primitive aliases with their primitive + // types. The hook runs before UnwrapPair. FieldPairAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) // ConvertPrimitive returns the expression that initializes a @@ -65,26 +54,27 @@ type ( // TransformUnion overrides the rendering of union // transformations. srcParent and tgtParent are the attributes // of the object being transformed when the union is an object - // field and nil when the union is transformed directly: the - // gRPC generator derives the protoc-generated oneof wrapper - // type names from the parent message type name. + // field and nil when the union is transformed directly. A + // generator can use them when its union conversion depends on + // the enclosing object. TransformUnion func(source, target *expr.AttributeExpr, sourceVar, targetVar string, newVar bool, srcParent, tgtParent *expr.AttributeExpr, ta *TransformAttrs) (string, error) - // HelperNameAttrs normalizes a source/target attribute pair - // before the transform helper function name is computed. The - // gRPC generator strips struct:pkg:path metadata from the - // protobuf side because protoc-generated types ignore package - // overrides. - HelperNameAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + // PlanUnionHelpers lists the helper-name calls made by TransformUnion. + // It is called only when TransformUnion is set. Call record once for each + // source and target branch pair, in the same order that TransformUnion + // calls TransformHelperName. The shared planner writes each helper body + // and records any recursive calls it makes. A nil function means the + // custom union renderer does not call TransformHelperName. + PlanUnionHelpers func(source, target *expr.AttributeExpr, record func(source, target *expr.AttributeExpr)) // GuardCondition returns the condition that guards the // transformation code of an object field, e.g. // "if p.Name != nil {\n". src is the (possibly normalized) // field attribute, srcVar the source field variable, required // reports whether the field is required and srcPtr whether the - // source field is pointer-backed. An empty condition + // source field uses a pointer. An empty condition // with ok true means the field transformation must not be - // guarded. ok must be false to use the engine default policy + // guarded. ok must be false to use Goa's normal check // (the gRPC generator always guards non-primitives because // proto3 message fields are always nilable). GuardCondition func(src *expr.AttributeExpr, srcVar string, required, srcPtr bool) (string, bool) @@ -113,8 +103,8 @@ type ( InlineCompositeElems bool } - // WrapDirective describes how the engine must account for a synthetic - // wrapper message unwrapped by the UnwrapPair hook. + // WrapDirective tells TransformAttribute how to create or read a protobuf + // wrapper message removed by UnwrapPair. WrapDirective struct { // WrapTarget is true when the target attribute was the // wrapper: the engine initializes the wrapper value and @@ -122,19 +112,19 @@ type ( // the source attribute was the wrapper and the engine reads // the value being transformed from the wrapper field. WrapTarget bool - // InitTypeName is the Go type name used to initialize the - // wrapper when WrapTarget is true. - InitTypeName string + // Target is the wrapper type initialized when WrapTarget is true. + // The transform resolves its Go name after package names are fixed. + Target *expr.AttributeExpr // FieldName is the Go name of the wrapper field holding the // wrapped value. FieldName string } ) -// apply rewrites the transformation variables per the directive and returns -// the code that initializes the wrapper when the target is wrapped. A nil -// directive leaves the variables untouched and returns an empty string. -func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string { +// apply changes the source or target variable to use the wrapper field. It +// returns the code that creates the wrapper when the target uses one. A nil +// WrapDirective leaves the variables unchanged and returns an empty string. +func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool, attrs *TransformAttrs) string { if d == nil { return "" } @@ -146,7 +136,8 @@ func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string if *newVar { assign = ":=" } - code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, d.InitTypeName) + name := attrs.TargetCtx.Scope.Name(d.Target, attrs.TargetCtx.Pkg(d.Target), attrs.TargetCtx.Pointer, attrs.TargetCtx.UseDefault) + code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, name) *targetVar += "." + d.FieldName *newVar = false return code diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 1037ca0545..69ccab8b30 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -3,10 +3,12 @@ package codegen import ( + "encoding/json" "fmt" "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -17,12 +19,33 @@ import ( ) type ( + // transformOwnerAttributor records each nested type name selected while a + // test plans and writes a conversion. transformOwnerAttributor struct { prefix string owner string scope *NameScope entered *[]string } + + // transformIdentityAttributor records the exact attributes passed to field + // name lookups while a plan renders its copied expressions. + transformIdentityAttributor struct { + Attributor + fields *[]*expr.AttributeExpr + } + + // transformTypedValue exercises an accepted object value with a mutable + // exported field. + transformTypedValue struct { + Values []string + } + + // transformPrivateMutableValue cannot be copied without reading private + // mutable state. + transformPrivateMutableValue struct { + values []string + } ) func TestGoTransform(t *testing.T) { @@ -271,6 +294,166 @@ func TestGoTransformUnionAcrossTransportBoundary(t *testing.T) { require.NotContains(t, transportToService, "target.Scope = &") } +// TestGoTransformUnionKeepsNilSelectedBranch verifies that conversion leaves a +// selected nil branch for the destination validator to report. +func TestGoTransformUnionKeepsNilSelectedBranch(t *testing.T) { + details := goTypeTestUserType("Details", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + union := &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + {Name: "details", Attribute: &expr.AttributeExpr{Type: details}}, + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + {Name: "aliases", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "blob", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "anything", Attribute: &expr.AttributeExpr{Type: expr.Any}}, + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: union}, + &expr.AttributeExpr{Type: union}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + code = FormatTestCode(t, "package foo\nfunc transform(){\n"+code+"}") + testutil.AssertGo(t, "testdata/golden/go_transform_union_nil_branch.go.golden", code) +} + +// TestGoTransformRequiredPrimitiveArrayElements verifies that JSON presence +// pointers are removed after validation and added only when encoding that form. +func TestGoTransformRequiredPrimitiveArrayElements(t *testing.T) { + alias := goTypeTestUserType("Alias", expr.String) + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: alias}, + NonNullableElems: true, + }} + scope := NewNameScope() + service := NewAttributeContext(false, false, true, "", scope) + jsonBody := service.Dup() + jsonBody.ArrayElementPointer = true + + decode, _, err := GoTransform(array, array, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.Contains(t, decode, "target := make([]Alias, len(source))") + require.Contains(t, decode, "target[i] = *val") + + encode, _, err := GoTransform(array, array, "source", "target", service, jsonBody, "", true) + require.NoError(t, err) + require.Contains(t, encode, "target := make([]*Alias, len(source))") + require.Contains(t, encode, "var transformed Alias") + require.Contains(t, encode, "target[i] = &transformed") + + ordinary := expr.DupAtt(array) + expr.AsArray(ordinary.Type).NonNullableElems = false + unchanged, _, err := GoTransform(ordinary, ordinary, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.NotContains(t, unchanged, "*val") +} + +// TestGoTransformUsesDesignNilabilityForCustomTypes verifies that default +// handling does not infer comparability from a generated Go type spelling. +func TestGoTransformUsesDesignNilabilityForCustomTypes(t *testing.T) { + raw := &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: json.RawMessage("foo"), + Meta: expr.MetaExpr{ + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + }, + } + defaults := goTypeTestUserType("WithRaw", &expr.Object{ + {Name: "raw", Attribute: raw}, + }) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: defaults}, + &expr.AttributeExpr{Type: defaults}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "if target.Raw == nil") + require.NotContains(t, code, "var zero json.RawMessage") + compileTransformSource(t, `package transformtest + +import "encoding/json" + +type WithRaw struct { + Raw json.RawMessage +} + +func transform(source *WithRaw) *WithRaw { +`+code+` + return target +} +`) +} + +// TestGoTransformArrayLoopNameUsesNestingDepth verifies that brackets in a +// caller expression do not change the generated loop variable. +func TestGoTransformArrayLoopNameUsesNestingDepth(t *testing.T) { + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(array, array, "source", "target[index]", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "for i, val := range source") + require.NotContains(t, code, "for j, val := range source") +} + +// TestGoTransformUnionTemporaryUsesNestingDepth verifies that a caller's +// destination spelling does not select the local used for a union branch. +func TestGoTransformUnionTemporaryUsesNestingDepth(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(source, target, "source", "target.Selected", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "obj := actual") + require.NotContains(t, code, "tmp := actual") + + nested, err := transformUnion(source, target, "source", "target.Selected", false, &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + unionDepth: 1, + }) + require.NoError(t, err) + require.Contains(t, nested, "tmp2 := actual") + require.NotContains(t, nested, "obj := actual") +} + func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { source := transformOwnerTestType("SourceEnvelope", "SourceChoice", "source/types") target := transformOwnerTestType("TargetEnvelope", "TargetSelection", "target/models") @@ -314,12 +497,52 @@ func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") } +// TestGoTransformEntersArrayFieldOwners verifies that a named array element +// is resolved from the array field rather than the object that contains it. +func TestGoTransformEntersArrayFieldOwners(t *testing.T) { + sourceComponent := goTypeTestUserType("SourceComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + targetComponent := goTypeTestUserType("TargetComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + source := goTypeTestUserType("SourceEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: sourceComponent}, + }}}, + }) + target := goTypeTestUserType("TargetEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: targetComponent}, + }}}, + }) + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "make([]*targetArray.TargetComponent") + require.Contains(t, *sourceOwner.entered, "sourceArray") + require.Contains(t, *targetOwner.entered, "targetArray") +} + func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { root := RunDSL(t, testdata.TestTypesDSL) deep := root.UserType("Deep") plan, err := NewTransformPlan( &expr.AttributeExpr{Type: deep}, &expr.AttributeExpr{Type: deep}, + "", + nil, ) require.NoError(t, err) @@ -349,9 +572,11 @@ func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { for index, helper := range helpers { require.Equal(t, planned[index].ID, helper.ID) require.Same(t, declarations[helper.ID], helper.Declaration) - require.Same(t, plannedByID[helper.ID].Source, plan.Helpers()[index].Source) - require.Same(t, plannedByID[helper.ID].Target, plan.Helpers()[index].Target) - require.Empty(t, helper.Name) + require.NotSame(t, plannedByID[helper.ID].Source, plan.Helpers()[index].Source) + require.NotSame(t, plannedByID[helper.ID].Target, plan.Helpers()[index].Target) + require.Equal(t, plannedByID[helper.ID].Source.Type.Name(), plan.Helpers()[index].Source.Type.Name()) + require.Equal(t, plannedByID[helper.ID].Target.Type.Name(), plan.Helpers()[index].Target.Type.Name()) + require.Equal(t, helper.Declaration.Name(), helper.Name) rendered += helper.Code } for _, helper := range helpers { @@ -359,21 +584,683 @@ func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { } } +func TestTransformPlanKeepsDistinctCopiesWithOneOrigin(t *testing.T) { + sourceOrigin := transformObjectAttribute("SourceNode", true).Type.(expr.UserType) + targetOrigin := transformObjectAttribute("TargetNode", true).Type.(expr.UserType) + sourceOuter := sourceOrigin.Dup(nil) + sourceInner := sourceOrigin.Dup(nil) + targetOuter := targetOrigin.Dup(nil) + targetInner := targetOrigin.Dup(nil) + sourceInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + targetInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + sourceOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: sourceInner}}, + }}) + targetOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: targetInner}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceOuter}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetOuter}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 2) + require.NotSame(t, plan.Helpers()[0].Source.Type, plan.Helpers()[1].Source.Type) + require.NotSame(t, plan.Helpers()[0].Target.Type, plan.Helpers()[1].Target.Type) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[1].Declaration.Name()+"(v.Child)") +} + +func TestTransformPlanClosesExactRecursiveCycle(t *testing.T) { + sourceNode := &expr.UserTypeExpr{TypeName: "SourceNode"} + targetNode := &expr.UserTypeExpr{TypeName: "TargetNode"} + sourceNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}) + targetNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[0].Declaration.Name()+"(v.Next)") +} + +func TestTransformPlanCopiesCallerExpressions(t *testing.T) { + sourceObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + targetObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + sourceType := &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: sourceObject}, + } + targetType := &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: targetObject}, + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: sourceType}, + &expr.AttributeExpr{Type: targetType}, + "", + nil, + ) + require.NoError(t, err) + sourceObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + targetObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.NotContains(t, code, "Late") +} + +func TestTransformPlanCopiesTypedCollectionDefaults(t *testing.T) { + tests := []struct { + name string + dataType expr.DataType + defaultValue any + planned string + changed string + }{ + { + name: "slice", + dataType: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + defaultValue: []string{"planned"}, + planned: `[]string{"planned"}`, + changed: `[]string{"changed"}`, + }, + { + name: "string-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }, + defaultValue: map[string]int{"value": 1}, + planned: `map[string]int{"value":1}`, + changed: `map[string]int{"value":2}`, + }, + { + name: "integer-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.Int}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }, + defaultValue: map[int]string{1: "planned"}, + planned: `map[int]string{1:"planned"}`, + changed: `map[int]string{1:"changed"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: test.dataType} + targetField := &expr.AttributeExpr{Type: test.dataType, DefaultValue: test.defaultValue} + source := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }}, + }} + target := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + switch value := test.defaultValue.(type) { + case []string: + value[0] = "changed" + case map[string]int: + value["value"] = 2 + case map[int]string: + value[1] = "changed" + default: + t.Fatalf("missing mutation for %T", test.defaultValue) + } + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, test.planned) + require.NotContains(t, code, test.changed) + }) + } +} + +func TestCopyTransformValueCopiesNestedTypedShapes(t *testing.T) { + values := []string{"planned"} + object := transformTypedValue{Values: []string{"planned"}} + source := [2]any{&values, object} + copied := copyTransformValue(source).([2]any) + values[0] = "changed" + object.Values[0] = "changed" + + require.Equal(t, "planned", (*copied[0].(*[]string))[0]) + require.Equal(t, "planned", copied[1].(transformTypedValue).Values[0]) +} + +func TestCopyTransformValueRejectsUnsupportedMutableShape(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type chan int", + func() { copyTransformValue(make(chan int)) }, + ) +} + +func TestCopyTransformValueRejectsPrivateMutableField(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type codegen.transformPrivateMutableValue: unexported field values contains mutable data", + func() { copyTransformValue(transformPrivateMutableValue{values: []string{"value"}}) }, + ) +} + +func TestCopyTransformValueRejectsCycle(t *testing.T) { + value := make(map[string]any) + value["self"] = value + require.PanicsWithValue( + t, + "cannot copy cyclic transform value of type map[string]interface {}", + func() { copyTransformValue(value) }, + ) +} + +func TestCopyTransformValueRejectsFunction(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type func()", + func() { copyTransformValue(func() {}) }, + ) +} + +func TestTransformPlanNameLookupsUseOriginalAttributes(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + var sourceFields, targetFields []*expr.AttributeExpr + sourceContext := NewAttributeContext(false, false, true, "", NewNameScope()) + sourceContext.Scope = &transformIdentityAttributor{ + Attributor: sourceContext.Scope, + fields: &sourceFields, + } + targetContext := NewAttributeContext(false, false, true, "", NewNameScope()) + targetContext.Scope = &transformIdentityAttributor{ + Attributor: targetContext.Scope, + fields: &targetFields, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.NotEmpty(t, sourceFields) + require.NotEmpty(t, targetFields) + for _, field := range sourceFields { + require.Same(t, sourceField, field) + } + for _, field := range targetFields { + require.Same(t, targetField, field) + } +} + +func TestTransformPlanCapturesStructuralHookChoices(t *testing.T) { + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }} + var unwrapCalls, fieldCalls int + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + unwrapCalls++ + return source, target, nil + }, + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + fieldCalls++ + return source, target + }, + } + plan, err := NewTransformPlan(attribute, attribute, "", hooks) + require.NoError(t, err) + plannedUnwrapCalls, plannedFieldCalls := unwrapCalls, fieldCalls + require.Positive(t, plannedUnwrapCalls) + require.Positive(t, plannedFieldCalls) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.Equal(t, plannedUnwrapCalls, unwrapCalls) + require.Equal(t, plannedFieldCalls, fieldCalls) +} + +func TestTransformPlanRejectsPlanningUnwrapPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String, DefaultValue: "before"} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if object := expr.AsObject(source.Type); object != nil { + object.Attribute("value").DefaultValue = "after" + } + return source, target, nil + }, + }) + + require.EqualError(t, err, "transform planning hook UnwrapPair changed the retained plan") +} + +func TestTransformPlanRejectsPlanningFieldPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + target.Meta = expr.MetaExpr{"mutated": {"yes"}} + return source, target + }, + }) + + require.EqualError(t, err, "transform planning hook FieldPairAttrs changed the retained plan") +} + +func TestTransformPlanRejectsPlanningUnionHelperMutation(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: sourceBranch}}}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: targetBranch}}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformUnion: func(_ *expr.AttributeExpr, _ *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, _ *TransformAttrs) (string, error) { + return "", nil + }, + PlanUnionHelpers: func(source, target *expr.AttributeExpr, _ func(*expr.AttributeExpr, *expr.AttributeExpr)) { + expr.AsUnion(source.Type).Values[0].Attribute.DefaultValue = "after" + expr.AsUnion(target.Type).Values[0].Attribute.Meta = expr.MetaExpr{"mutated": {"yes"}} + }, + }) + + require.EqualError(t, err, "transform planning hook PlanUnionHelpers changed the retained plan") +} + +func TestGoTransformWithAttrsCallsStructuralHookOnce(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + var calls int + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + Hooks: &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + calls++ + return source, target, nil + }, + }, + } + + code, helpers, err := GoTransformWithAttrs(attribute, attribute, "source", "target", attrs, true) + require.NoError(t, err) + require.Empty(t, helpers) + require.Equal(t, "target := source", code) + require.Equal(t, 1, calls) +} + +func TestGoTransformWithAttrsRejectsConflictingHelperBodies(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + attribute := &expr.AttributeExpr{Type: container} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + _, _, err := GoTransformWithAttrs(attribute, attribute, "source", "target", &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + }, true) + require.EqualError(t, err, "transform helper declaration \"transformRecursiveToRecursive\" has different definitions") +} + +func TestTransformPlanUsesCustomUnionHelperOrderForNamedArraysAndAliases(t *testing.T) { + sourceAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "SourceAlias", + } + targetAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "TargetAlias", + } + sourceArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "SourceArray", + } + targetArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "TargetArray", + } + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: sourceAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: sourceArray}}, + }}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: targetAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: targetArray}}, + }}} + hooks := &TransformHooks{ + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + record(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute) + record(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute) + }, + TransformUnion: func(source, target *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, attrs *TransformAttrs) (string, error) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + arrayName := TransformHelperName(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute, attrs) + aliasName := TransformHelperName(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute, attrs) + return arrayName + "(source.Array)\n" + aliasName + "(source.Alias)\n", nil + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.Equal(t, "SourceArray", helpers[0].Source.Type.Name()) + require.Equal(t, "SourceAlias", helpers[1].Source.Type.Name()) + arrayDeclaration := NewExactName(NameFunction, "arrayHelper") + aliasDeclaration := NewExactName(NameFunction, "aliasHelper") + generatedPackage := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, generatedPackage.DeclareName(arrayDeclaration)) + require.NoError(t, generatedPackage.DeclareName(aliasDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, arrayDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, aliasDeclaration)) + require.NoError(t, generatedPackage.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 2) + require.Contains(t, code, "arrayHelper") + require.Contains(t, code, "aliasHelper") + require.Less(t, strings.Index(code, "arrayHelper"), strings.Index(code, "aliasHelper")) +} + +func TestTransformPlanCapturesUnwrappedHelperBody(t *testing.T) { + sourceNode := transformObjectAttribute("SourceNode", true) + targetNode := transformObjectAttribute("TargetNode", true) + wrapper := &expr.UserTypeExpr{ + TypeName: "TargetWrapper", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "field", Attribute: targetNode}, + }}, + } + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: sourceNode}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: wrapper}}, + }} + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") +} + +func TestTransformPlanRetainsWrapperAndInlineArrayCalls(t *testing.T) { + node := &expr.UserTypeExpr{TypeName: "Node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{}} + expr.AsObject(node.Type).Set("next", &expr.AttributeExpr{Type: node}) + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: node}}} + target := expr.DupAtt(source) + hooks := &TransformHooks{ + InlineCompositeElems: true, + TransformArray: func(source, target *expr.Array, sourceVar, targetVar string, newVar bool, attrs *TransformAttrs) (string, error) { + return TransformAttribute(source.ElemType, target.ElemType, sourceVar+"[0]", targetVar+"[0]", newVar, attrs) + }, + } + plan, err := NewTransformPlan(source, target, "copied", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + declaration := NewExactName(NameFunction, "copyNode") + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(plan.Helpers()[0].ID, declaration)) + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, 1) + require.Contains(t, code+helpers[0].Code, "copyNode") + + wrapper := &expr.UserTypeExpr{ + TypeName: "WrappedNode", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "field", Attribute: &expr.AttributeExpr{Type: node}}, + }}, + } + wrapperTarget := &expr.AttributeExpr{Type: wrapper} + wrapperHooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + wrapperPlan, err := NewTransformPlan(&expr.AttributeExpr{Type: node}, wrapperTarget, "wrapped", wrapperHooks) + require.NoError(t, err) + require.Len(t, wrapperPlan.Helpers(), 1) + wrapperDeclaration := NewExactName(NameFunction, "copyWrappedNode") + wrapperPackage := newGeneratedPackage("wrapper", "example.com/wrapper", "gen") + require.NoError(t, wrapperPackage.DeclareName(wrapperDeclaration)) + require.NoError(t, wrapperPlan.BindHelperDeclaration(wrapperPlan.Helpers()[0].ID, wrapperDeclaration)) + require.NoError(t, wrapperPackage.freeze()) + require.NoError(t, wrapperPlan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + wrapperCode, _, err := wrapperPlan.Render("source", "target", true) + require.NoError(t, err) + require.Contains(t, wrapperCode, "target := &WrappedNode{}") + require.Contains(t, wrapperCode, "target.Field") +} + func TestTransformPlanRetainsSameTypeSiblingOccurrences(t *testing.T) { plan := siblingTransformPlan(t) require.Len(t, plan.Helpers(), 2) require.NotEqual(t, plan.Helpers()[0].ID, plan.Helpers()[1].ID) } -func TestTransformPlanRejectsOneDeclarationForDifferentHelpers(t *testing.T) { +func TestTransformPlanHelperDescriptionsCannotChangeRenderGraph(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + + // Helper descriptions are used to choose declarations. Mutating one must + // never alter the private attributes that Render uses to write those + // declarations. + helpers[0].Source.Type = expr.String + + _, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Equal(t, "*Recursive", definitions[0].ParamTypeRef) +} + +func TestTransformPlanRejectsRenderHookMutation(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + source.ElemType.Type = expr.Int + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err = plan.Render("source", "target", true) + require.EqualError(t, err, "transform render hook changed the retained plan") +} + +func TestTransformPlanCachesResultAgainstMutationRetainedByRenderHook(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var retained *expr.Array + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + retained = source + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + + retained.ElemType.Type = expr.Int + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) +} + +func TestTransformPlanCachesRepeatedRender(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var renders int + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(_ *expr.Array, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + renders++ + return fmt.Sprintf("render%d", renders), nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, _, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + code, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + require.Equal(t, 1, renders) +} + +func TestTransformPlanSharesOneDeclarationForEquivalentHelpers(t *testing.T) { plan := siblingTransformPlan(t) helpers := plan.Helpers() require.Len(t, helpers, 2) declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 1) + require.Contains(t, code, "target.Left = transformRecursive(source.Left)") + require.Contains(t, code, "target.Right = transformRecursive(source.Right)") +} + +func TestTransformPlanRejectsSharedDeclarationForDifferentBehavior(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) - err := plan.BindHelperDeclaration(helpers[1].ID, declaration) - require.EqualError(t, err, "transform helper declaration is already bound to a different operation") + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + _, _, err := plan.Render("source", "target", true) + require.EqualError(t, err, "transform helper declaration \"transformRecursive\" has different definitions") } func TestTransformPlanRequiresEveryHelperDeclaration(t *testing.T) { @@ -448,16 +1335,16 @@ func TestTransformPlanHelperEligibilityMatchesCompositeRenderers(t *testing.T) { for pairName, pair := range pairs { t.Run(shapeName+"/"+pairName, func(t *testing.T) { source, target := shape(pair.source, pair.target) - plan, err := NewTransformPlan(source, target) + plan, err := NewTransformPlan(source, target, "", nil) require.NoError(t, err) require.Len(t, plan.Helpers(), pair.helpers) code, helpers := renderTransformPlan(t, plan) require.Len(t, helpers, pair.helpers) if pair.helpers == 0 { - require.NotContains(t, code, "CanonicalHelper") + require.NotContains(t, code, "canonicalHelper") } else { - require.Contains(t, code, "CanonicalHelper1") + require.Contains(t, code, "canonicalHelper1") } }) } @@ -497,11 +1384,15 @@ func TestTransformPlanMapKeyHelperReceivesKey(t *testing.T) { KeyType: targetKey, ElemType: &expr.AttributeExpr{Type: expr.String}, }}, + "", + nil, ) require.NoError(t, err) require.Len(t, plan.Helpers(), 1) - require.Same(t, sourceKey, plan.Helpers()[0].Source) - require.Same(t, targetKey, plan.Helpers()[0].Target) + require.NotSame(t, sourceKey, plan.Helpers()[0].Source) + require.NotSame(t, targetKey, plan.Helpers()[0].Target) + require.Equal(t, sourceKey.Type.Name(), plan.Helpers()[0].Source.Type.Name()) + require.Equal(t, targetKey.Type.Name(), plan.Helpers()[0].Target.Type.Name()) code, definitions := renderTransformPlan(t, plan) require.Len(t, definitions, 1) @@ -551,6 +1442,8 @@ func siblingTransformPlan(t *testing.T) *TransformPlan { plan, err := NewTransformPlan( &expr.AttributeExpr{Type: container}, &expr.AttributeExpr{Type: container}, + "", + nil, ) require.NoError(t, err) return plan @@ -575,6 +1468,8 @@ func mixedSiblingTransformPlan(t *testing.T) *TransformPlan { plan, err := NewTransformPlan( &expr.AttributeExpr{Type: container}, &expr.AttributeExpr{Type: container}, + "", + nil, ) require.NoError(t, err) return plan @@ -594,8 +1489,8 @@ func transformObjectAttribute(name string, named bool) *expr.AttributeExpr { }} } -// renderTransformPlan binds deterministic function declarations and contexts, -// then renders the retained operation and definitions. +// renderTransformPlan assigns fixed function declarations and contexts, then +// renders the stored operation and definitions. func renderTransformPlan(t *testing.T, plan *TransformPlan) (string, []*TransformFunctionData) { t.Helper() packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") @@ -614,7 +1509,7 @@ func renderTransformPlan(t *testing.T, plan *TransformPlan) (string, []*Transfor return code, helpers } -// compileTransformSource proves that a rendered transform and its retained +// compileTransformSource proves that a rendered transform and its stored // helper definitions agree on concrete Go argument and result types. func compileTransformSource(t *testing.T, source string) { t.Helper() @@ -633,6 +1528,8 @@ func compileTransformSource(t *testing.T, source string) { } } +// newTransformOwnerAttributor creates a test type-name provider and records +// every nested type it enters. func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { entered := make([]string, 0) return &transformOwnerAttributor{ @@ -673,14 +1570,31 @@ func (*transformOwnerAttributor) IsSumType() bool { return true } -func (a *transformOwnerAttributor) ValidatorName(att *expr.AttributeExpr, view string) string { - return "Validate" + a.Name(att, "", false, true) + Goify(view, true) +func (a *transformOwnerAttributor) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) } func (a *transformOwnerAttributor) Scope() *NameScope { return a.scope } +// Field records the expression identity before delegating the field name. +func (a *transformIdentityAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + *a.fields = append(*a.fields, attribute) + return a.Attributor.Field(attribute, name, firstUpper) +} + +// Enter keeps recording identities below the supplied object. +func (a *transformIdentityAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformIdentityAttributor{ + Attributor: a.Attributor.Enter(attribute), + fields: a.fields, + } +} + +// transformOwnerTestType builds a nested union type assigned to the requested +// generated package. func transformOwnerTestType(name, unionName, location string) expr.UserType { union := &expr.Union{ TypeName: unionName, @@ -709,6 +1623,8 @@ func transformOwnerTestType(name, unionName, location string) expr.UserType { } } +// codegenTypeName returns the Go name used by transformOwnerAttributor for one +// test type. func codegenTypeName(att *expr.AttributeExpr) string { if att.Type.Name() == "object" { return "Object" diff --git a/codegen/go_type_plan.go b/codegen/go_type_plan.go index 2b24108504..6dce0004a0 100644 --- a/codegen/go_type_plan.go +++ b/codegen/go_type_plan.go @@ -1,6 +1,6 @@ -// This file retains Go type layouts and exact generated declaration bindings -// before package names freeze. Linked formatters render only these copied facts; -// they never inspect the mutable Goa expression graph. +// This file records each Go type before generated package names are final. +// Formatting later uses the copied field names, package paths, and child types +// without reading the Goa expressions again. package codegen import ( @@ -13,51 +13,46 @@ import ( ) type ( - // GoTypeKind identifies one retained Go layout category. + // GoTypeKind states how a planned value is represented in Go. GoTypeKind uint8 - // GoTypeImport identifies one package used by a retained type spelling. - // Name is the preferred qualifier supplied by the type contract; generated - // declaration owners leave Name empty. + // GoTypeImport describes one package used in a planned Go type. GoTypeImport struct { - // Name is the preferred package qualifier, when the design supplied one. + // Name is the package name requested before the type, when present. Name string - // Path is the canonical Go import path. + // Path is the Go import path. Path string } - // GoTypeBindingRequest describes one named or union occurrence whose owning - // subsystem must bind an exact generated declaration during planning. + // GoTypeBindingRequest asks which generated declaration and package contain + // one attribute's named type or union. GoTypeBindingRequest struct { - // Attribute is the exact expression occurrence being planned. Binders may - // inspect it during planning; linked formatters never do. + // Attribute is the expression being planned. Attribute *expr.AttributeExpr - // InheritedOwner is the package inherited from the enclosing layout. + // InheritedOwner is the package path inherited from the enclosing type. InheritedOwner string - // Kind distinguishes named types from union declarations. + // Kind says whether Attribute contains a named type or a union. Kind GoTypeKind } - // GoTypeBinding binds one planned occurrence to its exact generated package - // declaration. Named occurrences require Type; union occurrences require - // Union. The other declaration field must be nil. + // GoTypeBinding gives a planned attribute the package path and generated + // declaration that will represent it. A named type sets Type, and a union + // sets Union. GoTypeBinding struct { - // Owner is the canonical import path that owns the declaration. + // Owner is the import path of the package containing the declaration. Owner string - // Type is the exact generated declaration for a named user type. + // Type is the generated declaration for a named user type. Type *TypeDeclaration - // Union is the exact generated declaration for a sum type. + // Union is the generated declaration for a union. Union *UnionDeclaration } - // GoTypeBinder supplies package ownership and canonical declaration records - // for named and union occurrences. The subsystem that owns those catalogs - // must provide this callback; core layout planning never infers ownership. + // GoTypeBinder returns the package path and generated declaration for a named + // type or union. PlanGoType does not choose these values itself. GoTypeBinder func(GoTypeBindingRequest) (GoTypeBinding, error) - // GoLayoutPolicy records the complete generated Go field and validation - // representation selected for one plan. A shared named value keeps service, - // view, and transport planners from independently reconstructing policy. + // GoLayoutPolicy contains the pointer and validation choices used throughout + // one planned Go type. GoLayoutPolicy struct { // Pointer forces primitive object fields to use pointers. Pointer bool @@ -68,26 +63,29 @@ type ( // UnionPointer uses pointers for optional sum-type union fields and for // required union fields when Pointer is also true. UnionPointer bool - // SumType reports that unions use Goa's generated struct representation. + // ArrayElementPointer uses pointers for required primitive array elements + // when generated input validation must distinguish null from a zero value. + ArrayElementPointer bool + // SumType reports whether unions use Goa's generated struct form. SumType bool } - // GoTypePlanOptions configures one exact layout occurrence. + // GoTypePlanOptions supplies the package, field name, and rules used to plan + // one attribute. GoTypePlanOptions struct { - // Owner is the package inherited by the root occurrence. + // Owner is the package path that will contain the top-level attribute. Owner string - // FieldName is the optional design field name of the root occurrence. + // FieldName is the design field name of the top-level attribute, when set. FieldName string - // Policy is the complete Go representation selected by the caller. + // Policy contains the pointer and validation choices selected by the caller. Policy GoLayoutPolicy - // Bind resolves every named type and union to an exact declaration. + // Bind returns the generated declaration for every named type and union. Bind GoTypeBinder } - // GoTypePlan is an immutable symbolic Go layout built while expressions are - // available and package declarations remain mutable. It retains source - // pointers only for occurrence identity; no method reads an expression after - // PlanGoType returns. + // GoTypePlan stores the complete Go form copied from one attribute. It keeps + // expression pointers only so callers can find which plans came from the same + // attribute; its methods do not read those expressions. GoTypePlan struct { kind GoTypeKind owner string @@ -113,19 +111,19 @@ type ( key *GoTypePlan } - // GoTypeQualifier returns the final package qualifier for one canonical - // import path after the generation freezes its shared import aliases. + // GoTypeQualifier returns the final package name written before a type from + // the given import path. GoTypeQualifier func(importPath string) string - // LinkedGoType formats one retained plan relative to an output package. It - // resolves only frozen declaration names and retained import identities. + // LinkedGoType formats a planned type for one output package after all type + // names and imported package names are final. LinkedGoType struct { plan *GoTypePlan outputPath string qualifier GoTypeQualifier } - // goTypePlanner owns the expression-reading planning phase. + // goTypePlanner reads attributes and builds GoTypePlan values. goTypePlanner struct { policy GoLayoutPolicy bind GoTypeBinder @@ -133,17 +131,17 @@ type ( ) const ( - // GoPrimitive is a built-in or explicitly imported primitive spelling. + // GoPrimitive is a built-in or explicitly imported primitive type. GoPrimitive GoTypeKind = iota + 1 - // GoArray is a slice layout with one retained element occurrence. + // GoArray is a slice with one planned element type. GoArray - // GoMap is a map layout with retained key and element occurrences. + // GoMap is a map with planned key and element types. GoMap - // GoStruct is an anonymous struct with retained ordered field occurrences. + // GoStruct is an anonymous struct with fields in source order. GoStruct - // GoNamed is a user type bound to an exact generated type declaration. + // GoNamed is a user type with a generated type declaration. GoNamed - // GoUnion is a sum type bound to an exact generated union declaration. + // GoUnion is a union with a generated union declaration. GoUnion // GoEmpty is Goa's built-in empty service type. GoEmpty @@ -151,9 +149,9 @@ const ( GoServiceError ) -// PlanGoType copies the complete Go layout for attribute while generated -// packages are still mutable. Callers link and format the returned plan only -// after the generation freezes its declaration and import-alias catalogs. +// PlanGoType copies the Go form of attribute before generated type and imported +// package names are final. Callers format the result after Generation.Freeze +// chooses those names. func PlanGoType(attribute *expr.AttributeExpr, options GoTypePlanOptions) (*GoTypePlan, error) { if attribute == nil { return nil, fmt.Errorf("plan Go type: attribute must not be nil") @@ -168,7 +166,7 @@ func PlanGoType(attribute *expr.AttributeExpr, options GoTypePlanOptions) (*GoTy return planner.plan(attribute, options.Owner, options.FieldName, nil, false) } -// String returns the layout category used in planning diagnostics. +// String returns the name of the Go type kind used in error messages. func (k GoTypeKind) String() string { switch k { case GoPrimitive: @@ -192,32 +190,30 @@ func (k GoTypeKind) String() string { } } -// Kind returns the retained layout category. +// Kind returns how this planned value is represented in Go. func (p *GoTypePlan) Kind() GoTypeKind { return p.kind } -// Owner returns the canonical import path inherited or selected for this -// exact occurrence. +// Owner returns the import path of the package containing this type. func (p *GoTypePlan) Owner() string { return p.owner } -// Policy returns the complete generated representation selected for this -// occurrence. +// Policy returns the pointer and validation choices used for this type. func (p *GoTypePlan) Policy() GoLayoutPolicy { return p.policy } -// MatchesOccurrence reports whether attribute is the exact expression pointer -// used to build this plan. It never reads the expression. +// MatchesOccurrence reports whether PlanGoType built this plan from attribute. +// It compares pointers without reading the expression. func (p *GoTypePlan) MatchesOccurrence(attribute *expr.AttributeExpr) bool { return p.occurrence == attribute } -// PlansForOccurrence returns every plan in this retained layout that was built -// from attribute. Separate entries preserve distinct field, owner, or pointer -// policies when one expression pointer is reused. +// PlansForOccurrence returns every child plan built from attribute. The same +// attribute may produce several plans with different field names, package +// paths, or pointer choices. func (p *GoTypePlan) PlansForOccurrence(attribute *expr.AttributeExpr) []*GoTypePlan { var matches []*GoTypePlan p.walk(func(candidate *GoTypePlan) { @@ -228,20 +224,20 @@ func (p *GoTypePlan) PlansForOccurrence(attribute *expr.AttributeExpr) []*GoType return matches } -// TypeDeclaration returns the exact named declaration retained for this -// occurrence, or nil for layouts that are not named user types. +// TypeDeclaration returns the generated declaration for a named user type. It +// returns nil for every other kind. func (p *GoTypePlan) TypeDeclaration() *TypeDeclaration { return p.typeDeclaration } -// UnionDeclaration returns the exact union declaration retained for this -// occurrence, or nil for layouts that are not unions. +// UnionDeclaration returns the generated declaration for a union. It returns +// nil for every other kind. func (p *GoTypePlan) UnionDeclaration() *UnionDeclaration { return p.unionDeclaration } -// FieldName returns the retained Go field identifier. It returns the exported -// spelling when firstUpper is true and the package-local spelling otherwise. +// FieldName returns the copied Go field name. It returns an exported name when +// firstUpper is true and an unexported name otherwise. func (p *GoTypePlan) FieldName(firstUpper bool) string { if firstUpper { return p.fieldNameUpper @@ -249,32 +245,31 @@ func (p *GoTypePlan) FieldName(firstUpper bool) string { return p.fieldNameLower } -// Description returns the copied design description for this occurrence. +// Description returns the description copied from the attribute. func (p *GoTypePlan) Description() string { return p.description } -// Tag returns the complete retained Go struct tag, including leading space. +// Tag returns the complete copied Go struct tag, including its leading space. func (p *GoTypePlan) Tag() string { return p.tag } -// IsPointer reports whether an enclosing struct field stores this occurrence -// through a pointer under the planned pointer/default policy. +// IsPointer reports whether an enclosing struct stores this value through a +// pointer under the selected pointer and default rules. func (p *GoTypePlan) IsPointer() bool { return p.fieldPointer } -// Import returns the package imported directly by this type spelling. The -// boolean is false for native and generated declaration spellings. +// Import returns the package written directly in this type name. The second +// result is false for built-in types and generated declarations. func (p *GoTypePlan) Import() (GoTypeImport, bool) { return p.directImport, p.hasDirectImport } -// ImportPreferences returns every distinct authored alias preference and -// generated declaration path reachable from this plan in stable layout order. -// Multiple preferences for one path remain distinct so generation can resolve -// them before freezing its import aliases. +// ImportPreferences returns each requested imported package name and each +// generated type package found in this plan, in field order. It keeps different +// requested names for the same path so Generation can choose the final name. func (p *GoTypePlan) ImportPreferences() []GoTypeImport { seen := make(map[GoTypeImport]struct{}) var imports []GoTypeImport @@ -297,31 +292,30 @@ func (p *GoTypePlan) ImportPreferences() []GoTypeImport { return imports } -// Fields returns a copy of the ordered anonymous struct field plans. +// Fields returns a copy of the anonymous struct fields in source order. func (p *GoTypePlan) Fields() []*GoTypePlan { return append([]*GoTypePlan(nil), p.fields...) } -// Branches returns a copy of the ordered union branch plans. +// Branches returns a copy of the union branches in source order. func (p *GoTypePlan) Branches() []*GoTypePlan { return append([]*GoTypePlan(nil), p.branches...) } -// Elem returns the retained array or map element plan, or nil for other kinds. +// Elem returns the planned array or map element type. It returns nil for other +// kinds. func (p *GoTypePlan) Elem() *GoTypePlan { return p.element } -// Key returns the retained map key plan, or nil for other kinds. +// Key returns the planned map key type. It returns nil for other kinds. func (p *GoTypePlan) Key() *GoTypePlan { return p.key } -// Equivalent reports whether p and other retain the same complete Go layout. -// Source expression pointers are deliberately excluded: independently built -// compiler copies are equivalent when they bind the same declarations and -// retain identical owners, policies, field spellings, tags, pointer choices, -// imports, and ordered child layouts. +// Equivalent reports whether p and other produce the same Go type. It compares +// declarations, package paths, pointer choices, field names, tags, imports, and +// child types, but does not compare source expression pointers. func (p *GoTypePlan) Equivalent(other *GoTypePlan) bool { if p == nil || other == nil { return p == other @@ -353,16 +347,14 @@ func (p *GoTypePlan) Equivalent(other *GoTypePlan) bool { return true } -// Link binds this retained layout to one generated output package after the -// owning generation freezes declarations and import aliases. Link itself is a -// pure binding operation; declaration access remains governed by the catalog's -// freeze contract. The returned formatter contains no expression traversal or -// metadata decisions. +// Link prepares this plan for formatting in outputPath after generated type and +// imported package names are final. The returned value uses only data already +// copied into the plan. func (p *GoTypePlan) Link(outputPath string, qualifier GoTypeQualifier) LinkedGoType { return LinkedGoType{plan: p, outputPath: outputPath, qualifier: qualifier} } -// Name returns the Go type spelling selected by the retained layout. +// Name returns the Go type name selected by the plan. func (l LinkedGoType) Name() string { switch l.plan.kind { case GoPrimitive: @@ -397,7 +389,7 @@ func (l LinkedGoType) Name() string { } } -// Def returns the Go definition selected by the retained layout. +// Def returns the complete Go type definition selected by the plan. func (l LinkedGoType) Def() string { switch l.plan.kind { case GoArray: @@ -441,8 +433,8 @@ func (l LinkedGoType) Def() string { } } -// Ref returns the retained Go reference spelling, including named object and -// union pointer semantics. +// Ref returns the Go type reference, including any pointer required for a named +// object or union. func (l LinkedGoType) Ref() string { name := l.Name() if l.plan.referencePointer { @@ -451,13 +443,14 @@ func (l LinkedGoType) Ref() string { return name } -// Field returns the retained field identifier for this exact occurrence. +// Field returns the copied Go field name for this planned value. func (l LinkedGoType) Field(firstUpper bool) string { return l.plan.FieldName(firstUpper) } -// Package returns the qualifier for this occurrence's owner relative to the -// linked output package, or the empty string for a same-package occurrence. +// Package returns the package name written before this type when referenced +// from the output package. It returns an empty string when both types are in the +// same package. func (l LinkedGoType) Package() string { if l.plan.owner == l.outputPath { return "" @@ -465,8 +458,8 @@ func (l LinkedGoType) Package() string { return l.qualify(l.plan.owner) } -// Enter links an exact retained child while preserving the output package and -// frozen import alias lookup. +// Enter returns a formatter for child that uses the same output package and +// imported package name lookup. func (l LinkedGoType) Enter(child *GoTypePlan) LinkedGoType { if child == nil { panic("enter nil retained Go type plan") @@ -474,8 +467,8 @@ func (l LinkedGoType) Enter(child *GoTypePlan) LinkedGoType { return LinkedGoType{plan: child, outputPath: l.outputPath, qualifier: l.qualifier} } -// Imports returns every recursively retained import except the linked output -// package itself. +// Imports returns every package used by this type and its children except the +// output package itself. func (l LinkedGoType) Imports() []GoTypeImport { preferences := l.plan.ImportPreferences() seen := make(map[string]struct{}) @@ -496,8 +489,8 @@ func (l LinkedGoType) Imports() []GoTypeImport { return imports } -// plan copies one exact occurrence and recursively retains anonymous child -// layouts. Named types terminate at their canonical declaration binding. +// plan copies one attribute and recursively plans anonymous child types. Named +// types stop at their generated declaration. func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName string, parent *expr.AttributeExpr, definitionPointer bool) (*GoTypePlan, error) { layoutAttribute := attribute for { @@ -553,7 +546,9 @@ func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName stri } case *expr.Array: plan.kind = GoArray - element, err := p.plan(actual.ElemType, owner, "", nil, expr.IsObject(actual.ElemType.Type)) + elementPointer := expr.IsObject(actual.ElemType.Type) || + arrayElementIsPointer(actual, p.policy.ArrayElementPointer) + element, err := p.plan(actual.ElemType, owner, "", nil, elementPointer) if err != nil { return nil, err } @@ -620,7 +615,8 @@ func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName stri return plan, nil } -// binding obtains and validates one exact subsystem-owned declaration record. +// The planner asks the caller for the generated declaration and checks that its +// package path and type match the request. func (p goTypePlanner) binding(attribute *expr.AttributeExpr, inheritedOwner string, kind GoTypeKind) (GoTypeBinding, error) { if p.bind == nil { return GoTypeBinding{}, fmt.Errorf("plan Go %s: declaration binder must not be nil", kind) @@ -661,8 +657,7 @@ func (p goTypePlanner) binding(attribute *expr.AttributeExpr, inheritedOwner str return binding, nil } -// walk visits retained plans in stable pre-order without consulting expression -// contents. +// walk visits this plan and then its children in their stored order. func (p *GoTypePlan) walk(visit func(*GoTypePlan)) { visit(p) if p.key != nil { @@ -679,9 +674,9 @@ func (p *GoTypePlan) walk(visit func(*GoTypePlan)) { } } -// walkImports visits type spellings owned by the referring file. A named -// union's declaration file, not each file that refers to the union, owns the -// imports required by its branch definitions. +// walkImports visits the types whose packages must be imported by the current +// file. It stops at a union because the file that declares the union imports +// the packages used by its branches. func (p *GoTypePlan) walkImports(visit func(*GoTypePlan)) { visit(p) if p.kind == GoUnion { @@ -698,9 +693,8 @@ func (p *GoTypePlan) walkImports(visit func(*GoTypePlan)) { } } -// customTypeQualifier returns the package identifier authored in a custom Go -// type. An explicit metadata alias wins; otherwise the first selector supplies -// the identifier while pointer and container syntax remains untouched. +// customTypeQualifier returns the package name written in a custom Go type. It +// uses alias when provided; otherwise it reads the name before the first dot. func customTypeQualifier(typeName, alias string) string { if alias != "" { return alias @@ -725,7 +719,8 @@ func goIdentifierRune(char rune) bool { return char == '_' || unicode.IsLetter(char) || unicode.IsDigit(char) } -// qualify resolves one retained external import and rejects an unusable alias. +// qualify returns the package name written before types from importPath. It +// panics when no usable name was planned. func (l LinkedGoType) qualify(importPath string) string { if l.qualifier == nil { panic(fmt.Sprintf("format retained Go type import %q without qualifier lookup", importPath)) @@ -737,8 +732,8 @@ func (l LinkedGoType) qualify(importPath string) string { return qualifier } -// qualifiedDeclaration renders one exact frozen declaration relative to the -// linked output package. +// qualifiedDeclaration adds the declaring package name before a generated type +// when that type is outside the output package. func (l LinkedGoType) qualifiedDeclaration(declaration *NameDeclaration) string { name := declaration.Name() if l.plan.owner == l.outputPath { diff --git a/codegen/go_type_plan_test.go b/codegen/go_type_plan_test.go index 2e4d6508df..4588fd9cfe 100644 --- a/codegen/go_type_plan_test.go +++ b/codegen/go_type_plan_test.go @@ -200,14 +200,16 @@ func TestGoTypePlanRetainsServiceErrorImport(t *testing.T) { generation, err := NewGeneration("generated.local/gen", nil) require.NoError(t, err) - require.NoError(t, generation.RequireImport(NewImport("goa", "example.com/fixed/goa"))) + pkg, err := generation.ClaimPackage(owner) + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("goa", "example.com/fixed/goa"))) for _, preference := range plan.ImportPreferences() { - require.NoError(t, generation.DeclareImport(NewImport(preference.Name, preference.Path))) + require.NoError(t, pkg.DeclareImport(NewImport(preference.Name, preference.Path))) } require.NoError(t, generation.Freeze()) - require.Equal(t, "goa2", generation.ImportName(goaPath)) + require.Equal(t, "goa2", pkg.ImportName(goaPath)) - linked := plan.Link(owner, generation.ImportName) + linked := plan.Link(owner, pkg.ImportName) require.Equal(t, "goa2.ServiceError", linked.Name()) require.Equal(t, "*goa2.ServiceError", linked.Ref()) require.Equal(t, []GoTypeImport{{Name: "goa2", Path: goaPath}}, linked.Imports()) @@ -270,6 +272,79 @@ func TestGoTypePlanRetainsPointerAndDefaultPolicy(t *testing.T) { } } +// TestGoTypePlanRetainsRequiredArrayElementPointers verifies that only JSON +// input layouts add pointers to primitive elements that must reject null. +func TestGoTypePlanRetainsRequiredArrayElementPointers(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + alias := goTypeTestUserType("Alias", expr.String) + bytesAlias := goTypeTestUserType("BytesAlias", expr.Bytes) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + alias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, alias), + }, + bytesAlias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, bytesAlias), + }, + }) + require.NoError(t, generation.Freeze()) + + tests := []struct { + name string + array *expr.Array + jsonBody bool + want string + }{ + { + name: "built-in string in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}, NonNullableElems: true}, + jsonBody: true, + want: "[]*string", + }, + { + name: "string alias in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + jsonBody: true, + want: "[]*Alias", + }, + { + name: "ordinary string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}}, + jsonBody: true, + want: "[]Alias", + }, + { + name: "service string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + want: "[]Alias", + }, + { + name: "bytes alias already represents null", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: bytesAlias}, NonNullableElems: true}, + jsonBody: true, + want: "[]BytesAlias", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(&expr.AttributeExpr{Type: test.array}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + }, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.want, plan.Link(owner, goTypeTestQualifier).Def()) + }) + } +} + // TestGoTypePlanFormatsContainersAndUnions verifies array, map, raw struct, // named object, and union layouts use their retained child and declaration // policies after linking. diff --git a/codegen/header.go b/codegen/header.go index db93d1c383..95dce37b79 100644 --- a/codegen/header.go +++ b/codegen/header.go @@ -2,12 +2,14 @@ package codegen import ( "encoding/json" + "fmt" "path/filepath" goa "goa.design/goa/v3/pkg" ) -// Header returns a Go source file header section template. +// Header returns a Go source file header section template. It panics when the +// imports give one package path different explicit package names. func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { return &SectionTemplate{ Name: "source-header", @@ -15,7 +17,7 @@ func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { Data: map[string]any{ "Title": title, "Pkg": pack, - "Imports": imports, + "Imports": appendImports(nil, imports...), }, } } @@ -34,8 +36,8 @@ func VersionFile() *File { } } -// AddImport adds imports to a section template that was generated with -// Header. +// AddImport adds imports to a section template that was generated with Header. +// It panics when one package path is given different explicit package names. func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if len(imprts) == 0 { return @@ -45,16 +47,42 @@ func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if imports, ok := data["Imports"]; ok { specs = imports.([]*ImportSpec) } - seen := make(map[ImportSpec]struct{}, len(specs)+len(imprts)) - for _, spec := range specs { - seen[*spec] = struct{}{} - } - for _, spec := range imprts { - if _, ok := seen[*spec]; ok { - continue + data["Imports"] = appendImports(specs, imprts...) +} + +// appendImports keeps one import for each package path. An explicit package +// name replaces an unspecified name. Different explicit names are a generator +// error because one Go file cannot use both names for the same package. +func appendImports(existing []*ImportSpec, additions ...*ImportSpec) []*ImportSpec { + positions := make(map[string]int, len(existing)+len(additions)) + result := make([]*ImportSpec, 0, len(existing)+len(additions)) + appendImport := func(spec *ImportSpec) { + position, ok := positions[spec.Path] + if !ok { + positions[spec.Path] = len(result) + result = append(result, spec) + return } - seen[*spec] = struct{}{} - specs = append(specs, spec) + current := result[position] + switch { + case current.Name == spec.Name, spec.Name == "": + return + case current.Name == "": + result[position] = spec + default: + panic(fmt.Sprintf( + "import path %q uses package names %q and %q", + spec.Path, + current.Name, + spec.Name, + )) + } + } + for _, spec := range existing { + appendImport(spec) + } + for _, spec := range additions { + appendImport(spec) } - data["Imports"] = specs + return result } diff --git a/codegen/import_aliases.go b/codegen/import_aliases.go index c5aab8ae33..c8f871daa0 100644 --- a/codegen/import_aliases.go +++ b/codegen/import_aliases.go @@ -1,6 +1,7 @@ -// This file owns the import qualifiers shared by every file rendered in one -// generation. Generators declare complete package paths during planning, then -// render headers and references from the immutable bindings after freeze. +// This file chooses the Go name written before each imported type or function. +// Generators submit complete import paths before source is written. After +// Generation.Freeze chooses each name, every file in the output package reads +// the same result. package codegen import ( @@ -12,24 +13,24 @@ import ( ) type ( - // importPriority identifies the closed import ownership classes used during - // deterministic qualifier allocation. + // importPriority states why a generator requested an import name. A smaller + // value wins when the same import path has several requested names. importPriority uint8 - // importAliasPlan records every preferred spelling for a complete package - // path before qualifiers are allocated. + // importAliasPlan records every requested Go name for each complete import + // path before Generation.Freeze chooses the result. importAliasPlan struct { candidates map[string]*importAliasCandidate } - // importAliasCandidate retains requested names by ownership class so the - // highest-priority request for one complete path wins. + // importAliasCandidate groups requested Go names by their reason so the + // strongest requirement for one complete import path wins. importAliasCandidate struct { spellings [importPriorityCount]map[string]bool } - // importAliasBinding records the qualifier and whether its import declaration - // must spell that qualifier explicitly. + // importAliasBinding records the Go name written before identifiers from one + // imported package and whether the import line must include that name. importAliasBinding struct { name string explicit bool @@ -43,36 +44,37 @@ const ( importPriorityCount ) -// RequireImport declares an import whose qualifier is required by static -// generated code. Two different required qualifiers for one path are rejected. -func (g *Generation) RequireImport(spec *ImportSpec) error { - return g.declareImport(spec, fixedImportPriority) +// RequireImport records an import name that generated source already refers +// to. It returns an error when the same path is required with a different name. +func (p *GeneratedPackage) RequireImport(spec *ImportSpec) error { + return p.declareImport(spec, fixedImportPriority) } -// ReserveGeneratedImport declares a preferred qualifier for a generated -// package. Required static imports take priority and may cause it to be -// suffixed. -func (g *Generation) ReserveGeneratedImport(spec *ImportSpec) error { - return g.declareImport(spec, generatedImportPriority) +// ReserveGeneratedImport requests a Go name for a generated package. A name +// required by source that is already fixed takes priority, so this request may +// receive a number at the end. +func (p *GeneratedPackage) ReserveGeneratedImport(spec *ImportSpec) error { + return p.declareImport(spec, generatedImportPriority) } -// DeclareImport declares a design-owned import. Repeated declarations of one -// complete path are merged before freeze. -func (g *Generation) DeclareImport(spec *ImportSpec) error { - return g.declareImport(spec, metadataImportPriority) +// DeclareImport requests the Go name supplied by design metadata. Repeated +// requests for the same complete path are combined before Generation.Freeze. +func (p *GeneratedPackage) DeclareImport(spec *ImportSpec) error { + return p.declareImport(spec, metadataImportPriority) } -// Import returns the frozen import declaration for importPath. It panics when -// called before freeze or for a path that planning did not declare. -func (g *Generation) Import(importPath string) *ImportSpec { - binding := g.importBinding(importPath) +// Import returns the import line data chosen for importPath. It panics before +// Generation.Freeze or when no generator submitted that path. +func (p *GeneratedPackage) Import(importPath string) *ImportSpec { + binding := p.importBinding(importPath) return &ImportSpec{Name: explicitImportName(importPath, binding), Path: importPath} } -// ImportName returns the frozen Go qualifier for importPath. It panics when -// called before freeze or for a path that planning did not declare. -func (g *Generation) ImportName(importPath string) string { - return g.importBinding(importPath).name +// ImportName returns the Go name written before identifiers imported from +// importPath. It panics before Generation.Freeze or when no generator submitted +// that path. +func (p *GeneratedPackage) ImportName(importPath string) string { + return p.importBinding(importPath).name } // HasRoot reports whether root is one of the exact evaluated roots registered @@ -86,10 +88,10 @@ func (g *Generation) HasRoot(root eval.Root) bool { return false } -// declareImport merges one path spelling into the generation plan. -func (g *Generation) declareImport(spec *ImportSpec, priority importPriority) error { - if g.frozen { - return fmt.Errorf("generation imports are frozen") +// declareImport records one requested Go name for an import path. +func (p *GeneratedPackage) declareImport(spec *ImportSpec, priority importPriority) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) } importPath, preferred := spec.Path, spec.Name if importPath == "" { @@ -98,10 +100,10 @@ func (g *Generation) declareImport(spec *ImportSpec, priority importPriority) er if preferred == "" { preferred = path.Base(importPath) } - candidate, ok := g.importPlan.candidates[importPath] + candidate, ok := p.importPlan.candidates[importPath] if !ok { candidate = &importAliasCandidate{} - g.importPlan.candidates[importPath] = candidate + p.importPlan.candidates[importPath] = candidate } spellings := candidate.spellings[priority] if spellings == nil { @@ -123,16 +125,16 @@ func (g *Generation) declareImport(spec *ImportSpec, priority importPriority) er return nil } -// freezeImports validates fixed requirements, then allocates qualifiers by -// ownership class and complete import path. -func (g *Generation) freezeImports() error { - paths := make([]string, 0, len(g.importPlan.candidates)) - for importPath := range g.importPlan.candidates { +// freezeImports rejects conflicting required names, then chooses one unused Go +// name for every import path. No import name changes afterward. +func (p *GeneratedPackage) freezeImports() error { + paths := make([]string, 0, len(p.importPlan.candidates)) + for importPath := range p.importPlan.candidates { paths = append(paths, importPath) } sort.Slice(paths, func(i, j int) bool { - left := g.importPlan.candidates[paths[i]].priority() - right := g.importPlan.candidates[paths[j]].priority() + left := p.importPlan.candidates[paths[i]].priority() + right := p.importPlan.candidates[paths[j]].priority() if left != right { return left < right } @@ -140,7 +142,7 @@ func (g *Generation) freezeImports() error { }) fixedPaths := make(map[string]string) for _, importPath := range paths { - candidate := g.importPlan.candidates[importPath] + candidate := p.importPlan.candidates[importPath] if candidate.priority() != fixedImportPriority { continue } @@ -158,7 +160,7 @@ func (g *Generation) freezeImports() error { scope := NewNameScope() bindings := make(map[string]importAliasBinding, len(paths)) for _, importPath := range paths { - candidate := g.importPlan.candidates[importPath] + candidate := p.importPlan.candidates[importPath] spellings := candidate.spellings[candidate.priority()] preferred, explicit := firstImportSpelling(spellings) name := scope.Unique(preferred) @@ -168,24 +170,25 @@ func (g *Generation) freezeImports() error { } } scope.Freeze() - g.imports = bindings + p.imports = bindings return nil } -// importBinding returns one planned binding after generation freeze. -func (g *Generation) importBinding(importPath string) importAliasBinding { - if !g.frozen { - panic("generation imports requested before freeze") +// importBinding returns the Go package name chosen for one import path after +// Generation.Freeze chooses all import names. +func (p *GeneratedPackage) importBinding(importPath string) importAliasBinding { + if !p.frozen { + panic(fmt.Sprintf("generated package %q imports requested before freeze", p.path)) } - binding, ok := g.imports[importPath] + binding, ok := p.imports[importPath] if !ok { panic(fmt.Sprintf("import path %q has no planned alias", importPath)) } return binding } -// firstImportSpelling returns the lexicographically first spelling so plan -// registration order cannot affect generated qualifiers. +// firstImportSpelling returns the alphabetically first requested name so the +// order in which generators submit requests cannot change generated source. func firstImportSpelling(spellings map[string]bool) (string, bool) { names := make([]string, 0, len(spellings)) for name := range spellings { @@ -196,7 +199,7 @@ func firstImportSpelling(spellings map[string]bool) (string, bool) { return name, spellings[name] } -// priority returns the strongest ownership class that requested this path. +// priority returns the strongest reason for a requested import name. func (c *importAliasCandidate) priority() importPriority { for priority := fixedImportPriority; priority < importPriorityCount; priority++ { if len(c.spellings[priority]) > 0 { @@ -206,8 +209,9 @@ func (c *importAliasCandidate) priority() importPriority { panic("import alias candidate has no spellings") } -// explicitImportName omits a redundant alias unless planning or collision -// resolution requires one. +// explicitImportName returns an empty string when the import path already ends +// in the chosen Go name; otherwise it returns the name written on the import +// line. func explicitImportName(importPath string, binding importAliasBinding) string { if binding.explicit || binding.name != path.Base(importPath) { return binding.name diff --git a/codegen/import_aliases_test.go b/codegen/import_aliases_test.go index 0b902eb7c9..72f4bd8756 100644 --- a/codegen/import_aliases_test.go +++ b/codegen/import_aliases_test.go @@ -14,15 +14,17 @@ import ( func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { freeze := func(reverse bool) map[string]string { generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) declare := []func() error{ func() error { - return generation.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) + return pkg.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) }, func() error { - return generation.ReserveGeneratedImport(NewImport("goa", "generated.local/gen/goa")) + return pkg.ReserveGeneratedImport(NewImport("goa", "generated.local/gen/goa")) }, func() error { - return generation.DeclareImport(NewImport("goa", "example.com/custom/goa")) + return pkg.DeclareImport(NewImport("goa", "example.com/custom/goa")) }, } if reverse { @@ -33,9 +35,9 @@ func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { } require.NoError(t, generation.Freeze()) return map[string]string{ - "fixed": generation.ImportName("goa.design/goa/v3/pkg"), - "generated": generation.ImportName("generated.local/gen/goa"), - "metadata": generation.ImportName("example.com/custom/goa"), + "fixed": pkg.ImportName("goa.design/goa/v3/pkg"), + "generated": pkg.ImportName("generated.local/gen/goa"), + "metadata": pkg.ImportName("example.com/custom/goa"), } } @@ -53,15 +55,17 @@ func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { freeze := func(reverse bool) string { generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) declare := []func() error{ func() error { - return generation.RequireImport(NewImport("json", "encoding/json")) + return pkg.RequireImport(NewImport("json", "encoding/json")) }, func() error { - return generation.ReserveGeneratedImport(NewImport("jason", "encoding/json")) + return pkg.ReserveGeneratedImport(NewImport("jason", "encoding/json")) }, func() error { - return generation.DeclareImport(NewImport("jsonp", "encoding/json")) + return pkg.DeclareImport(NewImport("jsonp", "encoding/json")) }, } if reverse { @@ -71,7 +75,7 @@ func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { require.NoError(t, register()) } require.NoError(t, generation.Freeze()) - return generation.ImportName("encoding/json") + return pkg.ImportName("encoding/json") } require.Equal(t, "json", freeze(false)) @@ -83,10 +87,12 @@ func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { freeze := func(first, second string) string { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) - require.NoError(t, generation.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) require.NoError(t, generation.Freeze()) - return generation.ImportName("generated.local/gen/value") + return pkg.ImportName("generated.local/gen/value") } require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) @@ -96,10 +102,12 @@ func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { // templates cannot request two different mandatory spellings for one path. func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.RequireImport(NewImport("json", "encoding/json"))) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("json", "encoding/json"))) require.ErrorContains( t, - generation.RequireImport(NewImport("jason", "encoding/json")), + pkg.RequireImport(NewImport("jason", "encoding/json")), "requires qualifier", ) } @@ -108,7 +116,30 @@ func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { // packages cannot both require the same qualifier. func TestImportAliasRejectsFixedQualifierCollision(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/first"))) - require.NoError(t, generation.RequireImport(NewImport("runtime", "example.com/second"))) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/first"))) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/second"))) require.ErrorContains(t, generation.Freeze(), "required by both") } + +// TestImportAliasesAreIndependentAcrossOutputPackages verifies that packages +// which never compile together may use the same natural import name. +func TestImportAliasesAreIndependentAcrossOutputPackages(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/cli/calc") + require.NoError(t, err) + grpcPackage, err := generation.ClaimPackage("generated.local/gen/grpc/cli/calc") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/http/calc/client", + ))) + require.NoError(t, grpcPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/grpc/calc/client", + ))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "calcc", httpPackage.ImportName("generated.local/gen/http/calc/client")) + require.Equal(t, "calcc", grpcPackage.ImportName("generated.local/gen/grpc/calc/client")) +} diff --git a/codegen/internal/pluginregistry/registry.go b/codegen/internal/pluginregistry/registry.go new file mode 100644 index 0000000000..6420c4cb34 --- /dev/null +++ b/codegen/internal/pluginregistry/registry.go @@ -0,0 +1,113 @@ +// Package pluginregistry stores callbacks registered through Goa's released +// plugin API until generation starts. Plugin authors may register callbacks, +// but only the generator may stop registration or read the stored list. +package pluginregistry + +import ( + "fmt" + "slices" + "sync" +) + +type ( + // Position identifies when a plugin runs relative to normal plugins. + Position uint8 + + // Registry stores callbacks until the first generation run copies them. + // Callback types stay paired with the package that defines them. + Registry struct { + mu sync.Mutex + registrations []storedRegistration + sealed bool + } + + // Registration is one plugin definition with its original callback types. + Registration[Prepare, Generate any] struct { + Name string + Command string + Position Position + Prepare Prepare + Generate Generate + } + + // storedRegistration keeps callbacks without importing their defining + // package. Snapshot restores the types supplied by that same package. + storedRegistration struct { + name string + command string + position Position + prepare any + generate any + } +) + +const ( + // First places a plugin before normally ordered plugins. + First Position = iota + // Normal places a plugin between first and last plugins. + Normal + // Last places a plugin after normally ordered plugins. + Last +) + +var defaultRegistry = New() + +// New creates an open plugin registry for Goa or a focused test. +func New() *Registry { + return &Registry{} +} + +// Register records one plugin in the process-wide registry used by Goa. +func Register[Prepare, Generate any](name, command string, position Position, prepare Prepare, generate Generate) { + RegisterIn(defaultRegistry, name, command, position, prepare, generate) +} + +// RegisterIn records one plugin in registry before generation starts. +func RegisterIn[Prepare, Generate any](registry *Registry, name, command string, position Position, prepare Prepare, generate Generate) { + registry.mu.Lock() + defer registry.mu.Unlock() + if registry.sealed { + panic("plugin registry is sealed") + } + registry.registrations = append(registry.registrations, storedRegistration{ + name: name, + command: command, + position: position, + prepare: prepare, + generate: generate, + }) +} + +// Snapshot stops further process-wide registrations and returns a copy of the +// registered plugins with their original callback types. +func Snapshot[Prepare, Generate any]() []Registration[Prepare, Generate] { + return SnapshotFrom[Prepare, Generate](defaultRegistry) +} + +// SnapshotFrom stops further registrations in registry and returns a copy that +// callers may sort without changing the stored order. +func SnapshotFrom[Prepare, Generate any](registry *Registry) []Registration[Prepare, Generate] { + registry.mu.Lock() + defer registry.mu.Unlock() + registry.sealed = true + stored := slices.Clone(registry.registrations) + registrations := make([]Registration[Prepare, Generate], len(stored)) + for index, plugin := range stored { + prepare, ok := plugin.prepare.(Prepare) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected prepare callback type", plugin.name)) + } + generate, ok := plugin.generate.(Generate) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected generate callback type", plugin.name)) + } + registrations[index] = Registration[Prepare, Generate]{ + Name: plugin.name, + Command: plugin.command, + Position: plugin.position, + Prepare: prepare, + Generate: generate, + } + } + return registrations +} diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go index 7eaeddaad9..77dd8be0b5 100644 --- a/codegen/name_declaration.go +++ b/codegen/name_declaration.go @@ -1,6 +1,6 @@ -// This file defines the canonical package-level Go name shared by declaration -// planning and rendering. Generated packages allocate exact names before -// compiler-preferred names and freeze each record before source rendering. +// This file records package-level Go names before source is written. Names that +// must remain exact are assigned first, then generated names receive numeric +// suffixes when needed. After that, the names cannot change. package codegen import ( @@ -11,29 +11,30 @@ import ( ) type ( - // PackageNameKind identifies the Go declaration category for diagnostics. - // Types, functions, constants, and variables still share one package namespace. + // PackageNameKind states whether a package-level name declares a type, + // function, constant, or variable. All four kinds must have different names + // within one Go package. PackageNameKind uint8 - // PackageNameVisibility specifies whether a preferred generated declaration - // is visible outside its Go package. + // PackageNameVisibility states whether generated code outside the package can + // use a preferred name. PackageNameVisibility uint8 - // PackageNameOrder supplies a deterministic total order for preferred names - // in one subsystem-owned declaration family. Implementations must be named, - // non-pointer value types whose fields recursively contain immutable values. - // The package catalog compares only values of the same concrete type. + // PackageNameOrder sorts generated declarations that request the same name. + // Implementations must be named, non-pointer values containing only values + // that cannot change. Values of different concrete types are sorted by their + // package and type names before this method is called. PackageNameOrder interface { - // ComparePackageName compares two values of the same concrete type. It - // must return a negative value when the receiver sorts first, zero only - // when both values contain identical stable ordering facts, and a positive - // value when the receiver sorts last. Its sign must be antisymmetric, and - // its less-than relation must be transitive. + // ComparePackageName compares two values of the same concrete type. It must + // return a negative value when the receiver comes first, zero when the values + // are equal, and a positive value when the receiver comes last. Reversing the + // arguments must reverse the sign, and comparison must sort consistently. ComparePackageName(PackageNameOrder) int } - // NameDeclaration records one package-level Go identifier. Its final name is - // unavailable until the owning generation freezes. + // NameDeclaration records one package-level Go name. Name cannot be read + // until Generation.Freeze chooses its final spelling among all declarations + // in the package. NameDeclaration struct { kind PackageNameKind visibility PackageNameVisibility @@ -51,25 +52,25 @@ type ( ) const ( - // NameType identifies a package-level type declaration. + // NameType marks a package-level type name. NameType PackageNameKind = iota + 1 - // NameFunction identifies a package-level function declaration. + // NameFunction marks a package-level function name. NameFunction - // NameConstant identifies a package-level constant declaration. + // NameConstant marks a package-level constant name. NameConstant - // NameVariable identifies a package-level variable declaration. + // NameVariable marks a package-level variable name. NameVariable ) const ( - // ExportedName makes the preferred generated identifier package-visible. + // ExportedName requests a name that code outside the package can use. ExportedName PackageNameVisibility = iota + 1 - // UnexportedName keeps the preferred generated identifier package-private. + // UnexportedName requests a name that only code in the package can use. UnexportedName ) -// NewExactName creates a declaration whose valid Go name must not change. The -// owning generated package rejects invalid names and collisions. +// NewExactName records a package-level Go name that must not change. Adding the +// declaration to a generated package fails if name is invalid or already used. func NewExactName(kind PackageNameKind, name string) *NameDeclaration { return &NameDeclaration{ kind: kind, @@ -78,10 +79,10 @@ func NewExactName(kind PackageNameKind, name string) *NameDeclaration { } } -// NewPreferredName creates a compiler-owned declaration whose preferred Go -// identifier may receive a deterministic numeric suffix. order must be a -// named, non-pointer value whose fields recursively contain immutable values; -// the owning package validates that constraint when it accepts the record. +// NewPreferredName records a generated name that may receive a numeric suffix +// when another declaration requests the same spelling. order decides which +// declaration keeps the unsuffixed name and must be a named, non-pointer value +// containing only values that cannot change. func NewPreferredName(kind PackageNameKind, preferred string, visibility PackageNameVisibility, order PackageNameOrder) *NameDeclaration { return &NameDeclaration{ kind: kind, @@ -91,8 +92,8 @@ func NewPreferredName(kind PackageNameKind, preferred string, visibility Package } } -// Name returns the frozen Go identifier. It panics before the owning -// generation freezes because no renderer may observe a provisional spelling. +// Name returns the final Go name. It panics until Generation.Freeze chooses all +// declaration names because another declaration may still change this one. func (d *NameDeclaration) Name() string { if !d.frozen { panic(fmt.Sprintf("package name %q requested before generation freeze", d.preferredName())) @@ -100,12 +101,13 @@ func (d *NameDeclaration) Name() string { return d.final } -// Kind returns the declaration category used for collision diagnostics. +// Kind returns whether this name declares a type, function, constant, or +// variable. func (d *NameDeclaration) Kind() PackageNameKind { return d.kind } -// String returns the declaration category used in planning errors. +// String returns the declaration kind used in error messages. func (k PackageNameKind) String() string { switch k { case NameType: @@ -121,8 +123,8 @@ func (k PackageNameKind) String() string { } } -// newDependentName creates a compiler-owned name whose preferred spelling is -// derived from another canonical declaration after that declaration freezes. +// newDependentName records a name formed by adding prefix and suffix to base's +// final name. func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) *NameDeclaration { if base == nil { panic("dependent package name requires a base declaration") @@ -136,8 +138,9 @@ func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffi } } -// comparePackageNames orders independent records without consulting discovery -// order. Equal ordering facts for distinct records are a planning error. +// comparePackageNames sorts declarations by their order value rather than the +// order in which generators added them. Two distinct declarations must not +// compare equal. func comparePackageNames(left, right *NameDeclaration) int { leftType := reflect.TypeOf(left.order) rightType := reflect.TypeOf(right.order) @@ -150,8 +153,8 @@ func comparePackageNames(left, right *NameDeclaration) int { return left.order.ComparePackageName(right.order) } -// validateNameDeclaration rejects records that cannot identify a package-level -// Go declaration before the owning package changes its declaration catalog. +// validateNameDeclaration checks that declaration has a valid kind, visibility, +// and requested Go name before a generated package records it. func validateNameDeclaration(declaration *NameDeclaration) error { if !declaration.kind.valid() { return fmt.Errorf("invalid package name kind %d", declaration.kind) @@ -168,14 +171,13 @@ func validateNameDeclaration(declaration *NameDeclaration) error { return nil } -// valid reports whether visibility is represented by the preferred-name catalog. +// valid reports whether v is one of the supported visibility values. func (v PackageNameVisibility) valid() bool { return v == ExportedName || v == UnexportedName } -// validatePackageNameOrder rejects ordering values whose identity or contents -// can change after collection. A named value type gives independent generators -// a stable family identity without coordinating through caller-chosen strings. +// validatePackageNameOrder checks that order is a named value whose contents +// cannot change after a generated package records it. func validatePackageNameOrder(order PackageNameOrder) error { if order == nil { return fmt.Errorf("package name order must be a stable concrete named value type") @@ -187,8 +189,8 @@ func validatePackageNameOrder(order PackageNameOrder) error { return nil } -// isStablePackageNameOrderType reports whether values of typeOf contain only -// immutable value fields suitable for deterministic comparison after freeze. +// isStablePackageNameOrderType reports whether typeOf contains only values that +// cannot change after they are copied. func isStablePackageNameOrderType(typeOf reflect.Type) bool { switch typeOf.Kind() { case reflect.Array: @@ -212,8 +214,8 @@ func isStablePackageNameOrderType(typeOf reflect.Type) bool { } } -// packagePath returns the generated import path that owns the declaration. -// Access before package collection is an internal planning bug. +// packagePath returns the import path of the package that declares this name. +// It panics if no generated package has recorded the declaration. func (d *NameDeclaration) packagePath() string { if d.owner == nil { panic(fmt.Sprintf("package name %q has no generated package owner", d.preferredName())) @@ -221,8 +223,8 @@ func (d *NameDeclaration) packagePath() string { return d.owner.path } -// preferredName returns the requested name, using the base declaration's -// frozen spelling for linked declaration families such as union constructors. +// preferredName returns the requested name. A dependent name uses base's final +// spelling once available, then adds its prefix and suffix. func (d *NameDeclaration) preferredName() string { if d.base == nil { return d.preferred @@ -234,7 +236,7 @@ func (d *NameDeclaration) preferredName() string { return d.prefix + base + d.suffix } -// valid reports whether the category is represented by this catalog. +// valid reports whether k is one of the supported declaration kinds. func (k PackageNameKind) valid() bool { switch k { case NameType, NameFunction, NameConstant, NameVariable: diff --git a/codegen/normalize.go b/codegen/normalize.go index 723c7e0612..b17aba6be2 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -1,7 +1,6 @@ -// This file performs the one allowed post-evaluation design mutation. It gives -// raw method object shapes stable semantic user-type wrappers and records the -// exact wrapper objects so later planning never infers compiler provenance from -// a user-controlled string. +// This file wraps unnamed method payload and result objects in user types +// after evaluation. It records each wrapper so later code finds the same +// generated type without trusting a user-provided string. package codegen import ( @@ -23,32 +22,40 @@ func normalizeRoots(roots []eval.Root) map[expr.UserType]MethodTypeIdentity { // normalizeRoot records every wrapper created for one design root. func normalizeRoot(root *expr.RootExpr, normalized map[expr.UserType]MethodTypeIdentity) { + apiName := "" + if root.API != nil { + apiName = root.API.Name + } for _, service := range root.Services { - normalizeService(service, normalized) + normalizeService(apiName, service, normalized) } } -// normalizeService creates semantic wrappers for one service without -// consulting or mutating any Go name scope. -func normalizeService(service *expr.ServiceExpr, normalized map[expr.UserType]MethodTypeIdentity) { +// normalizeService wraps each unnamed object payload and result in a generated +// user type without reading or changing generated Go names. +func normalizeService(apiName string, service *expr.ServiceExpr, normalized map[expr.UserType]MethodTypeIdentity) { for _, method := range service.Methods { normalizeMethodAttribute(method.Payload, newMethodTypeIdentity( + apiName, method.Name, methodPayloadTypeKind, expr.MethodPayloadExampleIdentity(method), ), normalized) normalizeMethodAttribute(method.StreamingPayload, newMethodTypeIdentity( + apiName, method.Name, methodStreamingPayloadTypeKind, expr.MethodStreamingPayloadExampleIdentity(method), ), normalized) normalizeMethodAttribute(method.Result, newMethodTypeIdentity( + apiName, method.Name, methodResultTypeKind, expr.MethodResultExampleIdentity(method), ), normalized) if method.HasMixedResults() { normalizeMethodAttribute(method.StreamingResult, newMethodTypeIdentity( + apiName, method.Name, methodStreamingResultTypeKind, expr.MethodStreamingResultExampleIdentity(method), @@ -57,8 +64,8 @@ func normalizeService(service *expr.ServiceExpr, normalized map[expr.UserType]Me } } -// normalizeMethodAttribute records typed provenance only for the wrapper it -// creates. Existing named and non-object method types remain authored values. +// normalizeMethodAttribute records which generated wrapper was created for an +// unnamed object. Existing named and non-object method types remain unchanged. func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity, normalized map[expr.UserType]MethodTypeIdentity) { if attribute == nil { return diff --git a/codegen/plugin.go b/codegen/plugin.go new file mode 100644 index 0000000000..9e20fe3e34 --- /dev/null +++ b/codegen/plugin.go @@ -0,0 +1,74 @@ +// This file stores plugins registered through the released Goa v3 API. At the +// start of each generation command, the generator copies the registered +// functions. It calls First plugins before normal plugins and Last plugins +// afterward, orders names within each group, and keeps registration order when +// plugins in one group have the same name. This package stores those functions +// but does not call them. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) + +type ( + // GenerateFunc may add, remove, or change the files produced by Goa and by + // plugins that ran earlier. It returns the complete file list for the next + // plugin. + GenerateFunc func(genpkg string, roots []eval.Root, files []*File) ([]*File, error) + + // PrepareFunc may change evaluated designs before Goa chooses generated Go + // names. A nil PrepareFunc means that the plugin does not prepare designs. + PrepareFunc func(genpkg string, roots []eval.Root) error + + // pluginPosition identifies the three ordering groups supported by the + // released registration API. + pluginPosition = pluginregistry.Position +) + +const ( + pluginFirst = pluginregistry.First + pluginNormal = pluginregistry.Normal + pluginLast = pluginregistry.Last +) + +// RegisterPlugin adds a plugin to the normal alphabetically ordered group. It +// panics for an empty name, an unknown command, a nil generation function, or +// registration after generation has started. Repeated names remain allowed for +// compatibility with released Goa plugins. +func RegisterPlugin(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginNormal, prepare, generate) +} + +// RegisterPluginFirst adds a plugin before normal and last plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginFirst(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginFirst, prepare, generate) +} + +// RegisterPluginLast adds a plugin after first and normal plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginLast(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginLast, prepare, generate) +} + +// register validates and records one plugin definition before generation. +func registerPlugin(name, command string, position pluginPosition, prepare PrepareFunc, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.Register(name, command, position, prepare, generate) +} + +// validatePlugin rejects definitions that the generator cannot execute. +func validatePlugin(name, command string, generate GenerateFunc) { + if name == "" { + panic("plugin name is empty") + } + if command != "gen" && command != "example" { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + if generate == nil { + panic("plugin generate function is nil") + } +} diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go new file mode 100644 index 0000000000..ab264eba34 --- /dev/null +++ b/codegen/plugin_test.go @@ -0,0 +1,102 @@ +// This file verifies the released plugin registration calls without running a +// second generation pipeline. The generator package consumes the copied +// registrations tested here. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) + +// TestReleasedPluginRegistrationSignatures verifies that existing plugin +// packages still compile against the four-argument Goa v3 API. +func TestReleasedPluginRegistrationSignatures(t *testing.T) { + var register func(string, string, PrepareFunc, GenerateFunc) + + register = RegisterPlugin + require.NotNil(t, register) + register = RegisterPluginFirst + require.NotNil(t, register) + register = RegisterPluginLast + require.NotNil(t, register) +} + +// TestPluginRegistryRejectsInvalidRegistration verifies that invalid plugin +// definitions fail when they are registered, before generation can start. +func TestPluginRegistryRejectsInvalidRegistration(t *testing.T) { + tests := []struct { + name string + plugin string + command string + generate GenerateFunc + error string + }{ + {name: "empty name", command: "gen", generate: unchangedFiles, error: "plugin name is empty"}, + {name: "unknown command", plugin: "plugin", command: "other", generate: unchangedFiles, error: `unknown generator command "other"`}, + {name: "missing generate", plugin: "plugin", command: "gen", error: "plugin generate function is nil"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := pluginregistry.New() + require.PanicsWithValue(t, test.error, func() { + registerPluginIn(registry, test.plugin, test.command, pluginNormal, nil, test.generate) + }) + }) + } +} + +// TestPluginRegistryKeepsDuplicateRegistrationOrder verifies that the released +// API keeps every callback when packages reuse the same command and name. +func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginFirst, nil, unchangedFiles) + registerPluginIn(registry, "plugin", "gen", pluginLast, nil, changedFiles) + + registrations := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + require.Len(t, registrations, 2) + require.Equal(t, pluginFirst, registrations[0].Position) + require.Equal(t, pluginLast, registrations[1].Position) + files, err := registrations[1].Generate("generated.local/gen", nil, nil) + require.NoError(t, err) + require.Equal(t, "changed", files[0].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + registerPluginIn(registry, "late", "gen", pluginNormal, nil, unchangedFiles) + }) +} + +// TestPluginRegistrySnapshotIsCopied verifies that a caller cannot change the +// registrations retained for later generation runs. +func TestPluginRegistrySnapshotIsCopied(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginNormal, nil, unchangedFiles) + + first := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + first[0].Name = "changed" + second := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + + require.Equal(t, "plugin", second[0].Name) + require.Equal(t, "gen", second[0].Command) + require.Equal(t, pluginNormal, second[0].Position) +} + +// registerPluginIn applies the public registration checks to an isolated +// registry so the test does not stop later process-wide registrations. +func registerPluginIn(registry *pluginregistry.Registry, name, command string, position pluginPosition, prepare PrepareFunc, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.RegisterIn(registry, name, command, position, prepare, generate) +} + +// unchangedFiles provides a valid generation callback for registration tests. +func unchangedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return files, nil +} + +// changedFiles gives duplicate registration tests a distinct callback. +func changedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return append(files, &File{Path: "changed"}), nil +} diff --git a/codegen/protobuf.go b/codegen/protobuf.go new file mode 100644 index 0000000000..071d49e07e --- /dev/null +++ b/codegen/protobuf.go @@ -0,0 +1,73 @@ +// This file converts authored names into identifiers that Goa can safely write +// to protobuf files. Transport generators and external plugins use the same +// functions so identical design names produce identical protobuf names. +package codegen + +import ( + "regexp" + "strings" +) + +var ( + protobufDigits = regexp.MustCompile("[0-9]+") + + protobufKeywords = map[string]struct{}{ + "bool": {}, "bytes": {}, "double": {}, "fixed32": {}, "fixed64": {}, + "float": {}, "int32": {}, "int64": {}, "sfixed32": {}, "sfixed64": {}, + "sint32": {}, "sint64": {}, "string": {}, "uint32": {}, "uint64": {}, + "enum": {}, "import": {}, "map": {}, "message": {}, "oneof": {}, + "option": {}, "package": {}, "public": {}, "repeated": {}, "reserved": {}, + "returns": {}, "rpc": {}, "service": {}, "syntax": {}, + } +) + +// ProtobufName returns the identifier written for a protobuf message, service, +// or method. It keeps common acronyms uppercase and makes the first character +// legal for protobuf source. +func ProtobufName(name string) string { + return protobufIdentifier(name, true, true) +} + +// ProtobufFieldName returns the snake-case identifier written for a protobuf +// field or oneof. It makes the first character legal for protobuf source. +func ProtobufFieldName(name string) string { + name = SnakeCase(protobufIdentifier(name, false, false)) + if _, reserved := protobufKeywords[name]; reserved { + name += "_" + } + return name +} + +// protobufIdentifier removes characters protobuf identifiers cannot contain +// and separates digits so the generated Go name matches protoc-gen-go. +func protobufIdentifier(name string, firstUpper, acronym bool) string { + if index := strings.Index(name, ":"); index > 0 { + name = name[:index] + } + name = strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || + character == '_' { + return character + } + return '_' + }, name) + name = string(protobufDigits.ReplaceAllFunc([]byte(name), func(match []byte) []byte { + result := make([]byte, len(match)+1) + copy(result, match) + result[len(result)-1] = '_' + return result + })) + name = CamelCase(name, firstUpper, acronym) + if name == "" { + if firstUpper { + return "Val" + } + return "val" + } + if name[0] >= '0' && name[0] <= '9' { + name = "_" + name + } + return name +} diff --git a/codegen/scope.go b/codegen/scope.go index 2c361547c8..4578db72e7 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -1,7 +1,7 @@ -// Code generators use this file to turn caller-supplied type identities and -// attributes into unique Go names and type references. Hashed names use exactly -// the caller's Hash value; after Freeze, existing names remain readable but no -// new name may be reserved. +// Code generators use this file to turn caller-supplied type lookup keys and +// attributes into unique Go names and type references. Hashed names use the +// caller's exact Hash value. After Freeze, callers can read existing names but +// cannot reserve new ones. package codegen import ( @@ -18,7 +18,7 @@ type ( NameScope struct { names map[string]string // type hash to unique name counts map[string]int // raw type name to occurrence count - frozen bool // whether new names may be reserved + frozen bool // true after this set rejects new names } // Hasher is the interface implemented by the objects that must be @@ -43,9 +43,9 @@ func NewNameScope() *NameScope { } } -// Fork returns a mutable naming scope containing every name and hashed binding -// already recorded in s. Generators use it for private helpers that must avoid -// declarations owned by a frozen generated package. +// Fork returns a new scope containing every lookup key and name already +// recorded in s. The new scope can add private helper names without changing s +// or colliding with names already chosen there. func (s *NameScope) Fork() *NameScope { fork := NewNameScope() for hash, name := range s.names { @@ -92,8 +92,9 @@ func (s *NameScope) Freeze() { s.frozen = true } -// bind associates an already reserved name with one hash without allocating a -// second package identifier. Generated packages call it only during freeze. +// bind makes key return an already reserved Go name without reserving another +// name. Generated packages call it while Generation.Freeze assigns final +// declaration names. func (s *NameScope) bind(key Hasher, name string) { if s.frozen { panic("cannot bind a hashed name in a frozen name scope") @@ -321,7 +322,7 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { case *expr.Object: return s.GoTypeDef(att, false, false) case expr.UserType: - if actual == expr.ErrorResult { + if expr.IsErrorResult(actual) { return "goa.ServiceError" } // Qualified type references (pkg.Type) do not compete in the local @@ -348,7 +349,8 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { } // scopedTypeName returns a local or package-qualified generated declaration -// name. The caller supplies the exact identity owned by the target package. +// name. key must be the lookup key recorded by the package containing that +// declaration. func (s *NameScope) scopedTypeName(key Hasher, base, pkg string) string { if pkg == "" { return s.HashedUnique(key, base, "") diff --git a/codegen/sections_test.go b/codegen/sections_test.go index b810de4576..c3f142b042 100644 --- a/codegen/sections_test.go +++ b/codegen/sections_test.go @@ -122,3 +122,45 @@ package testpackage }) } } + +func TestHeaderKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{ + {Path: "encoding/json"}, + {Name: "json", Path: "encoding/json"}, + }) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } +} + +func TestAddImportKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Path: "encoding/json"}}) + AddImport(section, &ImportSpec{Name: "json", Path: "encoding/json"}) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } + + t.Run("different explicit names", func(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Name: "first", Path: "example.com/log"}}) + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("AddImport did not reject different explicit names for one package") + } + }() + AddImport(section, &ImportSpec{Name: "second", Path: "example.com/log"}) + }) +} diff --git a/codegen/service/client.go b/codegen/service/client.go index fc1ae37ce2..6a1be96650 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -1,5 +1,5 @@ -// This file renders one service's in-process client and keeps its type imports -// scoped to that generated client file. +// This file renders one service's in-process client and includes only the type +// imports used by that generated client file. package service import ( @@ -8,7 +8,7 @@ import ( "goa.design/goa/v3/codegen" ) -// clientFile renders the client for the exact service retained by plan. +// clientFile renders the client from the service data copied into plan. func clientFile(plan *Plan, facts *serviceFacts) *codegen.File { services := plan.Services() svc := services.Get(facts.name) diff --git a/codegen/service/codegen_specialization_test.go b/codegen/service/codegen_specialization_test.go new file mode 100644 index 0000000000..0cf86c52a5 --- /dev/null +++ b/codegen/service/codegen_specialization_test.go @@ -0,0 +1,277 @@ +// This file verifies that service generation omits runtime work whose answer +// is fixed by the evaluated design. +package service + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestInterceptorAccessorsDoNotRediscoverPlannedMethods catches generated +// accessors that switch on the method name or the planned payload wrapper. +func TestInterceptorAccessorsDoNotRediscoverPlannedMethods(t *testing.T) { + root := codegen.RunDSL(t, interceptorSpecializationDSL) + plan := retainedServicePlanForPackage(t, root) + files := interceptorsFiles(plan, plan.facts.services[0]) + + var rendered strings.Builder + for _, file := range files { + var source bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + parsed, err := parser.ParseFile(token.NewFileSet(), file.Path, source.Bytes(), 0) + require.NoError(t, err, source.String()) + ast.Inspect(parsed, func(node ast.Node) bool { + switch node.(type) { + case *ast.SwitchStmt, *ast.TypeSwitchStmt: + t.Errorf("%s contains a runtime switch for a planned interceptor fact", file.Path) + } + return true + }) + rendered.Write(source.Bytes()) + } + + code := rendered.String() + require.Contains(t, code, "InspectInfo interface") + require.NotContains(t, code, "method string") + require.NotContains(t, code, "callType goa.InterceptorCallType") + + generated, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFilesWith(t, generated, map[string]string{ + "gen/interceptor_specialization/info_specialization_test.go": interceptorInfoRuntimeTest, + }) +} + +// TestSharedInterceptorSpecializesDifferentClientAndServerMethods catches a +// client method implementation lost when the same interceptor is also used by +// a different server method. +func TestSharedInterceptorSpecializesDifferentClientAndServerMethods(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("value") + }) + }) + dsl.Service("SplitInterceptorMethods", func() { + dsl.Method("ServerOnly", func() { + dsl.ServerInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + dsl.Method("ClientOnly", func() { + dsl.ClientInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyProjectedValidatorsAreOmitted catches public validation functions +// and parent calls that cannot report an error for any value. +func TestEmptyProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + empty := dsl.ResultType("application/vnd.empty", func() { + dsl.TypeName("Empty") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("EmptyViews", func() { + dsl.Method("Read", func() { + dsl.Result(empty) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + require.Len(t, data.viewedResultTypes, 1) + require.Len(t, data.viewedResultTypes[0].Validate.Calls, 1) + require.Nil(t, data.viewedResultTypes[0].Validate.Calls[0].Declaration) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestRequiredParentOmitsEmptyChildCall catches removal of the parent's +// missing-field check when its selected child view has no other rules. +func TestRequiredParentOmitsEmptyChildCall(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.empty-child", func() { + dsl.TypeName("EmptyChild") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + parent := dsl.ResultType("application/vnd.required-parent", func() { + dsl.TypeName("RequiredParent") + dsl.Attribute("child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("RequiredParentViews", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("RequiredParentViews") + + var parentValidation *ValidateData + for _, projected := range data.projectedTypes { + switch projected.Name { + case "EmptyChildView": + require.Empty(t, projected.Validations) + case "RequiredParentView": + require.Len(t, projected.Validations, 1) + parentValidation = projected.Validations[0] + } + } + require.NotNil(t, parentValidation) + require.Empty(t, parentValidation.Calls) + require.Contains(t, parentValidation.Validate, `MissingFieldError("child", "result")`) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyRecursiveProjectedValidatorsAreOmitted catches cycles that retain +// validators even though no node in the cycle can report an error. +func TestEmptyRecursiveProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + tree := dsl.ResultType("application/vnd.empty-tree", func() { + dsl.TypeName("EmptyTree") + dsl.Attribute("next", "EmptyTree") + dsl.View("default", func() { + dsl.Attribute("next") + }) + }) + dsl.Service("EmptyRecursiveViews", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyRecursiveViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// interceptorSpecializationDSL applies one interceptor to multiple methods so +// generated accessors must select exact method and streaming types in advance. +func interceptorSpecializationDSL() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("initial") + }) + dsl.ReadStreamingPayload(func() { + dsl.Attribute("input") + }) + dsl.ReadStreamingResult(func() { + dsl.Attribute("output") + }) + }) + dsl.Service("InterceptorSpecialization", func() { + dsl.ServerInterceptor("inspect") + dsl.ClientInterceptor("inspect") + for _, name := range []string{"First", "Second"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Field(1, "initial", dsl.String) + }) + dsl.StreamingPayload(func() { + dsl.Field(1, "input", dsl.String) + }) + dsl.StreamingResult(func() { + dsl.Field(1, "output", dsl.String) + }) + dsl.GRPC(func() {}) + }) + } + }) +} + +const interceptorInfoRuntimeTest = `package interceptorspecialization + +import ( + "testing" + + goa "goa.design/goa/v3/pkg" +) + +func TestSpecializedInterceptorInfo(t *testing.T) { + initial := "start" + input := "in" + output := "out" + payload := &FirstPayload{Initial: &initial} + streamingPayload := &FirstStreamingPayload{Input: &input} + streamingResult := &FirstResult{Output: &output} + + server := &inspectFirstServerUnaryInfo{inspectFirstInfo: &inspectFirstInfo{ + rawPayload: &FirstEndpointInput{Payload: payload}, + }} + if server.Service() != "InterceptorSpecialization" || server.Method() != "First" || server.CallType() != goa.InterceptorUnary { + t.Errorf("unexpected server metadata: %s %s %v", server.Service(), server.Method(), server.CallType()) + } + if actual := server.Payload().Initial(); actual != initial { + t.Errorf("server payload = %q, want %q", actual, initial) + } + + client := &inspectFirstClientUnaryInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: payload}} + if client.CallType() != goa.InterceptorUnary || client.Payload().Initial() != initial { + t.Errorf("unexpected client endpoint metadata") + } + + send := &inspectFirstStreamingSendInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: streamingResult}} + if send.CallType() != goa.InterceptorStreamingSend || send.ServerStreamingResult().Output() != output { + t.Errorf("unexpected server send metadata") + } + + recv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: &inspectFirstInfo{}} + if recv.CallType() != goa.InterceptorStreamingRecv || recv.ServerStreamingPayload(streamingPayload).Input() != input { + t.Errorf("unexpected server receive metadata") + } + + clientSend := &inspectFirstStreamingSendInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: streamingPayload}} + if clientSend.ClientStreamingPayload().Input() != input { + t.Errorf("unexpected client send metadata") + } + clientRecv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: &inspectFirstInfo{}} + if clientRecv.ClientStreamingResult(streamingResult).Output() != output { + t.Errorf("unexpected client receive metadata") + } +} +` diff --git a/codegen/service/conversion_plan.go b/codegen/service/conversion_plan.go index f5a69ba987..060bcf69aa 100644 --- a/codegen/service/conversion_plan.go +++ b/codegen/service/conversion_plan.go @@ -1,6 +1,5 @@ -// This file retains external Go type conversions before package names freeze. -// The root plan assigns each operation to its generated receiver package, -// declares recursive helpers, and records every reflected package import. +// This file records external Go type conversions before generated names are +// chosen. It stores each conversion, its recursive functions, and its imports. package service import ( @@ -15,12 +14,12 @@ import ( ) type ( - // externalConversionDirection identifies which side of a retained mapping - // owns the generated receiver method. + // externalConversionDirection identifies whether a generated method converts + // to or from a user-supplied Go type. externalConversionDirection uint8 - // externalConversionNameOrder gives every recursive helper a stable place - // in its generated package without depending on service traversal. + // externalConversionNameOrder orders child conversion helpers by their + // service, method, and field position instead of discovery order. externalConversionNameOrder struct { receiverID string externalPkg string @@ -32,8 +31,8 @@ type ( required bool } - // externalConversionFacts retains one exact reflected mapping and transform - // graph from collection through linked render data. + // externalConversionFacts stores one conversion between a Goa type and a + // user-supplied Go type, including conversions for nested fields. externalConversionFacts struct { direction externalConversionDirection serviceName string @@ -61,8 +60,9 @@ type ( imports retainedFileImports } - // externalConversionIdentity identifies one receiver method contract across - // every root in a service planning batch. + // externalConversionIdentity selects one generated method by its receiver, + // conversion direction, and user-supplied Go type across all designs in the + // generation command. externalConversionIdentity struct { receiver *codegen.TypeDeclaration direction externalConversionDirection @@ -70,8 +70,8 @@ type ( externalPath string } - // externalConversionResolver qualifies each reflected named type with the - // frozen alias for that type's own Go package. + // externalConversionResolver writes each user-supplied type with the import + // name chosen for the package that declares it. externalConversionResolver struct { scope *codegen.AttributeScope packages map[expr.UserType]string @@ -83,9 +83,9 @@ const ( externalCreateFrom ) -// collectExternalConversions plans every mapping across the complete service -// run once per exact generated receiver package. A relocated receiver shared -// by roots therefore receives one method namespace and one convert.go file. +// collectExternalConversions records every conversion once in the package that +// declares its receiver type. When several designs use the same receiver +// package, Goa writes one set of method names and one convert.go file. func collectExternalConversions(roots []*rootFacts, generation *codegen.Generation) error { files := make(map[*codegen.GeneratedPackage]*externalConversionFileFacts) fileRoots := make(map[*codegen.GeneratedPackage]*rootFacts) @@ -106,7 +106,7 @@ func collectExternalConversions(roots []*rootFacts, generation *codegen.Generati continue } owner := generation.Package(generatedPackagePath( - generation.GenPkg(), service.service, codegen.UserTypeLocation(mapping.User), + generation.GenPkg(), service.packagePath, codegen.UserTypeLocation(mapping.User), )) selected := owners[owner] if selected == nil || service.packagePath < selected.packagePath { @@ -180,8 +180,8 @@ func collectExternalConversions(roots []*rootFacts, generation *codegen.Generati return nil } -// identifyExternalConversion resolves the complete run-wide receiver method -// identity before planning can declare helpers or imports for the operation. +// identifyExternalConversion identifies the generated receiver method before +// Goa submits its helper names and imports. func identifyExternalConversion(mapping *expr.TypeMap, owner *codegen.GeneratedPackage, direction externalConversionDirection) (externalConversionIdentity, string, error) { externalType := reflect.TypeOf(mapping.External) if externalType == nil { @@ -203,8 +203,8 @@ func identifyExternalConversion(mapping *expr.TypeMap, owner *codegen.GeneratedP }, externalAlias, nil } -// rootFactsOrder returns the stable service identity that owns shared file -// contributions selected from a root. +// rootFactsOrder returns the API and service names used to order definitions +// shared by several designs. func rootFactsOrder(facts *rootFacts) string { paths := make([]string, len(facts.services)) for index, service := range facts.services { @@ -214,8 +214,8 @@ func rootFactsOrder(facts *rootFacts) string { return facts.apiName + "\x00" + strings.Join(paths, "\x00") } -// externalConversionFiles returns the package files assigned by batch -// planning and rejects duplicate ownership instead of merging after freeze. +// externalConversionFiles returns one convert.go description per receiver +// package and rejects two service plans that both try to write that file. func externalConversionFiles(plans []*Plan) ([]*externalConversionFileFacts, error) { byOwner := make(map[*codegen.GeneratedPackage]struct{}) var files []*externalConversionFileFacts @@ -237,8 +237,8 @@ func externalConversionFiles(plans []*Plan) ([]*externalConversionFileFacts, err return files, nil } -// planExternalConversion reflects one external shape, builds its immutable -// transform graph, and binds each recursive helper to the receiver package. +// planExternalConversion reads one user-supplied Go type, records the complete +// field conversion, and submits each child helper to the receiver's package. func planExternalConversion( service *serviceFacts, mapping *expr.TypeMap, @@ -258,7 +258,7 @@ func planExternalConversion( if err != nil { return nil, err } - if err := generation.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { + if err := owner.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { return nil, err } externalPackages[userType.Origin()] = importPath @@ -274,7 +274,7 @@ func planExternalConversion( if identity.direction == externalCreateFrom { source, target = externalAttribute, source } - transform, err := codegen.NewTransformPlan(source, target) + transform, err := codegen.NewTransformPlan(source, target, "", nil) if err != nil { return nil, err } @@ -327,8 +327,9 @@ func planExternalConversion( return operation, nil } -// finishExternalConversionFile fixes canonical operation order, assigns -// receiver-scoped method names, and plans the exact imports used by convert.go. +// finishExternalConversionFile sorts the generated methods, assigns child +// helper names within each receiver method, and records the imports used by +// convert.go. func finishExternalConversionFile(file *externalConversionFileFacts, generation *codegen.Generation) error { sort.Slice(file.operations, func(i, j int) bool { return externalConversionOperationLess(file.operations[i], file.operations[j]) @@ -374,8 +375,8 @@ func finishExternalConversionFile(file *externalConversionFileFacts, generation return nil } -// externalConversionOperationLess orders retained receiver operations by their -// complete semantic identity independently of root and service traversal. +// externalConversionOperationLess orders generated receiver methods by their +// source and target types, service, method, and direction. func externalConversionOperationLess(left, right *externalConversionFacts) bool { if left.receiverID != right.receiverID { return left.receiverID < right.receiverID @@ -389,8 +390,8 @@ func externalConversionOperationLess(left, right *externalConversionFacts) bool return left.externalType.Name() < right.externalType.Name() } -// linkExternalConversions binds frozen type resolvers and formats every -// retained operation without reflecting types or discovering helpers. +// linkExternalConversions adds the chosen import names and formats every +// previously recorded conversion without reading Go types or creating helpers. func linkExternalConversions( facts *rootFacts, generation *codegen.Generation, @@ -399,7 +400,7 @@ func linkExternalConversions( for _, file := range facts.externalConversions { linkFileImports(&file.imports, generation) for _, operation := range file.operations { - serviceResolver := newRetainedServiceResolver( + serviceResolver := newServiceResolver( generation, aliases, operation.serviceName, @@ -414,8 +415,8 @@ func linkExternalConversions( return nil } -// linkExternalConversion renders one retained graph with frozen service and -// reflected-package aliases selected for its output file. +// linkExternalConversion formats one recorded field conversion with the Go +// type and import names selected for its output file. func linkExternalConversion( operation *externalConversionFacts, serviceResolver *declarationResolver, @@ -425,6 +426,7 @@ func linkExternalConversion( operation.externalScope, operation.externalPackages, aliases, + serviceResolver.outputPath, ) externalContext := &codegen.AttributeContext{ Scope: externalResolver, @@ -462,16 +464,17 @@ func linkExternalConversion( return nil } -// newExternalConversionResolver binds reflected user types to aliases selected -// by the generation-wide import catalog. +// newExternalConversionResolver associates each user-supplied Go type with the +// import name selected for its package. func newExternalConversionResolver( scope *codegen.NameScope, packages map[expr.UserType]string, aliases *importAliases, + outputPackage string, ) *externalConversionResolver { resolved := make(map[expr.UserType]string, len(packages)) for userType, importPath := range packages { - resolved[userType.Origin()] = aliases.name(importPath) + resolved[userType.Origin()] = aliases.name(outputPackage, importPath) } return &externalConversionResolver{ scope: codegen.NewAttributeScope(scope), @@ -500,7 +503,7 @@ func (r *externalConversionResolver) Field(att *expr.AttributeExpr, name string, return r.scope.Field(att, name, firstUpper) } -// Package returns the frozen alias for an external named type. +// Package returns the Go name written before a user-supplied named type. func (r *externalConversionResolver) Package(att *expr.AttributeExpr) string { if userType, ok := att.Type.(expr.UserType); ok { return r.packageName(userType) @@ -508,8 +511,8 @@ func (r *externalConversionResolver) Package(att *expr.AttributeExpr) string { return "" } -// Enter keeps the resolver because each nested named type selects its package -// independently from the attribute currently being transformed. +// Enter returns the same resolver because each child named type already records +// the package that declares it. func (r *externalConversionResolver) Enter(*expr.AttributeExpr) codegen.Attributor { return r } @@ -519,17 +522,18 @@ func (r *externalConversionResolver) IsSumType() bool { return r.scope.IsSumType() } -// ValidatorName is not part of external conversion rendering. -func (*externalConversionResolver) ValidatorName(*expr.AttributeExpr, string) string { +// ValidatorCall is not part of external conversion rendering. +func (*externalConversionResolver) ValidatorCall(*expr.AttributeExpr, string, string, string) string { panic("external conversion resolver does not own validators") } -// Scope returns the lexical scope used for reflected field and local names. +// Scope returns the name set used to prevent generated field and local variable +// names from colliding. func (r *externalConversionResolver) Scope() *codegen.NameScope { return r.scope.Scope() } -// packageName returns the exact frozen alias for one reflected user type. +// packageName returns the Go import name chosen for one user-supplied type. func (r *externalConversionResolver) packageName(userType expr.UserType) string { name, ok := r.packages[userType.Origin()] if !ok { @@ -538,8 +542,8 @@ func (r *externalConversionResolver) packageName(userType expr.UserType) string return name } -// ComparePackageName orders external conversion helpers by complete semantic -// operation identity rather than discovery order. +// ComparePackageName orders conversion helpers by their source and target +// types, service, method, and direction instead of discovery order. func (o externalConversionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { right := other.(externalConversionNameOrder) required := 0 diff --git a/codegen/service/conversion_plan_contract_test.go b/codegen/service/conversion_plan_contract_test.go index b71c283c8c..19d4707e2e 100644 --- a/codegen/service/conversion_plan_contract_test.go +++ b/codegen/service/conversion_plan_contract_test.go @@ -38,13 +38,13 @@ func TestExternalConversionsBelongToGeneratedReceiverPackage(t *testing.T) { require.NotContains(t, conversion, "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha") require.Contains(t, conversion, "nestedalpha.Child") require.NotContains(t, conversion, "nestedalpha2.Child") - compileGeneratedServiceFiles(t, "generated.local", files) + compileGeneratedServiceFiles(t, files) } // TestExternalConversionPlanIgnoresLaterTypeMapMutation proves linked output // is byte-for-byte determined by facts retained in NewPlan. func TestExternalConversionPlanIgnoresLaterTypeMapMutation(t *testing.T) { - baseline := retainedServicePlanForPackage(t, externalConversionContractRoot(t), "generated.local/gen") + baseline := retainedServicePlanForPackage(t, externalConversionContractRoot(t)) baselineFiles, err := Files(baseline) require.NoError(t, err) conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") @@ -74,12 +74,12 @@ func TestExternalConversionPlanIgnoresLaterTypeMapMutation(t *testing.T) { root.Creations = nil require.NoError(t, generation.Freeze()) require.NoError(t, plan.Link()) - require.Equal(t, "generated.local/gen/alpha", plan.Services().ServiceImport(originalServiceName).Path) + require.Equal(t, "generated.local/gen/alpha", plan.Services().ServiceImport("generated.local", originalServiceName).Path) afterFiles, err := Files(plan) require.NoError(t, err) after := renderSingleFileAtPath(t, afterFiles, conversionPath) require.Equal(t, before, after) - compileGeneratedServiceFiles(t, "generated.local", afterFiles) + compileGeneratedServiceFiles(t, afterFiles) } // TestExternalConversionOperationsHaveCanonicalOrder catches convert.go output @@ -89,8 +89,8 @@ func TestExternalConversionOperationsHaveCanonicalOrder(t *testing.T) { reverse := externalConversionContractRoot(t) slices.Reverse(reverse.Conversions) slices.Reverse(reverse.Creations) - forwardPlan := retainedServicePlanForPackage(t, forward, "generated.local/gen") - reversePlan := retainedServicePlanForPackage(t, reverse, "generated.local/gen") + forwardPlan := retainedServicePlanForPackage(t, forward) + reversePlan := retainedServicePlanForPackage(t, reverse) forwardFiles, err := Files(forwardPlan) require.NoError(t, err) reverseFiles, err := Files(reversePlan) @@ -147,12 +147,12 @@ func TestExternalConversionReachabilityCoversEveryServiceValue(t *testing.T) { }) use(mapped) }) - plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + plan := retainedServicePlanForPackage(t, root) files, err := Files(plan) require.NoError(t, err) conversionPath := filepath.Join(codegen.Gendir, "reach", "convert.go") require.Len(t, filesAtPath(files, conversionPath), 1) - compileGeneratedServiceFiles(t, "generated.local", files) + compileGeneratedServiceFiles(t, files) }) } } @@ -174,7 +174,7 @@ func TestExternalConversionsAggregateAcrossRoots(t *testing.T) { require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) require.Contains(t, forward, "func (t *AlphaMapped) ConvertToChild()") require.Contains(t, forward, "func (t *BetaMapped) ConvertToChild()") - compileGeneratedServiceFiles(t, "generated.local", forwardFiles) + compileGeneratedServiceFiles(t, forwardFiles) } // TestExternalConversionsShareReceiverMethodNamesAcrossRoots catches method @@ -195,7 +195,7 @@ func TestExternalConversionsShareReceiverMethodNamesAcrossRoots(t *testing.T) { require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild()") require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild2()") - compileGeneratedServiceFiles(t, "generated.local", forwardFiles) + compileGeneratedServiceFiles(t, forwardFiles) } // TestNewPlansRejectDuplicateExternalConversionsAcrossRoots proves the batch diff --git a/codegen/service/convert.go b/codegen/service/convert.go index 80a3dc3a60..feb3d4bea2 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -1,6 +1,7 @@ // This file generates ConvertTo and CreateFrom functions for service types -// mapped to external Go structs. Service-side names come from the frozen -// package catalog, including nested types relocated by design metadata. +// mapped to external Go structs. Service-side names come from the completed +// package records, including nested types placed in packages by design +// metadata. package service import ( @@ -315,7 +316,7 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs oref = expr.AsObject(ref) } - // Retain only fields represented by the matching design object. External + // Keep only fields represented by the matching design object. External // structs may contain additional fields, but generated transforms neither // read nor write them and therefore must not reserve their package imports. var fields []reflect.StructField diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go index 8cb99d806a..8174d5e816 100644 --- a/codegen/service/declaration_resolver.go +++ b/codegen/service/declaration_resolver.go @@ -1,6 +1,6 @@ -// This file resolves service type definitions and references through the -// frozen generated-package catalog. It follows explicit type locations by -// import path and keeps unlocated nested declarations in their enclosing +// This file writes service type definitions and references using the Go names +// chosen for each generated package. A type with an explicit package location +// uses that import path; a child type without one stays in its enclosing type's // package. package service @@ -14,8 +14,8 @@ import ( ) type ( - // declarationResolver renders service-side attributes from the package - // records selected during Plan. + // declarationResolver writes service fields and type references from the + // package declarations recorded by Plan. declarationResolver struct { generation *codegen.Generation aliases *importAliases @@ -28,21 +28,9 @@ type ( } ) -// newServiceResolver resolves declarations starting in service's generated -// package and qualifies names relative to outputPath. -func newServiceResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, outputPath string) *declarationResolver { - return newRetainedServiceResolver( - generation, - aliases, - service.Name, - servicePackagePath(generation.GenPkg(), service), - outputPath, - ) -} - -// newRetainedServiceResolver starts from a service package identity copied -// during planning rather than reading the service expression after freeze. -func newRetainedServiceResolver(generation *codegen.Generation, aliases *importAliases, serviceName, servicePath, outputPath string) *declarationResolver { +// newServiceResolver starts in the assigned service package and qualifies type +// references for the package that will contain the generated file. +func newServiceResolver(generation *codegen.Generation, aliases *importAliases, serviceName, servicePath, outputPath string) *declarationResolver { return &declarationResolver{ generation: generation, aliases: aliases, @@ -52,17 +40,9 @@ func newRetainedServiceResolver(generation *codegen.Generation, aliases *importA } } -// newViewResolver resolves every declaration in service's views package. -// derived binds rebuilt projected expression origins to their typed catalog -// identities. -func newViewResolver(generation *codegen.Generation, aliases *importAliases, service *expr.ServiceExpr, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { - viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" - return newRetainedViewResolver(generation, aliases, service.Name, viewsPath, derived) -} - -// newRetainedViewResolver starts from the views package identity copied during -// planning and never derives it from a mutable service name. -func newRetainedViewResolver(generation *codegen.Generation, aliases *importAliases, serviceName, viewsPath string, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { +// newViewResolver starts in the assigned views package and associates each +// generated view type with the service result type from which it was built. +func newViewResolver(generation *codegen.Generation, aliases *importAliases, serviceName, viewsPath string, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { return &declarationResolver{ generation: generation, aliases: aliases, @@ -74,8 +54,9 @@ func newRetainedViewResolver(generation *codegen.Generation, aliases *importAlia } } -// Name returns the generated Go type name for att. Package ownership comes -// from the resolver's current import path, not from the textual pkg argument. +// Name returns the generated Go type name for att. The resolver's current +// import path chooses the package containing the declaration; the textual pkg +// argument does not. func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDefault bool) string { switch actual := att.Type.(type) { case expr.Primitive: @@ -87,7 +68,7 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe if !qualified { return custom } - return r.aliases.name(spec.Path) + "." + typeName + return r.aliases.name(r.outputPath, spec.Path) + "." + typeName } return codegen.GoNativeTypeName(actual) case *expr.Array: @@ -100,7 +81,7 @@ func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDe if actual == expr.Empty { return "struct {}" } - if actual == expr.ErrorResult { + if expr.IsErrorResult(actual) { return "goa.ServiceError" } owner := r.owner(att) @@ -198,7 +179,7 @@ func (r *declarationResolver) Package(att *expr.AttributeExpr) string { if owner == r.outputPath { return "" } - return r.aliases.name(owner) + return r.aliases.name(r.outputPath, owner) } // Enter returns a resolver whose current package owns att and its unlocated @@ -213,19 +194,8 @@ func (r *declarationResolver) Enter(att *expr.AttributeExpr) codegen.Attributor return &entered } -// inOutputPackage returns a resolver for a file emitted in packagePath. -func (r *declarationResolver) inOutputPackage(packagePath string) *declarationResolver { - if packagePath == r.currentPath && packagePath == r.outputPath { - return r - } - output := *r - output.currentPath = packagePath - output.outputPath = packagePath - return &output -} - -// withOutputPackage returns a resolver that keeps its current declaration -// owner but qualifies references for a file emitted in packagePath. +// withOutputPackage returns a resolver that keeps declarations in the current +// package but qualifies references for a file emitted in packagePath. func (r *declarationResolver) withOutputPackage(packagePath string) *declarationResolver { if packagePath == r.outputPath { return r @@ -247,28 +217,28 @@ func (r *declarationResolver) bindDerived(origin expr.UserType, identity codegen return &bound } -// withValidators returns a resolver that maps nested validation calls to the -// exact dependent declarations collected by the retained service plan. +// withValidators returns a resolver that maps each child validation call to the +// Go function declaration submitted during service planning. func (r *declarationResolver) withValidators(validators map[validatorKey]*codegen.NameDeclaration) *declarationResolver { bound := *r bound.validators = validators return &bound } -// IsSumType reports that service unions use Goa's generated sum-type structs. +// IsSumType reports that service unions use generated values that hold one branch. func (*declarationResolver) IsSumType() bool { return true } -// ValidatorName returns the exact package-level validator declared for att -// and view before generation names froze. -func (r *declarationResolver) ValidatorName(att *expr.AttributeExpr, view string) string { +// ValidatorCall returns a call to the validation function submitted for att +// and view before Generation.Freeze chose the function's Go name. +func (r *declarationResolver) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { declaration := r.validatorDeclaration(att, view) - return r.qualify(r.owner(att), declaration.Name()) + return fmt.Sprintf("%s(%s)", r.qualify(r.owner(att), declaration.Name()), target) } -// validatorDeclaration returns the exact retained validator record for att and -// the canonical selected view. +// validatorDeclaration returns the NameDeclaration recorded for att and the +// selected view. The default view uses the same empty key as its call sites. func (r *declarationResolver) validatorDeclaration(att *expr.AttributeExpr, view string) *codegen.NameDeclaration { userType, ok := att.Type.(expr.UserType) if !ok { @@ -286,13 +256,14 @@ func (r *declarationResolver) validatorDeclaration(att *expr.AttributeExpr, view return validator } -// Scope returns the frozen name scope owned by the resolver's current package. +// Scope returns the name set for the resolver's current generated package. func (r *declarationResolver) Scope() *codegen.NameScope { return r.generation.Package(r.currentPath).Scope() } -// owner returns the import path that owns att. View projections stay in the -// views package after their original struct:pkg:path metadata is removed. +// owner returns the import path of the package containing att. View-specific +// result copies stay in the views package after Goa removes their original +// struct:pkg:path metadata. func (r *declarationResolver) owner(att *expr.AttributeExpr) string { if r.view { return r.currentPath @@ -326,27 +297,11 @@ func (r *declarationResolver) qualify(owner, name string) string { if owner == r.outputPath { return name } - return r.aliases.name(owner) + "." + name -} - -// refDeclaration qualifies declaration for the resolver's output file while -// preserving the pointer or value semantics of dataType. -func (r *declarationResolver) refDeclaration(declaration *codegen.TypeDeclaration, dataType expr.DataType) string { - qualified := r.qualify(declaration.PackagePath(), declaration.Name()) - if strings.HasPrefix(declaration.Ref(dataType), "*") { - return "*" + qualified - } - return qualified -} - -// declarationName returns the unqualified planned name for one named type. -func (r *declarationResolver) declarationName(attribute *expr.AttributeExpr) string { - entered := r.Enter(attribute).(*declarationResolver) - return entered.userType(entered.currentPath, attribute.Type.(expr.UserType)).Name() + return r.aliases.name(r.outputPath, owner) + "." + name } -// serviceFieldIsPointer matches Goa service struct pointer semantics for one -// field definition. +// serviceFieldIsPointer applies Goa's service-struct pointer rules to one field +// definition. func serviceFieldIsPointer(parent *expr.AttributeExpr, name string, pointer, useDefault bool) bool { field := expr.AsObject(parent.Type).Attribute(name) return expr.IsObject(field.Type) || diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go index 121430766c..235e97f35e 100644 --- a/codegen/service/declaration_resolver_test.go +++ b/codegen/service/declaration_resolver_test.go @@ -12,6 +12,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -60,7 +61,8 @@ func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { resolver := newServiceResolver( generation, aliasesForTest(t, "generated.local/gen/types"), - service, + service.Name, + servicePackagePath(generation.GenPkg(), service), "generated.local/gen/types", ) relocatedContext := declarationContext(resolver.Enter(relocatedAttribute), false) @@ -131,12 +133,12 @@ func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType( "generated.local/gen/errors", "generated.local/gen/types", ), - service, + service.Name, + servicePackagePath(generation.GenPkg(), service), servicePackagePath(generation.GenPkg(), service), ) require.Equal(t, "Fault", localDeclaration.Name()) require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) - } // TestDeclarationResolverPanicsWhenPlanOmittedType verifies render analysis @@ -149,7 +151,8 @@ func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { resolver := newServiceResolver( generation, aliasesForTest(t, servicePackagePath(generation.GenPkg(), service)), - service, + service.Name, + servicePackagePath(generation.GenPkg(), service), servicePackagePath(generation.GenPkg(), service), ) missing := resolverUserType("Missing", expr.String) @@ -184,7 +187,16 @@ func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T }) }) }) - services := mustServicesData(t, root) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + consumer, err := generation.ClaimOutputPackage("example.com/consumer", "consumer") + require.NoError(t, err) + require.NoError(t, consumer.ReserveGeneratedImport(codegen.NewImport("types", "goa.design/goa/example/types"))) + require.NoError(t, consumer.DeclareImport(codegen.NewImport("custom", "example.com/custom"))) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() external := services.ServiceAttributor("Values", "example.com/consumer") recordAttribute := &expr.AttributeExpr{Type: record} recordResolver := external.Enter(recordAttribute) @@ -206,8 +218,16 @@ func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T func aliasesForTest(t *testing.T, paths ...string) *importAliases { t.Helper() generation := mustTestGeneration(t, "generated.local/gen", nil) - for _, importPath := range paths { - require.NoError(t, generation.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) + packages := make([]*codegen.GeneratedPackage, len(paths)) + for index, importPath := range paths { + packages[index] = mustClaimTestPackage(t, generation, importPath) + } + for _, pkg := range packages { + for _, importPath := range paths { + if importPath != pkg.ImportPath() { + require.NoError(t, pkg.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) + } + } } require.NoError(t, generation.Freeze()) return &importAliases{generation: generation} diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index aa5801c681..ed191643ec 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -28,6 +28,18 @@ type ( ServerInterceptorsDeclaration *codegen.NameDeclaration // ClientInterceptorsDeclaration is the exact client interceptor interface. ClientInterceptorsDeclaration *codegen.NameDeclaration + // VarName is the generated endpoint collection name kept for existing plugins. + // + // Deprecated: Use EndpointsDeclaration.Name() after planning. + VarName string + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // Name is the service name. Name string // Description is the service description. @@ -54,6 +66,14 @@ type ( ClientDeclaration *codegen.NameDeclaration // ServiceDeclaration is the exact service interface accepted by the endpoint constructor. ServiceDeclaration *codegen.NameDeclaration + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // ArgName is the name of the argument used to initialize the client // struct method field. ArgName string @@ -62,12 +82,12 @@ type ( // // It is only set when HasMixedResults is true. StreamArgName string - // ServiceName is the name of the owner service. + // ServiceName is the name of the service that declares this method. ServiceName string } ) -// endpointFile renders the endpoints for the exact service retained by plan. +// endpointFile renders endpoints from the service data copied into plan. func endpointFile(plan *Plan, facts *serviceFacts) *codegen.File { services := plan.Services() svc := services.Get(facts.name) @@ -87,18 +107,11 @@ func endpointFile(plan *Plan, facts *serviceFacts) *codegen.File { sections = []*codegen.SectionTemplate{header, def} for _, m := range data.Methods { if m.ServerStream != nil { - // Generate endpoint input struct for streaming methods - // For JSON-RPC WebSocket with StreamingResult: generate struct (needed for stream handle) - // For JSON-RPC WebSocket without StreamingResult (client streaming only): no struct needed - // For JSON-RPC SSE: always generate struct (methods have stream params) - // For HTTP/gRPC: always generate endpoint input struct - if !m.IsJSONRPCWebSocket || m.ServerStream.EndpointStruct != "" { - sections = append(sections, &codegen.SectionTemplate{ - Name: "endpoint-input-struct", - Source: serviceTemplates.Read(serviceEndpointStreamStructT), - Data: m, - }) - } + sections = append(sections, &codegen.SectionTemplate{ + Name: "endpoint-input-struct", + Source: serviceTemplates.Read(serviceEndpointStreamStructT), + Data: m, + }) } if m.SkipRequestBodyEncodeDecode { sections = append(sections, &codegen.SectionTemplate{ @@ -153,6 +166,8 @@ func endpointData(svc *Data) *EndpointsData { MethodData: m, ClientDeclaration: svc.ClientDeclaration, ServiceDeclaration: svc.ServiceDeclaration, + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), ArgName: argName, StreamArgName: streamArgName, ServiceName: svc.Name, @@ -167,6 +182,9 @@ func endpointData(svc *Data) *EndpointsData { ServiceDeclaration: svc.ServiceDeclaration, ServerInterceptorsDeclaration: svc.ServerInterceptorsDeclaration, ClientInterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + VarName: svc.EndpointsDeclaration.Name(), + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), Name: svc.Name, Description: desc, ClientInitArgs: strings.Join(names, ", "), @@ -179,11 +197,7 @@ func endpointData(svc *Data) *EndpointsData { func payloadVar(e *EndpointMethodData) string { if e.ServerStream != nil { - if e.ServerStream.EndpointStruct != "" { - return "ep.Payload" - } - // JSON-RPC WebSocket has no payload for server streaming - return "" + return "ep.Payload" } if e.SkipRequestBodyEncodeDecode { return "ep.Payload" diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index f609eaf847..c469e3b204 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -4,7 +4,6 @@ package service import ( "fmt" - "os" "path" "path/filepath" @@ -12,8 +11,8 @@ import ( ) type ( - // exampleInterceptorData contains the canonical declarations and service - // metadata rendered by one starter interceptor implementation. + // exampleInterceptorData contains the generated type and constructor names + // plus service metadata rendered by one starter interceptor implementation. exampleInterceptorData struct { // ServiceName is the design service name described by the comments. ServiceName string @@ -29,7 +28,7 @@ type ( ) // ExampleInterceptorsFiles returns starter server and client interceptor files -// for every service retained by plan. +// for every service copied into plan. func ExampleInterceptorsFiles(plan *Plan) []*codegen.File { var fw []*codegen.File for _, facts := range plan.facts.services { @@ -40,18 +39,21 @@ func ExampleInterceptorsFiles(plan *Plan) []*codegen.File { return fw } -// exampleInterceptorsFile renders starter interceptors from one retained -// service. +// exampleInterceptorsFile renders starter interceptors from one service copied +// into plan. func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { + if len(facts.serverInterceptors) == 0 && len(facts.clientInterceptors) == 0 { + return nil + } genpkg := plan.generation.GenPkg() services := plan.Services() sdata := services.Get(facts.name) servicePath := path.Join(genpkg, sdata.PathName) - servicePkg := services.aliases.name(servicePath) + servicePkg := services.aliases.name(path.Join(path.Dir(genpkg), "interceptors"), servicePath) var files []*codegen.File - // Generate server interceptor if needed and file doesn't exist + // Generate the server interceptor starter when the service uses one. if len(sdata.ServerInterceptors) > 0 { data := &exampleInterceptorData{ ServiceName: sdata.Name, @@ -61,22 +63,21 @@ func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { Interceptors: sdata.ServerInterceptors, } serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") - if _, err := os.Stat(serverPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: serverPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", facts.imports.exampleServerInterceptors.specs), - { - Name: "example-server-interceptor", - Source: serviceTemplates.Read(exampleServerInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: serverPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", facts.imports.exampleServerInterceptors.specs), + { + Name: "example-server-interceptor", + Source: serviceTemplates.Read(exampleServerInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } - // Generate client interceptor if needed and file doesn't exist + // Generate the client interceptor starter when the service uses one. if len(sdata.ClientInterceptors) > 0 { data := &exampleInterceptorData{ ServiceName: sdata.Name, @@ -86,19 +87,18 @@ func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { Interceptors: sdata.ClientInterceptors, } clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") - if _, err := os.Stat(clientPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: clientPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", facts.imports.exampleClientInterceptors.specs), - { - Name: "example-client-interceptor", - Source: serviceTemplates.Read(exampleClientInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: clientPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", facts.imports.exampleClientInterceptors.specs), + { + Name: "example-client-interceptor", + Source: serviceTemplates.Read(exampleClientInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } return files diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index 4a3d2ff2de..f534a5d0a6 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -126,6 +126,18 @@ func TestExampleInterceptorsFiles(t *testing.T) { } } +func TestServerInterceptorConstructorIsAvailableToExampleMain(t *testing.T) { + root := runDSL(t, testdata.ServerInterceptorExampleDSL) + plan := mustServicePlan(t, root) + facts := plan.facts.services[0] + + require.Same( + t, + facts.exampleServerConstructor, + plan.Services().Get(facts.name).ExampleServerInterceptorsConstructorDeclaration, + ) +} + // assertExampleInterceptorDeclarations verifies that starter definitions and // constructor bodies use the exact declarations retained by the service plan. func assertExampleInterceptorDeclarations(t *testing.T, plan *Plan, files []*codegen.File) { diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index 32789c48c1..eea87b0e42 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -3,10 +3,10 @@ package service import ( - "os" "path" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" ) type ( @@ -38,13 +38,14 @@ type ( // name from the qualifier used by this example file. exampleServiceData struct { *Data - // ServicePkg is the canonical generated service package qualifier. + // ServicePkg is the import name used for the generated service package in + // this example file. ServicePkg string } ) // ExampleServiceFiles returns a basic implementation for every service -// retained by plan. +// copied into plan. func ExampleServiceFiles(plan *Plan) []*codegen.File { var fw []*codegen.File for _, facts := range plan.facts.services { @@ -55,19 +56,17 @@ func ExampleServiceFiles(plan *Plan) []*codegen.File { return fw } -// exampleServiceFile renders a basic implementation from one retained service. +// exampleServiceFile renders a basic implementation from one service copied +// into plan. func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen.File { genpkg := plan.generation.GenPkg() services := plan.Services() data := services.Get(facts.name) svcName := data.PathName servicePath := path.Join(genpkg, svcName) - servicePkg := services.aliases.name(servicePath) + servicePkg := services.aliases.name(path.Dir(genpkg), servicePath) renderData := &exampleServiceData{Data: data, ServicePkg: servicePkg} fpath := svcName + ".go" - if _, err := os.Stat(fpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } sections := []*codegen.SectionTemplate{ codegen.Header("", apipkg, facts.imports.exampleService.specs), { @@ -92,15 +91,6 @@ func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen sections = append(sections, basicEndpointSection(method, data, outputPath, services.aliases, servicePkg)) } - // Add HandleStream method for JSON-RPC WebSocket services (not SSE) - if hasJSONRPCWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-handle-stream", - Source: serviceTemplates.Read(jsonrpcHandleStreamT), - Data: renderData, - }) - } - return &codegen.File{ Path: fpath, SectionTemplates: sections, @@ -109,7 +99,8 @@ func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen } // basicEndpointSection returns a starter implementation whose payload and -// result references come from the method's frozen generated-package records. +// result references come from the method records after all generated package +// and declaration names have been chosen. func basicEndpointSection(facts *methodFacts, svcData *Data, outputPath string, aliases *importAliases, servicePkg string) *codegen.SectionTemplate { md := svcData.Method(facts.name) ed := &basicEndpointData{ @@ -118,15 +109,18 @@ func basicEndpointSection(facts *methodFacts, svcData *Data, outputPath string, ExampleStructDeclaration: svcData.ExampleStructDeclaration, } if facts.payload != nil && facts.payload.layout.Kind() != codegen.GoEmpty { - ed.PayloadFullRef = facts.payload.layout.Link(outputPath, retainedTypeQualifier(aliases)).Ref() + ed.PayloadFullRef = facts.payload.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)).Ref() } if facts.result != nil && facts.result.layout.Kind() != codegen.GoEmpty { - linked := facts.result.layout.Link(outputPath, retainedTypeQualifier(aliases)) + linked := facts.result.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)) ed.ResultFullName = linked.Name() ed.ResultFullRef = linked.Ref() ed.ResultIsStruct = facts.result.isObject if md.ViewedResult != nil { ed.ResultView = facts.viewedResult.viewName + if ed.ResultView == "" { + ed.ResultView = expr.DefaultView + } } } if md.ServerStream != nil { diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 64329d7733..b52f13469e 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -11,6 +11,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" + "goa.design/goa/v3/codegen/testutil" ) func TestExampleServiceFiles(t *testing.T) { @@ -50,4 +51,24 @@ func TestExampleServiceFiles(t *testing.T) { }) } }) + + t.Run("mixed result methods", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + Golden string + }{ + {"result and stream", testdata.MixedResultsEndpointDSL, "testdata/golden/example_service-mixed-results.go.golden"}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL, "testdata/golden/example_service-mixed-results-with-views.go.golden"}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := mustServicePlan(t, root) + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + testutil.AssertGo(t, c.Golden, renderSections(t, files[0].SectionTemplates)) + }) + } + }) } diff --git a/codegen/service/generated_emission.go b/codegen/service/generated_emission.go index 845067c392..cb08564df8 100644 --- a/codegen/service/generated_emission.go +++ b/codegen/service/generated_emission.go @@ -1,15 +1,18 @@ -// This file attaches linked service render data to the exact relocated type -// and union emission records selected by batch planning before names freeze. +// This file attaches service template data to types and Goa OneOf unions that +// are written outside their service package. The generation command selects +// each output package and declaration before source is written. package service import ( "fmt" + "strings" "goa.design/goa/v3/codegen" ) -// generatedPackage returns the retained render data for one exact generated -// package, creating the render container without changing package ownership. +// generatedPackage returns the template data collected for one generated Go +// package. It creates an empty container when this is the package's first type +// or union. func (d *ServicesData) generatedPackage(importPath string) *generatedPackageData { owner := d.generation.Package(importPath) if generatedPackage, ok := d.packages[owner]; ok { @@ -23,8 +26,8 @@ func (d *ServicesData) generatedPackage(importPath string) *generatedPackageData return generatedPackage } -// registerPackageData attaches linked template data to the exact type and -// union emission records selected by the batch planner before freeze. +// registerPackageData associates each selected type and union declaration with +// the template section and imports written to its generated package. func (d *ServicesData) registerPackageData() { for _, emission := range d.facts.generatedTypes { section, errorSection := generatedTypeSections(emission) @@ -47,8 +50,8 @@ func (d *ServicesData) registerPackageData() { } } -// generatedTypeSections formats the selected template family from linked data -// retained on the owning service and method facts. +// generatedTypeSections returns the template sections for one selected payload, +// result, error, or user type. func generatedTypeSections(emission *generatedTypeEmissionFacts) (*codegen.SectionTemplate, *codegen.SectionTemplate) { if emission.method != nil { var methodData *MethodData @@ -89,8 +92,8 @@ func generatedTypeSections(emission *generatedTypeEmissionFacts) (*codegen.Secti return section, &codegen.SectionTemplate{Name: "service-error", Source: serviceTemplates.Read(errorT), Data: data} } -// generatedUserTypeData returns the linked record for one retained authored -// type declaration. +// generatedUserTypeData returns the template data for one authored type +// declaration selected for a generated package. func generatedUserTypeData(emission *generatedTypeEmissionFacts) *UserTypeData { candidates := emission.service.data.userTypes if emission.kind == generatedErrorTypeEmission { @@ -98,14 +101,74 @@ func generatedUserTypeData(emission *generatedTypeEmissionFacts) *UserTypeData { } for _, candidate := range candidates { if candidate.Declaration == emission.declaration { - return candidate + data := *candidate + if data.Description == "" { + data.Description = generatedUserTypeDescription(emission) + } + return &data } } panic(fmt.Sprintf("generated type %q has no linked render data", emission.declaration.Name())) } -// copyGeneratedLocation retains location metadata before callers can mutate -// the expression graph after planning. +// generatedUserTypeDescription explains where an authored type is used. A +// nested type has no method role of its own. +func generatedUserTypeDescription(emission *generatedTypeEmissionFacts) string { + name := emission.declaration.Name() + if len(emission.uses) == 0 { + return fmt.Sprintf("%s is a named type defined in the service design.", name) + } + if len(emission.uses) == 1 { + use := emission.uses[0] + return fmt.Sprintf( + "%s is the %s type of the %s service %s method.", + name, + generatedTypeRoleNames(use.roles), + use.service, + use.method, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is used by these service methods:", name) + for _, use := range emission.uses { + fmt.Fprintf( + &description, + "\n- %s %s: %s", + use.service, + use.method, + generatedTypeRoleNames(use.roles), + ) + } + return description.String() +} + +// generatedTypeRoleNames joins the method fields that use one authored type. +func generatedTypeRoleNames(roles generatedTypeMethodRoles) string { + names := make([]string, 0, 4) + for _, role := range []struct { + value generatedTypeMethodRoles + name string + }{ + {generatedPayloadRole, "payload"}, + {generatedStreamingPayloadRole, "streaming payload"}, + {generatedResultRole, "result"}, + {generatedStreamingResultRole, "streaming result"}, + } { + if roles&role.value != 0 { + names = append(names, role.name) + } + } + if len(names) == 1 { + return names[0] + } + if len(names) == 2 { + return names[0] + " and " + names[1] + } + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] +} + +// copyGeneratedLocation copies the requested package and file so later changes +// to the design expression cannot change where the type is written. func copyGeneratedLocation(location *codegen.Location) *codegen.Location { if location == nil { return nil diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go index 9f94af1cea..29f329bde2 100644 --- a/codegen/service/generated_package.go +++ b/codegen/service/generated_package.go @@ -1,7 +1,5 @@ -// This file binds service types selected by one design root to the generated -// packages that declare them. Planning records relocated user types and unions -// before names freeze; rendering stores one canonical section per declaration -// record so each package emits that declaration once. +// This file assigns service types and unions to the generated packages that +// write them. Each generated package writes each declaration once. package service import ( @@ -16,12 +14,12 @@ import ( ) type ( - // generatedTypeEmissionKind identifies the template family that defines one - // retained relocated type declaration. + // generatedTypeEmissionKind identifies the template that writes one type + // outside its service package. generatedTypeEmissionKind uint8 - // generatedTypeEmissionFacts selects one canonical relocated declaration - // before names freeze and retains the linked data owner used by rendering. + // generatedTypeEmissionFacts records one type declaration written outside its + // service package and the service data used to render it. generatedTypeEmissionFacts struct { kind generatedTypeEmissionKind declaration *codegen.TypeDeclaration @@ -31,55 +29,60 @@ type ( method *methodFacts attribute *methodAttributeFacts userType *userTypeFacts + uses []generatedTypeMethodUse error bool } - // generatedUnionEmissionFacts selects one canonical union definition before - // names freeze and retains its linked render data. + // generatedTypeMethodUse records how one service method directly uses an + // authored type written outside the service package. + generatedTypeMethodUse struct { + service string + method string + roles generatedTypeMethodRoles + } + + // generatedTypeMethodRoles records the method fields whose declared type is + // the authored type being written. + generatedTypeMethodRoles uint8 + + // generatedUnionEmissionFacts records one Goa OneOf declaration and the data + // used to write it outside its service package. generatedUnionEmissionFacts struct { root *rootFacts service *serviceFacts union *unionFacts } - // plannedAttribute identifies one service attribute and the package inherited - // by nested types that do not select their own struct:pkg:path location. + // plannedAttribute records one service field and the Go package used by child + // types that do not declare their own struct:pkg:path location. plannedAttribute struct { attribute *expr.AttributeExpr - service *expr.ServiceExpr + service *serviceFacts location *codegen.Location } - // plannedUserType identifies one user type emitted in one generated package. - // The same expression may be copied into two packages through Extend. + // plannedUserType identifies one user type written to one generated package. + // Extend may copy the same expression into more than one package. plannedUserType struct { userType expr.UserType owner *codegen.GeneratedPackage } - // unionBranch identifies a generated user type that exists only to name one - // branch of its owning union. - unionBranch struct { - union *expr.Union - name string - } - - // rootTypeSet maps compiler-created copies back to the exact DSL declaration - // in the same design root. Generated union aliases have different typed - // origins and are not included. + // rootTypeSet maps compiler-created copies back to the user type declared in + // the same design. Generated Goa OneOf branch aliases are not included. rootTypeSet struct { byOrigin map[expr.UserType]expr.UserType } - // generatedPackageData owns the render data emitted into one Go package. + // generatedPackageData stores the render data emitted into one Go package. generatedPackageData struct { types map[*codegen.TypeDeclaration]*generatedTypeData unions map[*codegen.UnionDeclaration]*UnionTypeData unionImports []*codegen.ImportSpec } - // generatedTypeData owns one relocated user-type declaration and optional - // error behavior at its metadata-selected file. + // generatedTypeData stores one user-type declaration placed in the file + // selected by its metadata, plus optional error behavior. generatedTypeData struct { declaration *codegen.TypeDeclaration location *codegen.Location @@ -98,9 +101,16 @@ const ( generatedErrorTypeEmission ) -// collectServiceDeclarations declares every relocated user type and union reachable from root. -// User types are declared across the complete root before any union so exact -// user-authored names always take precedence over generated union names. +const ( + generatedPayloadRole generatedTypeMethodRoles = 1 << iota + generatedStreamingPayloadRole + generatedResultRole + generatedStreamingResultRole +) + +// collectServiceDeclarations submits every user type and Goa OneOf declaration +// reachable from root. It submits authored type names first, so generated union +// names receive a number when both request the same Go name. func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation) error { if !generation.HasRoot(facts.root) { return rootMembershipError(facts.root) @@ -108,10 +118,9 @@ func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation inputs := planningInputs(facts) rootTypes := facts.rootTypes for _, serviceFacts := range facts.services { - service := serviceFacts.service - // The service package record makes NewServicesData a render-only contract: - // its scope is unavailable until the generation freezes. - if _, err := generation.ClaimPackage(servicePackagePath(generation.GenPkg(), service)); err != nil { + // Record the service package now. File building reads its Go names after all + // generators finish submitting declarations. + if _, err := generation.ClaimPackage(serviceFacts.packagePath); err != nil { return err } } @@ -139,8 +148,9 @@ func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation return nil } -// collectGeneratedPackageEmissions selects one owner for every relocated type -// and union declaration across all roots before the generation freezes. +// collectGeneratedPackageEmissions selects the one service that supplies the +// definition for each type and Goa OneOf declaration shared across the designs +// in this generation command. func collectGeneratedPackageEmissions(roots []*rootFacts) error { types := make(map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts) unions := make(map[*codegen.UnionDeclaration]*generatedUnionEmissionFacts) @@ -206,6 +216,7 @@ func collectGeneratedPackageEmissions(roots []*rootFacts) error { root: root, service: service, userType: userType, + uses: generatedTypeMethodUses(service, userType.declaration), } if err := selectGeneratedTypeEmission(types, emission); err != nil { return err @@ -247,8 +258,8 @@ func collectGeneratedPackageEmissions(roots []*rootFacts) error { return nil } -// selectGeneratedTypeEmission coalesces identical declaration candidates and -// rejects candidates that would give one declaration two emitted contracts. +// selectGeneratedTypeEmission keeps one copy when two services would write the +// same type declaration and rejects them when their definitions differ. func selectGeneratedTypeEmission(selected map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts, candidate *generatedTypeEmissionFacts) error { existing := selected[candidate.declaration] if existing == nil { @@ -258,14 +269,72 @@ func selectGeneratedTypeEmission(selected map[*codegen.TypeDeclaration]*generate if err := validateGeneratedTypeEmission(existing, candidate); err != nil { return err } + uses := mergeGeneratedTypeMethodUses(existing.uses, candidate.uses) if generatedTypeEmissionLess(candidate, existing) { + candidate.uses = uses selected[candidate.declaration] = candidate + } else { + existing.uses = uses } return nil } -// validateGeneratedTypeEmission enforces the one-definition contract for one -// canonical generated type declaration. +// generatedTypeMethodUses records each method that declares the authored type +// as its payload, result, or streamed value. Types used only inside another +// type do not match these declarations. +func generatedTypeMethodUses(service *serviceFacts, declaration *codegen.TypeDeclaration) []generatedTypeMethodUse { + uses := make([]generatedTypeMethodUse, 0, len(service.orderedMethods)) + for _, method := range service.orderedMethods { + var roles generatedTypeMethodRoles + roles = addGeneratedTypeMethodRole(roles, method.payload, declaration, generatedPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingPayload, declaration, generatedStreamingPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.result, declaration, generatedResultRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingResult, declaration, generatedStreamingResultRole) + if roles == 0 { + continue + } + uses = append(uses, generatedTypeMethodUse{ + service: service.name, + method: method.name, + roles: roles, + }) + } + return mergeGeneratedTypeMethodUses(nil, uses) +} + +// addGeneratedTypeMethodRole records role when the method field directly uses +// declaration. +func addGeneratedTypeMethodRole(roles generatedTypeMethodRoles, attribute *methodAttributeFacts, declaration *codegen.TypeDeclaration, role generatedTypeMethodRoles) generatedTypeMethodRoles { + if attribute != nil && attribute.layout != nil && attribute.layout.TypeDeclaration() == declaration { + return roles | role + } + return roles +} + +// mergeGeneratedTypeMethodUses combines the methods found through different +// services or design roots and returns them in a stable order. +func mergeGeneratedTypeMethodUses(left, right []generatedTypeMethodUse) []generatedTypeMethodUse { + uses := append(append(make([]generatedTypeMethodUse, 0, len(left)+len(right)), left...), right...) + sort.Slice(uses, func(i, j int) bool { + if uses[i].service != uses[j].service { + return uses[i].service < uses[j].service + } + return uses[i].method < uses[j].method + }) + merged := uses[:0] + for _, use := range uses { + last := len(merged) - 1 + if last >= 0 && merged[last].service == use.service && merged[last].method == use.method { + merged[last].roles |= use.roles + continue + } + merged = append(merged, use) + } + return merged +} + +// validateGeneratedTypeEmission returns an error when two services would write +// different definitions for the same generated Go type declaration. func validateGeneratedTypeEmission(left, right *generatedTypeEmissionFacts) error { if left.kind != right.kind || !sameGeneratedLocation(left.location, right.location) || !sameGeneratedTypeEmissionSource(left, right) || @@ -287,9 +356,9 @@ func validateGeneratedTypeEmission(left, right *generatedTypeEmissionFacts) erro return nil } -// generatedTypeEmissionLayout returns the exact retained definition written by -// one emission candidate. References belong to consuming service files and do -// not participate in ownership of this declaration's definition. +// generatedTypeEmissionLayout returns the Go type definition supplied by one +// service. References from other service files do not affect which definition +// is written. func generatedTypeEmissionLayout(emission *generatedTypeEmissionFacts) *codegen.GoTypePlan { if emission.userType != nil { return emission.userType.layout @@ -297,8 +366,8 @@ func generatedTypeEmissionLayout(emission *generatedTypeEmissionFacts) *codegen. return emission.attribute.definition } -// sameGeneratedTypeEmissionContent compares the retained comments and error -// behavior that can change the bytes emitted for one declaration. +// sameGeneratedTypeEmissionContent reports whether two services would write +// the same comment and error behavior for one type declaration. func sameGeneratedTypeEmissionContent(left, right *generatedTypeEmissionFacts) bool { if left.error != right.error { return false @@ -315,9 +384,9 @@ func sameGeneratedTypeEmissionContent(left, right *generatedTypeEmissionFacts) b left.attribute.description == right.attribute.description } -// sameGeneratedTypeEmissionSource compares exact authored sources while -// recognizing generated union branch aliases already proven compatible by the -// package's canonical branch declaration. +// sameGeneratedTypeEmissionSource reports whether two candidates came from the +// same authored type. Generated aliases for the same Goa OneOf branch also +// match because the package already owns one declaration for that branch. func sameGeneratedTypeEmissionSource(left, right *generatedTypeEmissionFacts) bool { if generatedTypeEmissionOrigin(left) == generatedTypeEmissionOrigin(right) { return true @@ -327,8 +396,8 @@ func sameGeneratedTypeEmissionSource(left, right *generatedTypeEmissionFacts) bo !right.root.rootTypes.contains(right.userType.userType) } -// generatedTypeEmissionName describes the exact authored or normalized source -// in a planning conflict diagnostic. +// generatedTypeEmissionName describes the design type that caused a conflict +// while selecting one generated definition. func generatedTypeEmissionName(emission *generatedTypeEmissionFacts) string { origin := generatedTypeEmissionOrigin(emission) if origin == nil { @@ -337,8 +406,9 @@ func generatedTypeEmissionName(emission *generatedTypeEmissionFacts) string { return origin.Name() } -// validateGeneratedUnionEmission enforces one location and shape for a -// canonical generated union declaration. +// validateGeneratedUnionEmission returns an error when two services would +// write the same Goa OneOf declaration in different files or with different +// fields. func validateGeneratedUnionEmission(left, right *generatedUnionEmissionFacts) error { if left.union.declaration != right.union.declaration || left.union.identity != right.union.identity || @@ -360,8 +430,9 @@ func validateGeneratedUnionEmission(left, right *generatedUnionEmissionFacts) er return nil } -// sameGeneratedUnionBranches compares every fact that changes one canonical -// union declaration's type, constructors, validation, or JSON helpers. +// sameGeneratedUnionBranches reports whether two services would write the same +// branch fields, constructors, validation, and JSON functions for one Goa OneOf +// declaration. func sameGeneratedUnionBranches(left, right []*unionBranchFacts) bool { if len(left) != len(right) { return false @@ -383,7 +454,7 @@ func sameGeneratedUnionBranches(left, right []*unionBranchFacts) bool { // generatedLocationPath returns the generated package selected by location. // Union declarations always emit in unions.go, so their enclosing type's file -// name is not part of union ownership. +// name does not affect which generated package contains the union. func generatedLocationPath(location *codegen.Location) string { if location == nil { return "" @@ -403,8 +474,8 @@ func generatedTypeEmissionOrigin(emission *generatedTypeEmissionFacts) expr.User return nil } -// generatedTypeEmissionLess orders equivalent candidates by stable service -// and method facts, never by root traversal position. +// generatedTypeEmissionLess orders equal definitions by service and method +// names, so walking designs in another order does not change the selected copy. func generatedTypeEmissionLess(left, right *generatedTypeEmissionFacts) bool { if left.declaration.PackagePath() != right.declaration.PackagePath() { return left.declaration.PackagePath() < right.declaration.PackagePath() @@ -425,8 +496,8 @@ func generatedTypeEmissionLess(left, right *generatedTypeEmissionFacts) bool { return leftMethod < rightMethod } -// generatedUnionEmissionLess orders equivalent union candidates by their -// stable package and service ownership facts. +// generatedUnionEmissionLess orders equal Goa OneOf definitions by package and +// service names. func generatedUnionEmissionLess(left, right *generatedUnionEmissionFacts) bool { if left.union.declaration.PackagePath() != right.union.declaration.PackagePath() { return left.union.declaration.PackagePath() < right.union.declaration.PackagePath() @@ -434,20 +505,19 @@ func generatedUnionEmissionLess(left, right *generatedUnionEmissionFacts) bool { return left.service.packagePath < right.service.packagePath } -// rootMembershipError reports an attempt to plan or analyze a design root -// that the generation does not own. +// rootMembershipError reports an attempt to generate files from a design that +// was not supplied to this generation command. func rootMembershipError(root *expr.RootExpr) error { return fmt.Errorf("service root %p does not belong to the generation", root) } -// planMethodTypes declares the semantic wrappers created when NewGeneration -// takes ownership of raw method objects. Exact user types in the same package -// are planned separately and therefore keep their authored names. +// planMethodTypes submits the payload and result wrapper types created for raw +// object definitions. Authored user types are submitted separately and keep +// their requested names when no declaration conflicts. func planMethodTypes(facts *rootFacts, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { planned := make(map[expr.UserType]codegen.DerivedTypeID) for _, serviceFacts := range facts.services { - service := serviceFacts.service - generatedPackage := generation.Package(servicePackagePath(generation.GenPkg(), service)) + generatedPackage := generation.Package(serviceFacts.packagePath) for _, method := range serviceFacts.methods { attributes := []*expr.AttributeExpr{ method.Payload, @@ -477,45 +547,44 @@ func planMethodTypes(facts *rootFacts, generation *codegen.Generation) (map[expr return planned, nil } -// planningInputs returns the service attributes that can cause service types -// to be emitted. Unused root types are deliberately excluded. +// planningInputs returns the payloads, results, errors, and stream values that +// can write service types. It excludes design types that no service uses. func planningInputs(facts *rootFacts) []plannedAttribute { var inputs []plannedAttribute for _, serviceFacts := range facts.services { - service := serviceFacts.service for _, serviceError := range serviceFacts.errors { - inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: service}) + inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: serviceFacts}) } for _, method := range serviceFacts.methods { inputs = append(inputs, - plannedAttribute{attribute: method.Payload, service: service}, - plannedAttribute{attribute: method.StreamingPayload, service: service}, - plannedAttribute{attribute: method.Result, service: service}, + plannedAttribute{attribute: method.Payload, service: serviceFacts}, + plannedAttribute{attribute: method.StreamingPayload, service: serviceFacts}, + plannedAttribute{attribute: method.Result, service: serviceFacts}, ) if method.HasMixedResults() { - inputs = append(inputs, plannedAttribute{attribute: method.StreamingResult, service: service}) + inputs = append(inputs, plannedAttribute{attribute: method.StreamingResult, service: serviceFacts}) } for _, methodError := range method.Errors { - inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: service}) + inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: serviceFacts}) } } for _, userType := range facts.types { services, ok := userType.Attribute().Meta["type:generate:force"] - if !ok || len(services) > 0 && !slices.Contains(services, service.Name) { + if !ok || len(services) > 0 && !slices.Contains(services, serviceFacts.name) { continue } inputs = append(inputs, plannedAttribute{ attribute: &expr.AttributeExpr{Type: userType}, - service: service, + service: serviceFacts, }) } } return inputs } -// planUserTypes traverses attribute and declares each relocated user type in -// the package selected by its own or its enclosing type's metadata. -func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, methodTypes map[expr.UserType]codegen.DerivedTypeID, seen map[plannedUserType]struct{}) error { +// planUserTypes walks attribute and submits each user type to the Go package +// selected by its own metadata or by its enclosing type. +func planUserTypes(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, methodTypes map[expr.UserType]codegen.DerivedTypeID, seen map[plannedUserType]struct{}) error { if attribute == nil || attribute.Type == expr.Empty { return nil } @@ -532,7 +601,7 @@ func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, loc if typeLocation == nil { typeLocation = location } - owner, err := claimGeneratedPackage(generation, service, typeLocation) + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) if err != nil { return err } @@ -574,9 +643,9 @@ func planUserTypes(attribute *expr.AttributeExpr, service *expr.ServiceExpr, loc return nil } -// planUnions traverses attribute after all user types have been declared and -// records each relocated union in its owning package. -func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { +// planUnions walks attribute after authored user type names have been submitted +// and records each Goa OneOf declaration in the package that writes it. +func planUnions(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { if attribute == nil || attribute.Type == expr.Empty { return nil } @@ -590,7 +659,7 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati if typeLocation == nil { typeLocation = location } - owner, err := claimGeneratedPackage(generation, service, typeLocation) + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) if err != nil { return err } @@ -614,7 +683,7 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati } return recurse(actual.ElemType, location) case *expr.Union: - generatedPackage, err := claimGeneratedPackage(generation, service, location) + generatedPackage, err := claimGeneratedPackage(generation, service.packagePath, location) if err != nil { return err } @@ -639,14 +708,11 @@ func planUnions(attribute *expr.AttributeExpr, service *expr.ServiceExpr, locati return nil } -// planViews rebuilds the same projected expression graph used by rendering, -// declares every derived view type, and then declares view-local union -// families after the derived type names have been recorded. +// planViews builds the result types for each declared view, submits their Go +// type names, and then submits Goa OneOf types written in the views package. func planViews(facts *rootFacts, generation *codegen.Generation) error { for _, serviceFacts := range facts.services { - service := serviceFacts.service - viewsPath := servicePackagePath(generation.GenPkg(), service) + "/views" - views, err := generation.ClaimPackage(viewsPath) + views, err := generation.ClaimPackage(serviceFacts.viewsPath) if err != nil { return err } @@ -724,8 +790,8 @@ func planViews(facts *rootFacts, generation *codegen.Generation) error { return nil } -// collectProjectedTypeFacts selects validators and view-narrowed conversions -// from one projected graph before package names freeze. +// collectProjectedTypeFacts selects validation and conversion code for one set +// of result types containing only the fields in their declared views. func collectProjectedTypeFacts(pair *projectedTypePair) (*projectedTypeFacts, error) { facts := &projectedTypeFacts{ pair: pair, @@ -758,8 +824,8 @@ func collectProjectedTypeFacts(pair *projectedTypePair) (*projectedTypeFacts, er return facts, nil } -// collectValidationFacts retains the exact attributes and child validators -// selected for each projected type view. +// collectValidationFacts stores the field checks and child validation calls +// selected for each result type and view. func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { userType := projected.Type.(expr.UserType) resultType, viewed := userType.(*expr.ResultTypeExpr) @@ -781,7 +847,7 @@ func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { } object := &expr.Object{} walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attribute, viewAttribute *expr.AttributeExpr) { - if nested, ok := attribute.Type.(*expr.ResultTypeExpr); ok { + if _, ok := attribute.Type.(*expr.ResultTypeExpr); ok { selectedView := "" if explicit, ok := viewAttribute.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { selectedView = explicit @@ -790,7 +856,7 @@ func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { name: name, attribute: attribute, view: selectedView, - required: nested.Attribute().IsRequired(name), + required: resultType.Attribute().IsRequired(name), }) return } @@ -802,8 +868,66 @@ func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { return facts } -// collectViewConversionFacts narrows a projected result to each declared view -// and retains the transform operation used in the selected direction. +// markNeededViewValidators keeps only functions that can return an error. A +// parent is needed when it checks one of its own fields or calls a needed child. +func markNeededViewValidators(facts *serviceFacts) { + validations := make(map[viewValidationKey]*validationFacts) + for _, projection := range facts.projections { + for _, projected := range projection.types { + for _, validation := range projected.validations { + key := viewValidationKey{ + origin: projected.pair.projected.Origin(), + view: canonicalValidatorView(validation.viewName), + } + validations[key] = validation + if validation.collectionElem == nil && codegen.NeedsValidation(validation.attribute, viewValidationPolicy()) { + validation.needed = true + } + for _, field := range validation.fields { + validation.needed = validation.needed || field.required + } + } + } + } + + for changed := true; changed; { + changed = false + for _, validation := range validations { + if validation.needed { + continue + } + if validation.collectionElem != nil && neededValidation(validations, validation.collectionElem, validation.viewName) { + validation.needed = true + changed = true + continue + } + for _, field := range validation.fields { + if neededValidation(validations, field.attribute, field.view) { + validation.needed = true + changed = true + break + } + } + } + } +} + +// neededValidation reports whether the selected view-specific result type can +// return a validation error. +func neededValidation(validations map[viewValidationKey]*validationFacts, attribute *expr.AttributeExpr, view string) bool { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return false + } + validation := validations[viewValidationKey{ + origin: userType.Origin(), + view: canonicalValidatorView(view), + }] + return validation != nil && validation.needed +} + +// collectViewConversionFacts selects the fields in each declared result view +// and stores the conversion used in each direction. func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult bool) ([]*viewConversionFacts, error) { views := service.Type.(*expr.ResultTypeExpr).Views projectedObject := expr.AsObject(projected.Type) @@ -864,7 +988,7 @@ func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult }) targetObject.Delete(field.Name) } - plan, err := codegen.NewTransformPlan(source, conversion.transformTarget) + plan, err := codegen.NewTransformPlan(source, conversion.transformTarget, "", nil) if err != nil { return nil, err } @@ -875,9 +999,9 @@ func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult return result, nil } -// planViewUnions declares every union family reachable from one projected -// graph. Projected user types already own their derived declarations; only a -// branch without one is a generated alias owned by its union family. +// planViewUnions submits every Goa OneOf type reachable from view-specific +// result types. Existing view types keep their declarations; a branch without +// one receives a generated alias declaration. func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}, retained map[codegen.UnionTypeID]struct{}, unions *[]*unionFacts) error { if attribute == nil || attribute.Type == expr.Empty { return nil @@ -942,9 +1066,9 @@ func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.Gen return nil } -// newRootTypeSet records the exact declarations whose compiler-created copies -// share package records. Generated union aliases have independent origins -// and never enter this set. +// newRootTypeSet records the authored types in one design so compiler-created +// copies can use the same generated Go declarations. Generated Goa OneOf branch +// aliases are not added. func newRootTypeSet(root *expr.RootExpr) *rootTypeSet { userTypes := &rootTypeSet{ byOrigin: make(map[expr.UserType]expr.UserType, len(root.Types)+len(root.ResultTypes)+1), @@ -959,8 +1083,8 @@ func newRootTypeSet(root *expr.RootExpr) *rootTypeSet { return userTypes } -// generatedUnionBranch identifies the user type synthesized by OneOf around a -// branch that was not itself an exact DSL user-type declaration. +// generatedUnionBranch reports whether OneOf created a user type around a +// branch that was not declared as a user type in the design. func generatedUnionBranch(branch *expr.NamedAttributeExpr, rootTypes *rootTypeSet) (expr.UserType, bool) { userType, ok := branch.Attribute.Type.(expr.UserType) if !ok { @@ -969,13 +1093,14 @@ func generatedUnionBranch(branch *expr.NamedAttributeExpr, rootTypes *rootTypeSe return userType, !rootTypes.contains(userType) } -// add records one exact root declaration under its typed origin. +// add records one user type declared in this design under its original type. func (s *rootTypeSet) add(userType expr.UserType) { s.byOrigin[userType.Origin()] = userType } -// canonical maps only a compiler copy whose typed origin belongs to this root -// back to its exact declaration. +// canonical returns the original authored declaration for a compiler-created +// copy from this design. Types originating in another design are returned +// unchanged. func (s *rootTypeSet) canonical(userType expr.UserType) expr.UserType { if canonical, ok := s.byOrigin[userType.Origin()]; ok { return canonical @@ -983,20 +1108,20 @@ func (s *rootTypeSet) canonical(userType expr.UserType) expr.UserType { return userType } -// contains reports whether userType is an exact root declaration or one of -// its compiler copies. +// contains reports whether userType was declared in this design or copied from +// one of its declarations. func (s *rootTypeSet) contains(userType expr.UserType) bool { _, ok := s.byOrigin[userType.Origin()] return ok } -// claimGeneratedPackage preserves the relative path spelling supplied by -// design metadata so Generation can reject two claims that resolve to one -// output package. An absolute path violates the metadata contract instead of -// selecting a package beneath the generated module by string concatenation. -func claimGeneratedPackage(generation *codegen.Generation, service *expr.ServiceExpr, location *codegen.Location) (*codegen.GeneratedPackage, error) { +// claimGeneratedPackage passes the relative path from design metadata to +// Generation unchanged. Generation can then reject two different path strings +// that resolve to the same output package. An absolute path is invalid metadata +// and does not select a package under the generated module. +func claimGeneratedPackage(generation *codegen.Generation, servicePath string, location *codegen.Location) (*codegen.GeneratedPackage, error) { if location == nil { - return generation.ClaimPackage(servicePackagePath(generation.GenPkg(), service)) + return generation.ClaimPackage(servicePath) } if path.IsAbs(location.RelImportPath) { return nil, fmt.Errorf("generated package location %q must be relative", location.RelImportPath) @@ -1005,17 +1130,11 @@ func claimGeneratedPackage(generation *codegen.Generation, service *expr.Service return generation.ClaimPackage(claim) } -// generatedPackagePath returns the canonical import path selected by location, +// generatedPackagePath returns the cleaned import path selected by location, // or the service package when location is nil. -func generatedPackagePath(genpkg string, service *expr.ServiceExpr, location *codegen.Location) string { +func generatedPackagePath(genpkg, servicePath string, location *codegen.Location) string { if location != nil { return path.Join(genpkg, location.RelImportPath) } - return servicePackagePath(genpkg, service) -} - -// servicePackagePath returns the actual import path of service's generated Go -// package. -func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { - return path.Join(genpkg, codegen.SnakeCase(codegen.Goify(service.Name, false))) + return servicePath } diff --git a/codegen/service/imports.go b/codegen/service/imports.go index 72357e736c..cb421b17fa 100644 --- a/codegen/service/imports.go +++ b/codegen/service/imports.go @@ -1,6 +1,5 @@ -// This file plans one immutable import alias per complete package path, then -// computes the exact subset of those imports used by each generated service -// file. Qualified references and import declarations share these bindings. +// This file chooses one Go package name for each import path and records which +// imports each generated service file uses. package service import ( @@ -14,8 +13,8 @@ import ( ) type ( - // importAliases is the frozen render-model binding from complete import paths - // to their unique Go qualifiers. + // importAliases returns the Go package name chosen for an import path in one + // output package before files are rendered. importAliases struct { generation *codegen.Generation } @@ -31,11 +30,12 @@ type ( err error } - // retainedFileImports stores the complete package paths selected for one - // emitted file and their frozen import declarations after linking. + // This record stores the complete package paths used by one emitted file and + // the import declarations built after all package names are chosen. retainedFileImports struct { - paths []string - specs []*codegen.ImportSpec + outputPackage string + paths []string + specs []*codegen.ImportSpec } // serviceFileImports keeps imports separate for files that emit different @@ -55,8 +55,8 @@ type ( ) // AttributeImports returns the exact generated-type and metadata imports -// referenced by attributes using the frozen aliases shared with service type -// references. +// referenced by attributes, using the same chosen package names as service +// type references. func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) seen := make(map[expr.UserType]struct{}) @@ -66,8 +66,9 @@ func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*exp return collector.imports() } -// newImportAliases returns the generation-owned frozen alias binding used by -// service analysis and rendering. +// newImportAliases returns package-name lookups for the supplied generation. +// Service analysis and rendering use these lookups after all import names are +// chosen. func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { if !generation.HasRoot(root) { return nil, rootMembershipError(root) @@ -75,15 +76,15 @@ func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*imp return &importAliases{generation: generation}, nil } -// name returns the frozen qualifier for importPath and panics when rendering -// asks for a package that was absent from alias planning. -func (a *importAliases) name(importPath string) string { - return a.generation.ImportName(importPath) +// name returns the chosen Go package name for importPath. It panics when a +// renderer asks for a package that was not recorded during planning. +func (a *importAliases) name(outputPackage, importPath string) string { + return a.generation.Package(outputPackage).ImportName(importPath) } -// spec returns the frozen import declaration for importPath. -func (a *importAliases) spec(importPath string) *codegen.ImportSpec { - return a.generation.Import(importPath) +// spec returns the completed import declaration for importPath. +func (a *importAliases) spec(outputPackage, importPath string) *codegen.ImportSpec { + return a.generation.Package(outputPackage).Import(importPath) } // newImportCollector creates a file-scoped collector that omits imports of the @@ -174,7 +175,7 @@ func (c *importCollector) addLocation(location *codegen.Location) { if importPath != c.outputPackage { c.paths[importPath] = struct{}{} if c.planning && c.err == nil { - c.err = c.aliases.generation.ReserveGeneratedImport(codegen.NewImport( + c.err = c.aliases.generation.Package(c.outputPackage).ReserveGeneratedImport(codegen.NewImport( strings.ToLower(codegen.Goify(path.Base(importPath), false)), importPath, )) @@ -189,13 +190,14 @@ func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { if spec != nil && spec.Path != c.outputPackage { c.paths[spec.Path] = struct{}{} if c.planning && c.err == nil { - c.err = c.aliases.generation.DeclareImport(spec) + c.err = c.aliases.generation.Package(c.outputPackage).DeclareImport(spec) } } } -// retainFileImports collects one emitted file's exact fixed, generated, type -// definition, and recursive-reference package paths before names freeze. +// retainFileImports collects the fixed, generated, type-definition, and +// recursive-reference package paths used by one emitted file before +// Generation.Freeze chooses their Go package names. func retainFileImports( generation *codegen.Generation, outputPackage string, @@ -203,15 +205,16 @@ func retainFileImports( definitions, references []*expr.AttributeExpr, ) (retainedFileImports, error) { collector := newPlanningImportCollector(generation, outputPackage) + owner := generation.Package(outputPackage) for _, spec := range fixed { collector.addPath(spec.Path) - if err := generation.RequireImport(spec); err != nil { + if err := owner.RequireImport(spec); err != nil { return retainedFileImports{}, err } } for _, spec := range generated { collector.addPath(spec.Path) - if err := generation.ReserveGeneratedImport(spec); err != nil { + if err := owner.ReserveGeneratedImport(spec); err != nil { return retainedFileImports{}, err } } @@ -230,20 +233,25 @@ func retainFileImports( paths = append(paths, importPath) } sort.Strings(paths) - return retainedFileImports{paths: paths}, nil + return retainedFileImports{outputPackage: outputPackage, paths: paths}, nil } -// linkFileImports resolves one retained path list through the frozen -// Generation alias catalog without traversing service attributes. +// linkFileImports converts one saved path list into import declarations after +// Generation.Freeze chooses the package names. It does not reread service +// attributes. func linkFileImports(imports *retainedFileImports, generation *codegen.Generation) { imports.specs = make([]*codegen.ImportSpec, len(imports.paths)) + if len(imports.paths) == 0 { + return + } + owner := generation.Package(imports.outputPackage) for index, importPath := range imports.paths { - imports.specs[index] = generation.Import(importPath) + imports.specs[index] = owner.Import(importPath) } } -// addRetainedImportPath adds one explicitly declared package to a file's -// retained path set while preserving deterministic order. +// addRetainedImportPath adds one explicitly declared package to a file's saved +// path list in sorted order. func addRetainedImportPath(imports *retainedFileImports, importPath string) { index, found := slices.BinarySearch(imports.paths, importPath) if found { @@ -253,13 +261,12 @@ func addRetainedImportPath(imports *retainedFileImports, importPath string) { } // planServiceFileImports selects the package paths used by each concrete file -// emitted for one retained service and declares their alias preferences before -// generation freezes. +// emitted for one service copied into the plan. It requests their preferred Go +// package names before Generation.Freeze chooses the final names. func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { - service := facts.service - servicePath := servicePackagePath(generation.GenPkg(), service) - serviceImport := codegen.NewImport(strings.ToLower(codegen.Goify(service.Name, false)), servicePath) - viewsImport := codegen.NewImport(serviceImport.Name+"views", servicePath+"/views") + servicePath := facts.packagePath + serviceImport := facts.packageImport + viewsImport := facts.viewsImport facts.generatedTypeImports = make(map[*codegen.TypeDeclaration]*retainedFileImports) definitions := serviceDefinitionAttributes(facts) @@ -328,7 +335,9 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat } if len(facts.projections) > 0 { - viewsFixed := []*codegen.ImportSpec{goaImport, codegen.SimpleImport("unicode/utf8")} + viewsFixed := []*codegen.ImportSpec{goaImport} + validationFixed, validationGenerated := viewValidationImports(facts) + viewsFixed = append(viewsFixed, validationFixed...) if len(facts.viewUnions) > 0 { viewsFixed = append(viewsFixed, codegen.SimpleImport("bytes"), @@ -337,7 +346,7 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat ) } facts.imports.views, err = retainFileImports( - generation, servicePath+"/views", viewsFixed, nil, viewDefinitions, nil, + generation, servicePath+"/views", viewsFixed, validationGenerated, viewDefinitions, nil, ) if err != nil { return err @@ -346,16 +355,26 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat interceptorFixed := []*codegen.ImportSpec{contextImport, goaImport} if len(facts.serverInterceptors) > 0 { + serverInterceptorNames := interceptorNames(facts.serverInterceptorFacts) + serverInterceptorReferences := interceptorReferences(facts.serverInterceptorFacts) + serverInterceptorReferences = append( + serverInterceptorReferences, + interceptorReferencesOnly(facts.clientInterceptorFacts, serverInterceptorNames)..., + ) facts.imports.serverInterceptors, err = retainFileImports( - generation, servicePath, interceptorFixed, nil, nil, nil, + generation, servicePath, interceptorFixed, nil, nil, serverInterceptorReferences, ) if err != nil { return err } } if len(facts.clientInterceptors) > 0 { + clientInterceptorReferences := interceptorReferencesWithout( + facts.clientInterceptorFacts, + interceptorNames(facts.serverInterceptorFacts), + ) facts.imports.clientInterceptors, err = retainFileImports( - generation, servicePath, interceptorFixed, nil, nil, nil, + generation, servicePath, interceptorFixed, nil, nil, clientInterceptorReferences, ) if err != nil { return err @@ -441,7 +460,7 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat continue } owner := generation.Package(generatedPackagePath( - generation.GenPkg(), facts.service, codegen.UserTypeLocation(userType), + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), )) declaration, err := owner.UserType(rootTypes.canonical(userType)) if err != nil { @@ -493,8 +512,117 @@ func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generat return nil } +// interceptorReferences returns the selected fields whose Go types are written +// in an interceptor interface or accessor method. +func interceptorReferences(interceptors []*interceptorFacts) []*expr.AttributeExpr { + return interceptorReferencesWithout(interceptors, nil) +} + +// interceptorReferencesWithout returns selected interceptor fields except for +// interceptors whose names are emitted by another file. +func interceptorReferencesWithout(interceptors []*interceptorFacts, excluded map[string]struct{}) []*expr.AttributeExpr { + var references []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, skip := excluded[interceptor.name]; skip { + continue + } + references = append(references, interceptorValueReferences(interceptor)...) + } + return references +} + +// interceptorReferencesOnly returns references for interceptor definitions +// written in another file with the same name. +func interceptorReferencesOnly(interceptors []*interceptorFacts, included map[string]struct{}) []*expr.AttributeExpr { + var references []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, keep := included[interceptor.name]; !keep { + continue + } + references = append(references, interceptorValueReferences(interceptor)...) + } + return references +} + +// interceptorValueReferences returns the selected fields and the complete +// method values stored behind their generated accessors. +func interceptorValueReferences(interceptor *interceptorFacts) []*expr.AttributeExpr { + accesses := [][]*interceptorAccessFacts{ + interceptor.readPayloadFields, + interceptor.writePayloadFields, + interceptor.readResultFields, + interceptor.writeResultFields, + interceptor.readStreamingPayloadFields, + interceptor.writeStreamingPayloadFields, + interceptor.readStreamingResultFields, + interceptor.writeStreamingResultFields, + } + var references []*expr.AttributeExpr + for _, fields := range accesses { + for _, field := range fields { + references = append(references, field.attribute) + } + } + hasPayload := len(interceptor.readPayloadFields) > 0 || len(interceptor.writePayloadFields) > 0 + hasResult := len(interceptor.readResultFields) > 0 || len(interceptor.writeResultFields) > 0 + hasStreamingPayload := len(interceptor.readStreamingPayloadFields) > 0 || len(interceptor.writeStreamingPayloadFields) > 0 + hasStreamingResult := len(interceptor.readStreamingResultFields) > 0 || len(interceptor.writeStreamingResultFields) > 0 + for _, method := range interceptor.methods { + if hasPayload { + references = append(references, method.payload.attribute) + } + if hasResult { + references = append(references, method.result.attribute) + } + if hasStreamingPayload { + references = append(references, method.streamingPayload.attribute) + } + if hasStreamingResult { + references = append(references, method.result.attribute) + } + } + return references +} + +// interceptorNames returns the names of interceptors emitted in a file. +func interceptorNames(interceptors []*interceptorFacts) map[string]struct{} { + names := make(map[string]struct{}, len(interceptors)) + for _, interceptor := range interceptors { + names[interceptor.name] = struct{}{} + } + return names +} + +// viewValidationImports separates packages named directly by templates from +// generated packages whose import names may change to avoid a collision. +func viewValidationImports(facts *serviceFacts) (fixed, generated []*codegen.ImportSpec) { + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, validation := range projected.validations { + if validation.plan == nil { + continue + } + for _, preference := range validation.plan.ImportPreferences() { + spec := codegen.NewImport(preference.Name, preference.Path) + switch preference.Path { + case codegen.GoaImport("").Path, "unicode/utf8": + fixed = append(fixed, spec) + default: + generated = append(generated, spec) + } + } + } + } + } + return +} + // linkServiceFileImports resolves every concrete file contribution after the -// generation alias catalog freezes. +// Generation.Freeze chooses all imported package names. func linkServiceFileImports(facts *serviceFacts, generation *codegen.Generation) { imports := []*retainedFileImports{ &facts.imports.service, @@ -548,8 +676,9 @@ func serviceDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { return definitions } -// viewDefinitionAttributes returns each projected definition emitted in the -// service views file exactly once by expression identity. +// viewDefinitionAttributes returns each view-specific definition emitted in +// the service views file exactly once, even when several entries point to the +// same attribute value. func viewDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { seen := make(map[*expr.AttributeExpr]struct{}) var definitions []*expr.AttributeExpr @@ -595,13 +724,13 @@ func serviceUsesResponseBody(facts *serviceFacts) bool { // calls the Goa service-error runtime. func serviceUsesGoaErrors(facts *serviceFacts) bool { for _, serviceError := range facts.errors { - if serviceError.Type == expr.ErrorResult { + if expr.IsErrorResult(serviceError.Type) { return true } } for _, method := range facts.methods { for _, methodError := range method.Errors { - if methodError.Type == expr.ErrorResult { + if expr.IsErrorResult(methodError.Type) { return true } } @@ -619,7 +748,7 @@ func (c *importCollector) imports() []*codegen.ImportSpec { sort.Strings(paths) imports := make([]*codegen.ImportSpec, len(paths)) for i, importPath := range paths { - imports[i] = c.aliases.spec(importPath) + imports[i] = c.aliases.spec(c.outputPackage, importPath) } return imports } diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index 007499221a..c0912ee841 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -99,13 +99,14 @@ func TestFileImportsAreRetainedBeforeFreeze(t *testing.T) { // the same complete package path. func TestImportAliasesUsePathAsIdentity(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) - require.NoError(t, generation.DeclareImport(codegen.NewImport("jason", "encoding/json"))) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport("jason", "encoding/json"))) require.NoError(t, generation.Freeze()) aliases := &importAliases{generation: generation} - require.Equal(t, "json", aliases.name("encoding/json")) - require.Equal(t, "encoding/json", aliases.spec("encoding/json").Path) + require.Equal(t, "json", aliases.name(pkg.ImportPath(), "encoding/json")) + require.Equal(t, "encoding/json", aliases.spec(pkg.ImportPath(), "encoding/json").Path) } // TestImportAliasPreferenceIsOrderIndependent verifies that two metadata @@ -113,10 +114,11 @@ func TestImportAliasesUsePathAsIdentity(t *testing.T) { func TestImportAliasPreferenceIsOrderIndependent(t *testing.T) { freeze := func(first, second string) string { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.DeclareImport(codegen.NewImport(first, "example.com/value"))) - require.NoError(t, generation.DeclareImport(codegen.NewImport(second, "example.com/value"))) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.DeclareImport(codegen.NewImport(first, "example.com/value"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport(second, "example.com/value"))) require.NoError(t, generation.Freeze()) - return generation.ImportName("example.com/value") + return pkg.ImportName("example.com/value") } require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) @@ -155,8 +157,9 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { require.NoError(t, secondPlan.Link()) first := firstPlan.Services() second := secondPlan.Services() - require.Equal(t, "alpha", first.aliases.name("example.com/shared/value")) - require.Equal(t, first.aliases.name("example.com/shared/value"), second.aliases.name("example.com/shared/value")) + const outputPackage = "generated.local/gen/types" + require.Equal(t, "alpha", first.aliases.name(outputPackage, "example.com/shared/value")) + require.Equal(t, first.aliases.name(outputPackage, "example.com/shared/value"), second.aliases.name(outputPackage, "example.com/shared/value")) files := mustServiceFiles(t, firstPlan, secondPlan) for _, name := range []string{"first_payload.go", "second_payload.go"} { @@ -192,8 +195,9 @@ func TestEmittedUnionReservesFixedJSON(t *testing.T) { require.NoError(t, generation.Freeze()) aliases, err := newImportAliases(root, generation) require.NoError(t, err) - require.Equal(t, "json", aliases.name("encoding/json")) - require.Equal(t, "json2", aliases.name("example.com/custom/json")) + const outputPackage = "generated.local/gen/values" + require.Equal(t, "json", aliases.name(outputPackage, "encoding/json")) + require.Equal(t, "json2", aliases.name(outputPackage, "example.com/custom/json")) } // TestFixedTemplateAliasesBeatGeneratedPackages verifies that generated @@ -210,10 +214,11 @@ func TestFixedTemplateAliasesBeatGeneratedPackages(t *testing.T) { }) plan := mustServicePlan(t, root) services := plan.Services() - require.Equal(t, "goa", services.aliases.name(codegen.GoaImport("").Path)) - require.Equal(t, "goa2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) - require.Equal(t, "log", services.aliases.name("goa.design/clue/log")) - require.Equal(t, "log2", services.aliases.name(servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) + outputPackage := path.Join(path.Dir(services.generation.GenPkg()), "interceptors") + require.Equal(t, "goa", services.aliases.name(outputPackage, codegen.GoaImport("").Path)) + require.Equal(t, "goa2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) + require.Equal(t, "log", services.aliases.name(outputPackage, "goa.design/clue/log")) + require.Equal(t, "log2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) } // TestMetadataImportKeepsItsPreferredAlias verifies that an import used only @@ -256,8 +261,9 @@ func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { plan := mustServicePlan(t, root) services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) - require.Equal(t, "values", services.aliases.name(servicePath)) - require.Equal(t, "values2", services.aliases.name("example.com/custom/values")) + outputPackage := path.Dir(services.generation.GenPkg()) + require.Equal(t, "values", services.aliases.name(outputPackage, servicePath)) + require.Equal(t, "values2", services.aliases.name(outputPackage, "example.com/custom/values")) files := ExampleServiceFiles(plan) require.Len(t, files, 1) @@ -295,9 +301,10 @@ func TestExampleServiceReservesFixedQualifiers(t *testing.T) { plan := mustServicePlan(t, root) services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Fmt")) - servicePkg := services.aliases.name(servicePath) + outputPackage := path.Dir(services.generation.GenPkg()) + servicePkg := services.aliases.name(outputPackage, servicePath) require.NotEqual(t, "fmt", servicePkg) - require.Equal(t, "strings2", services.aliases.name("example.com/custom/strings")) + require.Equal(t, "strings2", services.aliases.name(outputPackage, "example.com/custom/strings")) files := ExampleServiceFiles(plan) require.Len(t, files, 1) @@ -331,8 +338,8 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { services := plan.Services() servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) viewsPath := servicePath + "/views" - require.Equal(t, "valuesviews", services.aliases.name(viewsPath)) - require.Equal(t, "valuesviews2", services.aliases.name("example.com/custom/views")) + require.Equal(t, "valuesviews", services.aliases.name(servicePath, viewsPath)) + require.Equal(t, "valuesviews2", services.aliases.name(servicePath, "example.com/custom/views")) file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "service.go")) require.NotNil(t, file) @@ -343,14 +350,50 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { require.Contains(t, code, `valuesviews2 "example.com/custom/views"`) } +// TestViewValidationReservesOnlyUsedImports verifies that validation +// without string-length checks leaves the utf8 package name available to a +// field type supplied by the design. +func TestViewValidationReservesOnlyUsedImports(t *testing.T) { + const customUTF8 = "example.com/custom/utf8" + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "utf8.Value", customUTF8, "utf8") + }) + dsl.Attribute("name", dsl.String, func() { + dsl.Pattern("^[a-z]+$") + }) + dsl.View("default", func() { + dsl.Attribute("value") + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + outputPackage := servicePackagePath(plan.Services().generation.GenPkg(), root.Service("Values")) + "/views" + require.Equal(t, "utf8", plan.Services().aliases.name(outputPackage, customUTF8)) + + file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "views", "view.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"`+customUTF8+`"`) + require.NotContains(t, code, `"unicode/utf8"`) +} + // TestUnionFieldReferencesUseFixedImportAliases verifies that the qualifier in // a union field type and the import declaration come from the same frozen path // binding when encoding/json already owns the preferred json name. func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) - require.NoError(t, generation.RequireImport(codegen.SimpleImport("encoding/json"))) - require.NoError(t, generation.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) - require.NoError(t, generation.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, generatedPackage.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, }} @@ -361,7 +404,6 @@ func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { Attribute: branch, }}, } - generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") declaration, err := generatedPackage.DeclareUnion(union) require.NoError(t, err) facts := &unionFacts{ diff --git a/codegen/service/interceptor_data.go b/codegen/service/interceptor_data.go index f1ef1b615c..431703e9a9 100644 --- a/codegen/service/interceptor_data.go +++ b/codegen/service/interceptor_data.go @@ -1,4 +1,5 @@ -// This file formats retained interceptor applicability and access facts for interceptor templates. +// This file builds the values used to generate interceptor interfaces and +// wrappers. package service import ( @@ -7,20 +8,21 @@ import ( // buildInterceptorData creates the data needed to generate interceptor code. func buildInterceptorData(service *serviceFacts, facts *interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) *InterceptorData { - lookup := func(role serviceNameRole, method, subject string) *codegen.NameDeclaration { + lookup := func(role serviceNameRole, subject string) *codegen.NameDeclaration { return service.names[serviceSymbolID{ - role: role, service: service.name, method: method, subject: subject, + role: role, service: service.name, subject: subject, }].declaration } data := &InterceptorData{ - InfoDeclaration: lookup(serviceInterceptorInfoNameRole, "", facts.name), - PayloadDeclaration: lookup(serviceInterceptorPayloadNameRole, "", facts.name), - ResultDeclaration: lookup(serviceInterceptorResultNameRole, "", facts.name), - StreamingPayloadDeclaration: lookup(serviceInterceptorStreamingPayloadNameRole, "", facts.name), - StreamingResultDeclaration: lookup(serviceInterceptorStreamingResultNameRole, "", facts.name), + InfoDeclaration: lookup(serviceInterceptorInfoNameRole, facts.name), + PayloadDeclaration: lookup(serviceInterceptorPayloadNameRole, facts.name), + ResultDeclaration: lookup(serviceInterceptorResultNameRole, facts.name), + StreamingPayloadDeclaration: lookup(serviceInterceptorStreamingPayloadNameRole, facts.name), + StreamingResultDeclaration: lookup(serviceInterceptorStreamingResultNameRole, facts.name), Name: codegen.Goify(facts.name, true), DesignName: facts.name, Description: facts.description, + Service: service.name, } if len(facts.methods) == 0 { return data @@ -49,8 +51,8 @@ func buildInterceptorData(service *serviceFacts, facts *interceptorFacts, method return data } -// formatInterceptorAccess resolves the frozen type spelling for fields chosen -// during interceptor planning. +// formatInterceptorAccess returns the generated name and type for each field +// that an interceptor may read or write. func formatInterceptorAccess(facts []*interceptorAccessFacts, resolver *declarationResolver) []*AttributeData { if len(facts) == 0 { return nil @@ -59,7 +61,7 @@ func formatInterceptorAccess(facts []*interceptorAccessFacts, resolver *declarat for index, field := range facts { data[index] = &AttributeData{ Name: field.name, - TypeRef: field.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)).Ref(), + TypeRef: field.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)).Ref(), Pointer: field.pointer, } } @@ -126,6 +128,11 @@ func buildInterceptorMethodData(service *serviceFacts, interceptorName string, m streamingResultAccess = streamingResultAccessDeclaration.Name() } return &MethodInterceptorData{ + InfoDeclaration: declaration(serviceInterceptorMethodInfoNameRole), + ServerUnaryInfoDeclaration: declaration(serviceInterceptorServerUnaryInfoNameRole), + ClientUnaryInfoDeclaration: declaration(serviceInterceptorClientUnaryInfoNameRole), + StreamingSendInfoDeclaration: declaration(serviceInterceptorStreamingSendInfoNameRole), + StreamingRecvInfoDeclaration: declaration(serviceInterceptorStreamingRecvInfoNameRole), PayloadAccessDeclaration: payloadAccessDeclaration, ResultAccessDeclaration: resultAccessDeclaration, StreamingPayloadAccessDeclaration: streamingPayloadAccessDeclaration, @@ -140,7 +147,7 @@ func buildInterceptorMethodData(service *serviceFacts, interceptorName string, m StreamingPayloadAccess: streamingPayloadAccess, StreamingPayloadRef: md.StreamingPayloadRef, StreamingResultAccess: streamingResultAccess, - StreamingResultRef: md.ResultRef, + StreamingResultRef: md.StreamingResultRef, ClientStream: clientStream, ServerStream: serverStream, } diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index ef71d26b66..7773141949 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -1,5 +1,5 @@ -// This file renders service interceptor interfaces, information records, and -// endpoint wrappers from the interceptor data collected during service analysis. +// This file generates service interceptor interfaces, call information, and +// endpoint wrappers. package service import ( @@ -9,8 +9,8 @@ import ( ) type ( - // endpointInterceptorWrapperData binds one public endpoint wrapper to the - // exact private interceptor wrappers it applies in order. + // endpointInterceptorWrapperData lists the interceptor wrappers called by + // one generated endpoint wrapper, in call order. endpointInterceptorWrapperData struct { Declaration *codegen.NameDeclaration InterceptorsDeclaration *codegen.NameDeclaration @@ -19,8 +19,8 @@ type ( Wrappers []*codegen.NameDeclaration } - // interceptorWrappersData supplies one side's exact interceptor interface - // and retained interceptor operations to its wrapper template. + // interceptorWrappersData identifies the server or client interceptor + // interface and the interceptors called through it. interceptorWrappersData struct { Service string InterceptorsDeclaration *codegen.NameDeclaration @@ -28,8 +28,7 @@ type ( } ) -// interceptorsFiles renders interceptors for the exact service retained by -// plan. +// interceptorsFiles generates interceptor files for one service. func interceptorsFiles(plan *Plan, facts *serviceFacts) []*codegen.File { var files []*codegen.File services := plan.Services() @@ -67,9 +66,9 @@ func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *cod desc = svc.Name + desc path := filepath.Join(codegen.Gendir, svc.PathName, filename) - interceptors := svc.ServerInterceptors - if !server { - interceptors = svc.ClientInterceptors + interceptors := svc.ClientInterceptors + if server { + interceptors = mergeInterceptorDefinitions(svc.ServerInterceptors, svc.ClientInterceptors) } appliedInterceptors := interceptors @@ -154,8 +153,8 @@ func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *cod Source: serviceTemplates.Read(interceptorsT), Data: interceptors, FuncMap: map[string]any{ - "hasPrivateImplementationTypes": hasPrivateImplementationTypes, - "hasEndpointStruct": hasEndpointStruct(server), + "hasPrivateAccessorMethods": hasPrivateAccessorMethods, + "hasEndpointStruct": hasEndpointStruct(server), }, }) } @@ -163,6 +162,35 @@ func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *cod return &codegen.File{Path: path, SectionTemplates: sections} } +// mergeInterceptorDefinitions adds client-only methods when the shared +// interceptor interface is written in the server file. This keeps every method +// that uses that interface in the same generated file. +func mergeInterceptorDefinitions(server, client []*InterceptorData) []*InterceptorData { + merged := make([]*InterceptorData, len(server)) + for index, interceptor := range server { + copy := *interceptor + copy.Methods = append([]*MethodInterceptorData(nil), interceptor.Methods...) + seen := make(map[string]struct{}, len(copy.Methods)) + for _, method := range copy.Methods { + seen[method.MethodName] = struct{}{} + } + for _, candidate := range client { + if candidate.DesignName != interceptor.DesignName { + continue + } + for _, method := range candidate.Methods { + if _, exists := seen[method.MethodName]; exists { + continue + } + copy.Methods = append(copy.Methods, method) + seen[method.MethodName] = struct{}{} + } + } + merged[index] = © + } + return merged +} + // wrapperFile returns the file containing the interceptor wrappers. func wrapperFile(svc *Data, imports []*codegen.ImportSpec) *codegen.File { path := filepath.Join(codegen.Gendir, svc.PathName, "interceptor_wrappers.go") @@ -247,8 +275,9 @@ func wrapperFile(svc *Data, imports []*codegen.ImportSpec) *codegen.File { } } -// interceptorMethod returns the retained method record for one named -// interceptor application. Planning guarantees that both identities exist. +// interceptorMethod returns the generated call information for one interceptor +// and service method. The design has already linked the interceptor to the +// method, so a missing entry is a generator bug. func interceptorMethod(interceptors []*InterceptorData, name, method string) *MethodInterceptorData { for _, interceptor := range interceptors { if interceptor.DesignName != name { @@ -263,11 +292,23 @@ func interceptorMethod(interceptors []*InterceptorData, name, method string) *Me panic("retained interceptor method is missing") } -// hasPrivateImplementationTypes returns true if any of the interceptors have -// private implementation types. +// hasPrivateImplementationTypes reports whether the file needs private structs +// that hold call information for a service method. func hasPrivateImplementationTypes(interceptors []*InterceptorData) bool { for _, intr := range interceptors { - if intr.ReadPayload != nil || intr.WritePayload != nil || intr.ReadResult != nil || intr.WriteResult != nil || intr.ReadStreamingPayload != nil || intr.WriteStreamingPayload != nil || intr.ReadStreamingResult != nil || intr.WriteStreamingResult != nil { + if len(intr.Methods) > 0 { + return true + } + } + return false +} + +// hasPrivateAccessorMethods reports whether an interceptor exposes selected +// payload or result fields through private accessor methods. +func hasPrivateAccessorMethods(interceptors []*InterceptorData) bool { + for _, interceptor := range interceptors { + if interceptor.HasPayloadAccess || interceptor.HasResultAccess || + interceptor.HasStreamingPayloadAccess || interceptor.HasStreamingResultAccess { return true } } diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index cedd07bd4e..70c4ab5987 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -31,8 +31,13 @@ func TestInterceptors(t *testing.T) { {"single-service-server-interceptor", testdata.SingleServiceServerInterceptorDSL, 2}, {"single-method-server-interceptor", testdata.SingleMethodServerInterceptorDSL, 2}, {"single-client-interceptor", testdata.SingleClientInterceptorDSL, 2}, + {"leading-initialism-interceptor", testdata.LeadingInitialismInterceptorDSL, 3}, {"multiple-interceptors", testdata.MultipleInterceptorsExampleDSL, 3}, {"interceptor-with-read-payload", testdata.InterceptorWithReadPayloadDSL, 3}, + {"interceptor-with-external-read-payload", testdata.InterceptorWithExternalReadPayloadDSL, 2}, + {"interceptor-with-external-payload", testdata.InterceptorWithExternalPayloadDSL, 2}, + {"mixed-interceptors-with-external-client-payload", testdata.MixedInterceptorsWithExternalClientPayloadDSL, 3}, + {"merged-interceptors-with-external-client-payload", testdata.MergedInterceptorsWithExternalClientPayloadDSL, 3}, {"interceptor-with-write-payload", testdata.InterceptorWithWritePayloadDSL, 3}, {"interceptor-with-read-write-payload", testdata.InterceptorWithReadWritePayloadDSL, 3}, {"interceptor-with-read-result", testdata.InterceptorWithReadResultDSL, 3}, @@ -41,6 +46,7 @@ func TestInterceptors(t *testing.T) { {"streaming-interceptors", testdata.StreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload-and-read-streaming-payload", testdata.StreamingInterceptorsWithReadPayloadAndReadStreamingPayloadDSL, 3}, {"streaming-interceptors-with-read-streaming-result", testdata.StreamingInterceptorsWithReadStreamingResultDSL, 3}, + {"mixed-result-streaming-interceptors", testdata.MixedResultStreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload", testdata.StreamingInterceptorsWithReadPayloadDSL, 2}, {"streaming-interceptors-with-read-result", testdata.StreamingInterceptorsWithReadResultDSL, 2}, } @@ -54,6 +60,35 @@ func TestInterceptors(t *testing.T) { require.Len(t, fs, c.expectedFileCount) for _, f := range fs { + base := filepath.Base(f.Path) + if c.Name == "interceptor-with-external-read-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "interceptor-with-external-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "mixed-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "client_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } + if c.Name == "merged-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "service_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } buf := new(bytes.Buffer) for _, s := range f.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) diff --git a/codegen/service/jsonrpc_websocket_signature_test.go b/codegen/service/jsonrpc_websocket_signature_test.go deleted file mode 100644 index e5066af2d5..0000000000 --- a/codegen/service/jsonrpc_websocket_signature_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// This file checks where generated JSON-RPC WebSocket methods receive their -// request values. -package service - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/dsl" -) - -// TestJSONRPCWebSocketBidirectionalPayloadIsStreamOwned checks that a method -// which receives many values reads them with Recv. A method which receives one -// value gets it as its first argument. -func TestJSONRPCWebSocketBidirectionalPayloadIsStreamOwned(t *testing.T) { - root := codegen.RunDSL(t, func() { - dsl.Service("socket", func() { - dsl.JSONRPC(func() { - dsl.Path("/stream") - }) - dsl.Method("bidi", func() { - dsl.StreamingPayload(func() { - dsl.Attribute("value", dsl.String) - }) - dsl.StreamingResult(dsl.String) - dsl.JSONRPC(func() {}) - }) - dsl.Method("server", func() { - dsl.Payload(func() { - dsl.Attribute("value", dsl.String) - }) - dsl.StreamingResult(dsl.String) - dsl.JSONRPC(func() {}) - }) - }) - }) - plan := mustServicePlan(t, root) - facts := plan.facts.services[0] - - serviceCode := renderSignatureFile(t, serviceFiles(plan, facts)[0]) - require.Contains(t, serviceCode, "Bidi(context.Context, BidiServerStream) (err error)") - require.Contains(t, serviceCode, "Server(context.Context, *ServerPayload, ServerServerStream) (err error)") - - endpointCode := renderSignatureFile(t, endpointFile(plan, facts)) - require.Contains(t, endpointCode, "return nil, s.Bidi(ctx, ep.Stream)") - require.Contains(t, endpointCode, "return nil, s.Server(ctx, ep.Payload, ep.Stream)") -} - -// renderSignatureFile returns the Go source produced for one file. -func renderSignatureFile(t *testing.T, file *codegen.File) string { - t.Helper() - var source strings.Builder - for _, section := range file.SectionTemplates { - require.NoError(t, section.Write(&source)) - } - return source.String() -} diff --git a/codegen/service/method_data.go b/codegen/service/method_data.go index 48fff66ad7..0ad274d791 100644 --- a/codegen/service/method_data.go +++ b/codegen/service/method_data.go @@ -1,4 +1,5 @@ -// This file formats retained service method and stream facts for service and transport templates. +// This file builds the template data for service methods and their streams from +// the values and Go declarations recorded during planning. package service import ( @@ -8,9 +9,9 @@ import ( "goa.design/goa/v3/expr" ) -// buildMethodData creates the data needed to render the given endpoint. It -// records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declarationResolver, serviceFacts *serviceFacts) (*MethodData, error) { +// buildMethodData formats one method using the names and types chosen during +// service planning. +func buildMethodData(facts *methodFacts, resolver *declarationResolver, serviceFacts *serviceFacts) *MethodData { var ( vname string desc string @@ -28,7 +29,6 @@ func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declaration resultEx any errors []*ErrorInitData errorLocs map[string]*codegen.Location - isJSONRPC bool reqs = facts.requirements schemes = facts.schemes ) @@ -65,8 +65,6 @@ func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declaration errorLocs[errorFacts.name] = errorFacts.location } } - isJSONRPC = facts.isJSONRPC - data := &MethodData{ EndpointDeclaration: serviceFacts.names.declaration(serviceSymbolID{ role: serviceMethodEndpointNameRole, service: serviceFacts.name, method: facts.varName, @@ -80,9 +78,6 @@ func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declaration ClientStreamDeclaration: serviceFacts.names[serviceSymbolID{ role: serviceClientStreamNameRole, service: serviceFacts.name, method: facts.varName, }].declaration, - EventDeclaration: serviceFacts.names[serviceSymbolID{ - role: serviceMethodEventNameRole, service: serviceFacts.name, method: facts.varName, - }].declaration, RequestDeclaration: serviceFacts.names[serviceSymbolID{ role: serviceRequestNameRole, service: serviceFacts.name, method: facts.varName, }].declaration, @@ -116,9 +111,6 @@ func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declaration ResultEx: resultEx, Errors: errors, ErrorLocs: errorLocs, - IsJSONRPC: isJSONRPC, - IsJSONRPCSSE: facts.isJSONRPCSSE, - IsJSONRPCWebSocket: facts.isJSONRPCWebSocket, Requirements: reqs, Schemes: schemes, StreamKind: facts.streamKind, @@ -131,16 +123,14 @@ func (d *ServicesData) buildMethodData(facts *methodFacts, resolver *declaration StreamEndpointField: facts.streamEndpointField, } - if err := d.initStreamData(data, facts, vname, rname, resultRef, resolver); err != nil { - return nil, err - } - return data, nil + initStreamData(data, facts, resolver) + return data } // initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vname, rname, resultRef string, resolver *declarationResolver) error { +func initStreamData(data *MethodData, facts *methodFacts, resolver *declarationResolver) { if !facts.isStreaming && !facts.hasMixedResults { - return nil + return } var ( spayloadName string @@ -148,15 +138,20 @@ func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vnam spayloadDef string spayloadDesc string spayloadEx any - srname = rname // streaming result name - srref = resultRef // streaming result ref + srname string + srref string + srdef string ) + if facts.streamingResult != nil && facts.streamingResult.present { + srname, srdef, srref = retainedMethodTypeData(facts.streamingResult, resolver) + } + data.StreamingResultRef = srref - // If StreamingResult is different from Result, use it for streaming + // Mixed-result methods return StreamingResult from their streaming endpoint + // and Result from their ordinary endpoint. if facts.hasMixedResults && facts.streamingResult != nil && facts.streamingResult.present { - srname, data.StreamingResultDef, srref = retainedMethodTypeData(facts.streamingResult, resolver) data.StreamingResult = srname - data.StreamingResultRef = srref + data.StreamingResultDef = srdef data.StreamingResultDeclaration = facts.streamingResult.layout.TypeDeclaration() data.StreamingResultDesc = facts.streamingResult.description if data.StreamingResultDesc == "" { @@ -176,17 +171,15 @@ func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vnam } spayloadEx = facts.streamingPayload.example } - // For JSON-RPC WebSocket: - // - Client streaming (no result streaming): no endpoint struct needed, just payload - // - Bidirectional streaming: endpoint struct needed for both payload and stream + // Streaming endpoint calls carry the request value and stream together. var endpointStruct string if data.EndpointInputDeclaration != nil { endpointStruct = data.EndpointInputDeclaration.Name() } - // For mixed results with SSE, treat as server streaming + // A mixed-result SSE method sends results from the server even though its + // service method is not otherwise marked as streaming. streamKind := facts.streamKind if facts.hasMixedResults && !facts.isStreaming { - // Mixed results with SSE should be treated as server streaming streamKind = expr.ServerStreamKind } svrStream := &StreamData{ @@ -213,16 +206,6 @@ func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vnam RecvTypeName: srname, RecvTypeRef: srref, } - // For SSE server streaming, we need both Send (for notifications) and SendAndClose (for final response) - if data.IsJSONRPCSSE && facts.streamKind == expr.ServerStreamKind && resultRef != "" { - svrStream.SendAndCloseName = "SendAndClose" - svrStream.SendAndCloseDesc = fmt.Sprintf("SendAndClose sends a final response with %q and closes the stream.", srname) - // For JSON-RPC SSE, methods take context directly; align names accordingly - svrStream.SendWithContextName = "Send" - svrStream.RecvWithContextName = "Recv" - // Update Send description to clarify it's for notifications only - svrStream.SendDesc = fmt.Sprintf("Send streams JSON-RPC notifications with %q. Notifications do not expect a response.", srname) - } if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { switch streamKind { case expr.ClientStreamKind: @@ -262,16 +245,16 @@ func (d *ServicesData) initStreamData(data *MethodData, facts *methodFacts, vnam data.StreamingPayloadRef = spayloadRef data.StreamingPayloadDesc = spayloadDesc data.StreamingPayloadEx = spayloadEx - return nil } -// retainedMethodTypeData formats one preplanned method type relative to the -// service output package without consulting its source expression. +// This helper returns the Go name, definition, and reference for one payload or +// result relative to the service output package. It reads the type layout +// copied during planning instead of rereading the design expression. func retainedMethodTypeData(facts *methodAttributeFacts, resolver *declarationResolver) (string, string, string) { - linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) definition := "" if facts.definition != nil { - definition = facts.definition.Link(facts.layout.Owner(), retainedTypeQualifier(resolver.aliases)).Def() + definition = facts.definition.Link(facts.layout.Owner(), retainedTypeQualifier(resolver.aliases, facts.layout.Owner())).Def() } return linked.Name(), definition, linked.Ref() } diff --git a/codegen/service/method_package_imports_test.go b/codegen/service/method_package_imports_test.go new file mode 100644 index 0000000000..221127595b --- /dev/null +++ b/codegen/service/method_package_imports_test.go @@ -0,0 +1,47 @@ +// This file verifies transport generators can retain the exact generated +// service and views package preferences chosen by a service plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages catches HTTP +// generators guessing a service or views package from their own output path. +func TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.storage.item", func() { + dsl.TypeName("Item") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Storage", func() { + dsl.Method("Show", func() { + dsl.Result(result) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + servicePackage, viewsPackage, err := plan.MethodPackageImports(root.Service("Storage").Method("Show")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) + + servicePackage, viewsPackage, err = plan.ServicePackageImports(root.Service("Storage")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) +} diff --git a/codegen/service/method_payload_layout_test.go b/codegen/service/method_payload_layout_test.go new file mode 100644 index 0000000000..40885c210e --- /dev/null +++ b/codegen/service/method_payload_layout_test.go @@ -0,0 +1,37 @@ +// This file verifies other generators can read the exact Go fields already +// chosen for a service method payload. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestMethodPayloadLayoutReturnsAssignedFieldNames verifies field metadata is +// applied once by the service planner and exposed without rebuilding the name. +func TestMethodPayloadLayoutReturnsAssignedFieldNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + query := dsl.Type("ReadQuery", func() { + dsl.Attribute("cursor", dsl.String, func() { + dsl.Meta("struct:field:name", "OriginalCursor") + }) + }) + dsl.Service("Documents", func() { + dsl.Method("Read", func() { + dsl.Payload(query) + }) + }) + }) + plan := mustServicePlan(t, root) + + layout, err := plan.MethodPayloadLayout(root.Service("Documents").Method("Read")) + require.NoError(t, err) + require.Equal(t, codegen.GoStruct, layout.Kind()) + require.Len(t, layout.Fields(), 1) + require.Equal(t, "OriginalCursor", layout.Fields()[0].FieldName(true)) + require.True(t, layout.Fields()[0].IsPointer()) +} diff --git a/codegen/service/plan.go b/codegen/service/plan.go index aaca38a8d9..72d7d95cdd 100644 --- a/codegen/service/plan.go +++ b/codegen/service/plan.go @@ -1,11 +1,9 @@ -// This file owns the service analysis retained by one generation run. It -// collects declarations before names freeze, links those exact records into -// immutable template data afterward, and exposes that data to service and -// transport renderers without rebuilding the expression graph. +// This file stores the service declarations and file data selected for one +// generation run. File writers use this stored data without rereading the +// design. package service import ( - "fmt" "path" "strings" @@ -14,31 +12,32 @@ import ( ) type ( - // PlanInput supplies one evaluated Goa root and the example generator whose - // stable identities belong to that root. + // PlanInput supplies one evaluated Goa design and the generator used to make + // examples for types in that design. PlanInput struct { - // Root is one service design root owned by the generation. + // Root is one evaluated Goa design included in this generation command. Root *expr.RootExpr - // Examples produces the examples retained in that root's render plan. + // Examples produces the values written as examples for Root. Examples *expr.ExampleGenerator } - // Plan retains one design root's service declarations and linked render - // model from collection through generated file rendering. + // Plan stores one design's services, generated Go declarations, and template + // data from name collection through file rendering. Plan struct { generation *codegen.Generation facts *rootFacts services *ServicesData } - // rootFacts retains the exact service membership selected during collection. - // Expression nodes are immutable after generator preparation; copying the - // containing slices prevents linking from rediscovering later membership. + // rootFacts stores the services and API values selected from one design. + // Copying its slices prevents later steps from walking the design again and + // finding a different set of services. rootFacts struct { root *expr.RootExpr apiName string apiVersion string examplePackageName string + exampleImports []*codegen.ImportSpec services []*serviceFacts serviceByID map[string]*serviceFacts types []expr.UserType @@ -49,13 +48,16 @@ type ( generatedUnions []*generatedUnionEmissionFacts } - // serviceFacts retains the exact service inputs selected during collection. + // serviceFacts stores the service values needed to name and write its files. serviceFacts struct { service *expr.ServiceExpr + apiName string name string description string packagePath string viewsPath string + packageImport *codegen.ImportSpec + viewsImport *codegen.ImportSpec methods []*expr.MethodExpr orderedMethods []*methodFacts methodByExpr map[*expr.MethodExpr]*methodFacts @@ -86,7 +88,7 @@ type ( data *Data } - // methodFacts retains transport decisions that belong to one service method. + // methodFacts stores the method values used by service and transport files. methodFacts struct { method *expr.MethodExpr serviceName string @@ -103,7 +105,6 @@ type ( streamKind expr.StreamKind isStreaming bool hasMixedResults bool - isJSONRPC bool varName string serverStreamVarName string clientStreamVarName string @@ -111,14 +112,12 @@ type ( streamEndpointField string viewedResult *viewedResultFacts projection *projectionFacts - isJSONRPCSSE bool - isJSONRPCWebSocket bool skipRequestBodyEncodeDecode bool skipResponseBodyEncodeDecode bool } - // methodAttributeFacts retains one method value's top-level contract and - // example while its nested Go layout is owned by codegen.GoTypePlan. + // methodAttributeFacts stores one payload or result description, default, and + // example. GoTypePlan separately stores the Go fields nested inside it. methodAttributeFacts struct { attribute *expr.AttributeExpr layout *codegen.GoTypePlan @@ -132,8 +131,8 @@ type ( example any } - // errorRenderFacts retains the exact error behavior and type selected for - // service, client, and endpoint output. + // errorRenderFacts stores the error type, description, and marker fields + // written to service, client, and endpoint files. errorRenderFacts struct { attribute *expr.AttributeExpr layout *codegen.GoTypePlan @@ -146,8 +145,8 @@ type ( serviceType bool } - // interceptorFacts retains the exact methods to which one interceptor - // applies on one side of the service boundary. + // interceptorFacts stores the methods that call one interceptor on either the + // client or server side. interceptorFacts struct { name string description string @@ -170,23 +169,25 @@ type ( methods []*methodFacts } - // interceptorAccessFacts retains one generated accessor field and its exact - // type layout from the first method to which the interceptor applies. + // interceptorAccessFacts stores one field exposed to an interceptor and the + // Go type written for that field. interceptorAccessFacts struct { - name string - pointer bool - layout *codegen.GoTypePlan + attribute *expr.AttributeExpr + name string + pointer bool + layout *codegen.GoTypePlan } - // projectionFacts owns the single projected graph built for one method. - // Planning declares names from this graph; linking formats the same nodes. + // projectionFacts stores copies of one method's result types containing only + // the fields in each selected view. Name collection and template data both + // read these same copies. projectionFacts struct { pairs []*projectedTypePair types []*projectedTypeFacts } - // projectedTypeFacts retains one projected declaration graph and the exact - // validation and conversion operations selected from it. + // projectedTypeFacts stores one result type containing the fields selected by + // a view, together with the validation and conversion code generated for it. projectedTypeFacts struct { pair *projectedTypePair projectedType expr.UserType @@ -201,22 +202,23 @@ type ( declaration *codegen.TypeDeclaration } - // viewRenderFacts retains the authored view text and ordered field names - // used by service and views templates. + // viewRenderFacts stores the description and ordered field names from one + // declared result view for the service and views templates. viewRenderFacts struct { name string description string attributes []string } - // validationFacts retains one projected validator's selected fields and - // nested validator calls without resolving function names. + // validationFacts stores the field checks and child validation calls emitted + // by one view-specific validation function. Function names are added later. validationFacts struct { viewName string attribute *expr.AttributeExpr layout *codegen.GoTypePlan plan *codegen.ValidationPlan declaration *codegen.NameDeclaration + needed bool alias bool pointer bool collectionElem *expr.AttributeExpr @@ -224,7 +226,8 @@ type ( fields []*validationFieldFacts } - // validationFieldFacts retains one nested result-type field call. + // validationFieldFacts stores one child result field and the validation call + // emitted for it. validationFieldFacts struct { name string attribute *expr.AttributeExpr @@ -233,8 +236,15 @@ type ( call *codegen.NameDeclaration } - // viewConversionFacts retains one view-narrowed conversion and its exact - // recursive transform plan. + // viewValidationKey identifies one result type and view while Goa decides + // whether its generated validation function can return an error. + viewValidationKey struct { + origin expr.UserType + view string + } + + // viewConversionFacts stores one conversion between a service result and a + // selected result view, including conversions for nested fields. viewConversionFacts struct { toResult bool viewName string @@ -253,8 +263,8 @@ type ( elementCall *codegen.NameDeclaration } - // viewConversionFieldFacts retains one nested result constructor call that - // is emitted outside the general type transform. + // viewConversionFieldFacts stores one child result constructor call emitted + // separately from the general type conversion. viewConversionFieldFacts struct { name string attribute *expr.AttributeExpr @@ -262,8 +272,8 @@ type ( call *codegen.NameDeclaration } - // viewedResultFacts retains the wrapper type and selected view behavior for - // one method result. + // viewedResultFacts stores the wrapper type and selected view written for one + // method result. viewedResultFacts struct { wrapped expr.UserType wrappedLayout *codegen.GoTypePlan @@ -283,8 +293,8 @@ type ( isCollection bool } - // userTypeFacts binds one selected expression type to the exact package - // declaration and inherited output location chosen during collection. + // userTypeFacts records one selected design type, its generated Go type, and + // the file location inherited from an enclosing type when needed. userTypeFacts struct { userType expr.UserType name string @@ -294,11 +304,10 @@ type ( location *codegen.Location declaration *codegen.TypeDeclaration layout *codegen.GoTypePlan - reference *codegen.GoTypePlan imports retainedFileImports } - // unionFacts binds one selected sum type to its exact package declaration. + // unionFacts records one Goa OneOf type and its generated Go declaration. unionFacts struct { union *expr.Union identity codegen.UnionTypeID @@ -311,8 +320,8 @@ type ( data *UnionTypeData } - // unionBranchFacts retains one emitted union branch and its exact generated - // declaration and Go layout. + // unionBranchFacts stores one Goa OneOf branch, its generated names, and the + // Go type written for its value. unionBranchFacts struct { name string fieldName string @@ -323,8 +332,9 @@ type ( primitiveAliasType string } - // validatorKey identifies the exact generated type and result view whose - // validation function is called by projected validation code. + // validatorKey selects the validation function for one generated result type + // and view. Validation code for view-specific result copies uses that exact + // function. validatorKey struct { declaration *codegen.TypeDeclaration view string @@ -338,20 +348,22 @@ type ( toResult bool } - // streamWrapperKey identifies one side of a retained method stream. + // streamWrapperKey identifies the client or server wrapper for one method's + // stream. streamWrapperKey struct { method *expr.MethodExpr server bool } ) -// collectServiceNames declares every package-level symbol emitted for one -// core service and its views package before generation names freeze. +// collectServiceNames submits every package-level Go declaration written for +// one service and its views before Generation.Freeze chooses the final Go +// names. func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { service := facts.service serviceName := service.Name - servicePackage := generation.Package(servicePackagePath(generation.GenPkg(), service)) - viewsPackage := generation.Package(servicePackagePath(generation.GenPkg(), service) + "/views") + servicePackage := generation.Package(facts.packagePath) + viewsPackage := generation.Package(facts.viewsPath) examplePackage, err := generation.ClaimOutputPackage(path.Dir(generation.GenPkg()), ".") if err != nil { return err @@ -369,7 +381,7 @@ func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation declare := func(pkg *codegen.GeneratedPackage, role serviceNameRole, preferred string, id serviceSymbolID) error { id.role = role id.service = serviceName - _, err := facts.names.declare(pkg, id, preferred) + _, err := facts.names.declareForAPI(pkg, id, preferred, facts.apiName) return err } static := []struct { @@ -390,64 +402,54 @@ func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation static = append(static, struct { role serviceNameRole preferred string - }{serviceAutherNameRole, "Auther"}) + }{serviceAutherNameRole, "Auther"}) //nolint:misspell // Keep Goa's existing generated interface name. } for _, symbol := range static { if err := declare(servicePackage, symbol.role, symbol.preferred, serviceSymbolID{}); err != nil { return err } } - facts.exampleStruct, err = facts.names.declare(examplePackage, serviceSymbolID{ + facts.exampleStruct, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ role: serviceExampleStructNameRole, service: serviceName, - }, codegen.Goify(serviceName, false)+"srvc") + }, codegen.Goify(serviceName, false)+"srvc", facts.apiName) if err != nil { return err } - facts.exampleConstructor, err = facts.names.declare(examplePackage, serviceSymbolID{ + facts.exampleConstructor, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ role: serviceExampleConstructorNameRole, service: serviceName, - }, "New"+codegen.Goify(serviceName, true)) + }, "New"+codegen.Goify(serviceName, true), facts.apiName) if err != nil { return err } structName := codegen.Goify(serviceName, true) if len(facts.serverInterceptors) > 0 { - facts.exampleServerStruct, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + facts.exampleServerStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ role: serviceExampleServerInterceptorsStructNameRole, service: serviceName, - }, structName+"ServerInterceptors") + }, structName+"ServerInterceptors", facts.apiName) if err != nil { return err } - facts.exampleServerConstructor, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + facts.exampleServerConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ role: serviceExampleServerInterceptorsConstructorNameRole, service: serviceName, - }, "New"+structName+"ServerInterceptors") + }, "New"+structName+"ServerInterceptors", facts.apiName) if err != nil { return err } } if len(facts.clientInterceptors) > 0 { - facts.exampleClientStruct, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + facts.exampleClientStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ role: serviceExampleClientInterceptorsStructNameRole, service: serviceName, - }, structName+"ClientInterceptors") + }, structName+"ClientInterceptors", facts.apiName) if err != nil { return err } - facts.exampleClientConstructor, err = facts.names.declare(exampleInterceptorsPackage, serviceSymbolID{ + facts.exampleClientConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ role: serviceExampleClientInterceptorsConstructorNameRole, service: serviceName, - }, "New"+structName+"ClientInterceptors") + }, "New"+structName+"ClientInterceptors", facts.apiName) if err != nil { return err } } - if hasRetainedJSONRPCStreaming(facts) { - if err := declare(servicePackage, serviceStreamNameRole, "Stream", serviceSymbolID{}); err != nil { - return err - } - if hasRetainedJSONRPCSSEResults(facts) { - if err := declare(servicePackage, serviceEventNameRole, "Event", serviceSymbolID{}); err != nil { - return err - } - } - } for _, method := range facts.methods { methodFacts := facts.methodByExpr[method] methodID := serviceSymbolID{method: methodFacts.varName} @@ -458,14 +460,7 @@ func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation if err := declare(servicePackage, serviceClientStreamNameRole, methodFacts.varName+"ClientStream", methodID); err != nil { return err } - if !methodFacts.isJSONRPCWebSocket || method.Stream != expr.ClientStreamKind { - if err := declare(servicePackage, serviceEndpointInputNameRole, methodFacts.varName+"EndpointInput", methodID); err != nil { - return err - } - } - } - if methodFacts.isJSONRPCSSE { - if err := declare(servicePackage, serviceMethodEventNameRole, methodFacts.varName+"Event", methodID); err != nil { + if err := declare(servicePackage, serviceEndpointInputNameRole, methodFacts.varName+"EndpointInput", methodID); err != nil { return err } } @@ -502,18 +497,7 @@ func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation return collectViewNames(facts, servicePackage, viewsPackage, rootTypes, generation) } -// hasRetainedJSONRPCSSEResults reports whether the SSE service template emits -// its package-level Event interface for at least one concrete result. -func hasRetainedJSONRPCSSEResults(facts *serviceFacts) bool { - for method, retained := range facts.methodByExpr { - if retained.isJSONRPCSSE && method.Result.Type != expr.Empty { - return true - } - } - return false -} - -// serviceHasSchemes reports whether any retained method requires generated +// serviceHasSchemes reports whether any selected method needs generated // authorization functions. func serviceHasSchemes(facts *serviceFacts) bool { for _, method := range facts.methods { @@ -533,18 +517,18 @@ func collectErrorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPac errors = append(errors, method.Errors...) } for _, serviceError := range errors { - if serviceError.Type != expr.ErrorResult { + if !expr.IsErrorResult(serviceError.Type) { continue } if _, exists := seen[serviceError.Name]; exists { continue } seen[serviceError.Name] = struct{}{} - declaration, err := facts.names.declare(servicePackage, serviceSymbolID{ + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ role: serviceErrorConstructorNameRole, service: facts.service.Name, subject: serviceError.Name, - }, "Make"+codegen.Goify(serviceError.Name, true)) + }, "Make"+codegen.Goify(serviceError.Name, true), facts.apiName) if err != nil { return err } @@ -559,7 +543,7 @@ func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.Genera declare := func(role serviceNameRole, preferred string, id serviceSymbolID) error { id.role = role id.service = facts.service.Name - _, err := facts.names.declare(servicePackage, id, preferred) + _, err := facts.names.declareForAPI(servicePackage, id, preferred, facts.apiName) return err } if len(facts.serverInterceptors) > 0 { @@ -605,17 +589,26 @@ func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.Genera continue } methodName := facts.methodByExpr[method].varName - base := codegen.Goify(interceptor.Name, false) + methodName + base := codegen.Goify(codegen.SnakeCase(interceptor.Name), false) + methodName methodID := serviceSymbolID{method: facts.methodByExpr[method].varName, subject: interceptor.Name} + streamingAccess := interceptorHasStreamingAccess(interceptor) && (method.IsStreaming() || method.HasMixedResults()) + hasPayloadAccess := interceptor.ReadPayload != nil || interceptor.WritePayload != nil + hasStreamingPayloadAccess := interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil + hasStreamingResultAccess := interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil for _, symbol := range []struct { role serviceNameRole suffix string emit bool }{ - {serviceInterceptorPayloadAccessNameRole, "Payload", interceptor.ReadPayload != nil || interceptor.WritePayload != nil}, + {serviceInterceptorPayloadAccessNameRole, "Payload", hasPayloadAccess}, {serviceInterceptorResultAccessNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, - {serviceInterceptorStreamingPayloadAccessNameRole, "StreamingPayload", interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil}, - {serviceInterceptorStreamingResultAccessNameRole, "StreamingResult", interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil}, + {serviceInterceptorStreamingPayloadAccessNameRole, "StreamingPayload", hasStreamingPayloadAccess}, + {serviceInterceptorStreamingResultAccessNameRole, "StreamingResult", hasStreamingResultAccess}, + {serviceInterceptorMethodInfoNameRole, "Info", true}, + {serviceInterceptorServerUnaryInfoNameRole, "ServerUnaryInfo", server && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorClientUnaryInfoNameRole, "ClientUnaryInfo", client && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorStreamingSendInfoNameRole, "StreamingSendInfo", streamingAccess && (server && hasStreamingResultAccess || client && hasStreamingPayloadAccess)}, + {serviceInterceptorStreamingRecvInfoNameRole, "StreamingRecvInfo", streamingAccess && (server && hasStreamingPayloadAccess || client && hasStreamingResultAccess)}, } { if !symbol.emit { continue @@ -662,6 +655,7 @@ func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.Genera // collectViewNames declares validators and constructor/map companions from // the exact service and view type declarations allocated during view planning. func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen.GeneratedPackage, rootTypes *rootTypeSet, generation *codegen.Generation) error { + markNeededViewValidators(facts) for _, method := range facts.methods { projection := facts.projections[method] if projection == nil { @@ -674,15 +668,11 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen return err } projectedFacts.declaration = declaration - views := []string{""} - if resultType, ok := pair.projected.(*expr.ResultTypeExpr); ok { - views = views[:0] - for _, view := range resultType.Views { - views = append(views, view.Name) + for _, validation := range projectedFacts.validations { + if !validation.needed { + continue } - } - for _, view := range views { - view = canonicalValidatorView(view) + view := canonicalValidatorView(validation.viewName) suffix := "" if view != "" { suffix = codegen.Goify(view, true) @@ -699,25 +689,20 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen view: view, side: "projected", } - validator, err := facts.names.declareDependent(viewsPackage, id, declaration.Declaration(), "Validate", suffix) + validator, err := facts.names.declareDependentForAPI(viewsPackage, id, declaration.Declaration(), "Validate", suffix, facts.apiName) if err != nil { return err } facts.validators[key] = validator - for _, validation := range projectedFacts.validations { - if canonicalValidatorView(validation.viewName) == view { - validation.declaration = validator - break - } - } + validation.declaration = validator } if _, ok := pair.projected.(*expr.ResultTypeExpr); ok { - projectedFacts.mapDeclaration, err = facts.names.declare(viewsPackage, serviceSymbolID{ + projectedFacts.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ role: serviceViewMapNameRole, service: facts.service.Name, subject: pair.source.ID(), source: pair.source.Name(), - }, codegen.Goify(pair.source.Name(), true)+"Map") + }, codegen.Goify(pair.source.Name(), true)+"Map", facts.apiName) if err != nil { return err } @@ -733,14 +718,14 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen if conversion.viewName != expr.DefaultView { suffix = codegen.Goify(conversion.viewName, true) } - conversion.constructor, err = facts.names.declare(servicePackage, serviceSymbolID{ + conversion.constructor, err = facts.names.declareForAPI(servicePackage, serviceSymbolID{ role: servicePrivateProjectionConstructorNameRole, service: facts.service.Name, subject: pair.source.ID(), source: pair.source.Name(), view: canonicalValidatorView(conversion.viewName), side: side, - }, "new"+preferredBase+suffix) + }, "new"+preferredBase+suffix, facts.apiName) if err != nil { return err } @@ -758,7 +743,7 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen } else { targetPreferred = viewsPackageName + codegen.Goify(targetName, true) } - declaration, err := facts.names.declare(servicePackage, serviceSymbolID{ + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ role: serviceTransformHelperNameRole, service: facts.service.Name, subject: pair.source.ID(), @@ -768,7 +753,7 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen side: side, occurrence: helper.Occurrence, required: helper.Required, - }, "transform"+codegen.Goify(sourcePreferred, true)+"To"+codegen.Goify(targetPreferred, true)) + }, "transform"+codegen.Goify(sourcePreferred, true)+"To"+codegen.Goify(targetPreferred, true), facts.apiName) if err != nil { return err } @@ -803,7 +788,7 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen source: resultType.Name(), side: "viewed", } - validator, err := facts.names.declareDependent(viewsPackage, validatorID, viewedDeclaration.Declaration(), "Validate", "") + validator, err := facts.names.declareDependentForAPI(viewsPackage, validatorID, viewedDeclaration.Declaration(), "Validate", "", facts.apiName) if err != nil { return err } @@ -818,13 +803,13 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen {serviceViewConstructorNameRole, "NewViewed", "to-viewed"}, {serviceViewConstructorNameRole, "New", "to-result"}, } { - constructor, err := facts.names.declare(servicePackage, serviceSymbolID{ + constructor, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ role: symbol.role, service: facts.service.Name, subject: resultType.ID(), source: resultType.Name(), side: symbol.side, - }, symbol.prefix+codegen.Goify(resultType.Name(), true)) + }, symbol.prefix+codegen.Goify(resultType.Name(), true), facts.apiName) if err != nil { return err } @@ -834,12 +819,12 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen facts.methodByExpr[method].viewedResult.toResult = constructor } } - facts.methodByExpr[method].viewedResult.mapDeclaration, err = facts.names.declare(viewsPackage, serviceSymbolID{ + facts.methodByExpr[method].viewedResult.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ role: serviceViewMapNameRole, service: facts.service.Name, subject: resultType.ID(), source: resultType.Name(), - }, codegen.Goify(resultType.Name(), true)+"Map") + }, codegen.Goify(resultType.Name(), true)+"Map", facts.apiName) if err != nil { return err } @@ -849,9 +834,6 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen declaration: viewedFacts.projected.declaration, view: canonicalValidatorView(view.name), }] - if declaration == nil { - return fmt.Errorf("validator for viewed result %q view %q was not declared", resultType.Name(), view.name) - } viewedFacts.validationCalls = append(viewedFacts.validationCalls, declaration) } } @@ -859,8 +841,8 @@ func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen return planServiceValidations(facts, rootTypes, generation) } -// linkViewConversionCalls binds collection and nested constructor calls to the -// exact retained function records selected for their projected type and view. +// linkViewConversionCalls gives collection and child constructor calls the Go +// function names chosen for their result type and view. func linkViewConversionCalls(facts *serviceFacts) { lookup := make(map[viewConversionCallKey]*codegen.NameDeclaration) for _, method := range facts.methods { @@ -921,17 +903,6 @@ func interceptorHasStreamingAccess(interceptor *expr.InterceptorExpr) bool { interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil } -// hasRetainedJSONRPCStreaming reports whether the retained methods emit the -// package-level JSON-RPC Stream declaration. -func hasRetainedJSONRPCStreaming(facts *serviceFacts) bool { - for _, method := range facts.methods { - if _, jsonRPC := method.Meta["jsonrpc"]; jsonRPC && (method.IsStreaming() || method.HasMixedResults()) { - return true - } - } - return false -} - // interceptorNamed reports whether interceptors contains name. func interceptorNamed(interceptors []*expr.InterceptorExpr, name string) bool { for _, interceptor := range interceptors { diff --git a/codegen/service/plan_lifecycle.go b/codegen/service/plan_lifecycle.go index 7f9fc6132d..b1d80dc791 100644 --- a/codegen/service/plan_lifecycle.go +++ b/codegen/service/plan_lifecycle.go @@ -4,6 +4,7 @@ package service import ( "fmt" + "path" "strings" "goa.design/goa/v3/codegen" @@ -49,9 +50,13 @@ func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, err len(inputs), ) } + servicePaths, err := allocateServicePackagePaths(generation.GenPkg(), inputs) + if err != nil { + return nil, err + } plans := make([]*Plan, len(inputs)) for index, input := range inputs { - facts, err := collectRootFacts(input.Root, generation, input.Examples) + facts, err := collectRootFacts(input.Root, generation, input.Examples, servicePaths) if err != nil { return nil, err } @@ -86,6 +91,17 @@ func (p *Plan) Root() *expr.RootExpr { return p.facts.root } +// ExampleImports returns copies of the application and interceptor imports +// selected while this service plan was created. +func (p *Plan) ExampleImports() []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(p.facts.exampleImports)) + for index, spec := range p.facts.exampleImports { + copy := *spec + imports[index] = © + } + return imports +} + // ProjectedResult returns a copy of the result fields included in the views for // method. It reports an error when method is absent or has no views. func (p *Plan) ProjectedResult(method *expr.MethodExpr) (*expr.AttributeExpr, error) { @@ -126,9 +142,103 @@ func (p *Plan) HTTPMethodNames(method *expr.MethodExpr) (HTTPMethodNames, error) return HTTPMethodNames{}, fmt.Errorf("service method %q is not part of this plan", method.Name) } +// MethodPayloadLayout returns the Go fields stored by method's payload. For a +// named payload, it returns the definition containing those fields instead of +// the outer reference to the named type. +func (p *Plan) MethodPayloadLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.payload == nil || facts.payload.layout == nil { + return nil, fmt.Errorf("service method %q does not have a payload", method.Name) + } + if facts.payload.definition != nil { + return facts.payload.definition, nil + } + return facts.payload.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// StreamingResultLayout returns the Go type layout used by method's stream. +// An explicitly empty streaming result uses the ordinary result layout, which +// is the type implemented by the generated client and server stream methods. +// Transport planners use this fact to decide whether their decoded value is +// directly assignable or needs a generated conversion. +func (p *Plan) StreamingResultLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.streamingResult != nil && facts.streamingResult.present { + return facts.streamingResult.layout, nil + } + if facts.result == nil || facts.result.layout == nil { + return nil, fmt.Errorf("service method %q does not have a streaming result", method.Name) + } + return facts.result.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// ServicePackageImports returns the generated service and views package +// preferences recorded before Generation.Freeze. Transport generators use it +// for service-level files that may not contain a method, such as an HTTP file +// server. +func (p *Plan) ServicePackageImports( + serviceExpression *expr.ServiceExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + if service.service != serviceExpression { + continue + } + serviceCopy := *service.packageImport + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if serviceExpression == nil { + return nil, nil, fmt.Errorf("service is not part of this plan") + } + return nil, nil, fmt.Errorf("service %q is not part of this plan", serviceExpression.Name) +} + +// MethodPackageImports returns the generated service package preference and, +// for a viewed result, its views package preference. These are the names and +// paths recorded before Generation.Freeze; an importing output package may +// receive a numbered qualifier when another import requests the same name. +func (p *Plan) MethodPackageImports( + method *expr.MethodExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + serviceCopy := *service.packageImport + if facts.viewedResult == nil { + return &serviceCopy, nil, nil + } + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if method == nil { + return nil, nil, fmt.Errorf("service method is not part of this plan") + } + return nil, nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + // collectRootFacts reads one service design and chooses names used only by that // design before shared files receive their names. -func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*rootFacts, error) { +func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator, servicePaths map[string]string) (*rootFacts, error) { examplePackageScope := codegen.NewNameScope() for _, service := range root.Services { examplePackageScope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) @@ -145,11 +255,27 @@ func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examp } for _, service := range root.Services { serviceFacts := collectServiceFacts(root, service, examples) - serviceFacts.packagePath = servicePackagePath(generation.GenPkg(), service) + serviceFacts.packagePath = servicePaths[service.Name] serviceFacts.viewsPath = serviceFacts.packagePath + "/views" + serviceFacts.packageImport = codegen.NewImport( + strings.ToLower(codegen.Goify(service.Name, false)), + serviceFacts.packagePath, + ) + serviceFacts.viewsImport = codegen.NewImport( + serviceFacts.packageImport.Name+"views", + serviceFacts.viewsPath, + ) facts.services = append(facts.services, serviceFacts) facts.serviceByID[service.Name] = serviceFacts } + rootPath := path.Dir(generation.GenPkg()) + facts.exampleImports = append(facts.exampleImports, codegen.NewImport(facts.examplePackageName, rootPath)) + for _, service := range facts.services { + if len(service.serverInterceptors) > 0 || len(service.clientInterceptors) > 0 { + facts.exampleImports = append(facts.exampleImports, codegen.NewImport("interceptors", rootPath+"/interceptors")) + break + } + } if err := collectServiceDeclarations(facts, generation); err != nil { return nil, err } diff --git a/codegen/service/render_name_compatibility_test.go b/codegen/service/render_name_compatibility_test.go new file mode 100644 index 0000000000..b3c4aaca6a --- /dev/null +++ b/codegen/service/render_name_compatibility_test.go @@ -0,0 +1,106 @@ +// This file verifies that plugins still receive the released Go-name strings +// while Goa templates use the matching planned declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestLinkedRenderNamesMatchDeclarations(t *testing.T) { + root := codegen.RunDSL(t, func() { + customError := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + dsl.Error("failed") + dsl.Error("custom", customError) + }) + }) + }) + + data := mustServicePlan(t, root).Services().Get("Values") + endpoints := endpointData(data) + require.Equal(t, endpoints.EndpointsDeclaration.Name(), endpoints.VarName) + require.Equal(t, endpoints.ClientDeclaration.Name(), endpoints.ClientVarName) + require.Equal(t, endpoints.ServiceDeclaration.Name(), endpoints.ServiceVarName) + for _, method := range endpoints.Methods { + require.Equal(t, method.ClientDeclaration.Name(), method.ClientVarName) + require.Equal(t, method.ServiceDeclaration.Name(), method.ServiceVarName) + } + require.Len(t, data.errorInits, 1) + require.Equal(t, "MakeFailed", data.errorInits[0].Name) + assertErrorInitName(t, data.errorInits[0]) + + method := data.Method("Read") + require.NotEmpty(t, data.ViewsPkg) + require.Len(t, method.Errors, 2) + for _, serviceError := range method.Errors { + switch serviceError.ErrName { + case "failed": + assertErrorInitName(t, serviceError) + case "custom": + require.Nil(t, serviceError.Declaration) + require.Empty(t, serviceError.Name) + default: + t.Errorf("unexpected service error %q", serviceError.ErrName) + } + } + require.NotNil(t, method.ViewedResult) + require.Equal(t, "NewViewedReading", method.ViewedResult.Init.Name) + require.Equal(t, "ValidateReading", method.ViewedResult.Validate.Name) + assertInitName(t, method.ViewedResult.Init) + assertInitName(t, method.ViewedResult.ResultInit) + assertValidateName(t, method.ViewedResult.Validate) + + for _, projected := range data.projectedTypes { + require.Equal(t, data.ViewsPkg, projected.ViewsPkg) + for _, init := range projected.Projections { + assertInitName(t, init) + } + for _, init := range projected.TypeInits { + assertInitName(t, init) + } + for _, validation := range projected.Validations { + assertValidateName(t, validation) + } + } +} + +// assertErrorInitName checks the compatibility name exposed to plugins. +func assertErrorInitName(t *testing.T, data *ErrorInitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertInitName checks the compatibility name exposed to plugins. +func assertInitName(t *testing.T, data *InitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertValidateName checks the compatibility name exposed to plugins. +func assertValidateName(t *testing.T, data *ValidateData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} diff --git a/codegen/service/retained_expression_mutation_contract_test.go b/codegen/service/retained_expression_mutation_contract_test.go index 4e527d6cd3..a61df98dd0 100644 --- a/codegen/service/retained_expression_mutation_contract_test.go +++ b/codegen/service/retained_expression_mutation_contract_test.go @@ -26,7 +26,7 @@ type retainedExpressionFixture struct { // security, example, or stream expressions after NewPlan returns. func TestServicePlanIgnoresRetainedExpressionMutation(t *testing.T) { baselineFixture := retainedExpressionMutationFixture(t) - baselinePlan := retainedServicePlanForPackage(t, baselineFixture.root, "generated.local/gen") + baselinePlan := retainedServicePlanForPackage(t, baselineFixture.root) baseline := renderedPlanAndExamples(t, baselinePlan) baselineMethod := baselinePlan.Services().Get("RetainedMutable").Methods[0] diff --git a/codegen/service/security_data.go b/codegen/service/security_data.go index 06e584c8fb..1ac7a9249d 100644 --- a/codegen/service/security_data.go +++ b/codegen/service/security_data.go @@ -64,8 +64,8 @@ func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { return data } -// schemeScopes returns the scope names defined by the scheme, nil when the -// scheme defines none. +// schemeScopes returns the authorization scope names defined by the scheme. It +// returns nil when the scheme defines none. func schemeScopes(s *expr.SchemeExpr) []string { if len(s.Scopes) == 0 { return nil diff --git a/codegen/service/service.go b/codegen/service/service.go index da4bd522cc..0f610cc6fe 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -1,5 +1,6 @@ -// This file renders service declarations and aggregates relocated declarations -// into the exact generated Go packages and files that own them. +// This file renders service declarations and groups declarations with explicit +// package locations into the generated Go package and file where each one is +// written. package service import ( @@ -15,7 +16,7 @@ import ( ) type ( - // serviceTypeSectionPhase identifies the stable group that owns a service + // serviceTypeSectionPhase identifies the stable group containing a service // type-file section. Type declarations must precede methods defined on them. serviceTypeSectionPhase uint8 @@ -34,8 +35,8 @@ const ( ) // Files renders every service file described by plans. Each plan must be -// linked so every renderer reads the exact declarations retained before names -// froze instead of rebuilding service analysis from the expression root. +// linked so every renderer reads the declarations copied before their names +// were chosen instead of rebuilding service analysis from the expression root. func Files(plans ...*Plan) ([]*codegen.File, error) { var files []*codegen.File if len(plans) == 0 { @@ -247,10 +248,7 @@ func serviceFiles(plan *Plan, facts *serviceFacts) []*codegen.File { Source: serviceTemplates.Read(serviceT), Data: svc, FuncMap: map[string]any{ - "hasJSONRPCStreaming": hasJSONRPCStreaming, - "isJSONRPCWebSocket": hasJSONRPCWebSocket, - "streamInterfaceFor": streamInterfaceFor, - "dedupeByResult": dedupeByResult, + "streamInterfaceFor": streamInterfaceFor, }, } @@ -266,8 +264,10 @@ func serviceFiles(plan *Plan, facts *serviceFacts) []*codegen.File { sections = append(sections, record.section) } sections = append(sections, svcSections...) - files := []*codegen.File{{Path: svcPath, SectionTemplates: sections}} - return append(files, interceptorsFiles(plan, facts)...) + interceptors := interceptorsFiles(plan, facts) + files := make([]*codegen.File, 1, 1+len(interceptors)) + files[0] = &codegen.File{Path: svcPath, SectionTemplates: sections} + return append(files, interceptors...) } // generatedPackageFiles renders each relocated user type in its configured @@ -291,7 +291,7 @@ func generatedPackageFiles(analyses []*ServicesData) ([]*codegen.File, error) { var files []*codegen.File for _, owner := range packageOwners { packagePath := owner.ImportPath() - packageName := codegen.Goify(path.Base(packagePath), false) + packageName := strings.ToLower(codegen.Goify(path.Base(packagePath), false)) generatedPackage := packages[owner] typesByFile := make(map[string][]*generatedTypeData) for _, generatedType := range generatedPackage.types { @@ -351,8 +351,9 @@ func generatedPackageFiles(analyses []*ServicesData) ([]*codegen.File, error) { return files, nil } -// aggregateGeneratedPackages selects one render section per canonical package -// declaration across all analyzed roots without mutating generation state. +// aggregateGeneratedPackages selects one render section for each generated +// package declaration across all analyzed roots without changing generation +// state. func aggregateGeneratedPackages(analyses []*ServicesData) (map[*codegen.GeneratedPackage]*generatedPackageData, error) { packages := make(map[*codegen.GeneratedPackage]*generatedPackageData) for _, services := range analyses { @@ -411,63 +412,13 @@ func appendImportSpecs(existing, added []*codegen.ImportSpec) []*codegen.ImportS return result } -// dedupeByResult returns a slice of methods where only a single representative -// per unique ResultRef is kept (first occurrence wins). Methods without a -// ResultRef are ignored. -func dedupeByResult(ms []*MethodData) []*MethodData { - seen := make(map[string]struct{}) - out := make([]*MethodData, 0, len(ms)) - for _, m := range ms { - key := m.Result - if key == "" { - key = m.StreamingResult - } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, m) - } - return out -} - -// hasJSONRPCStreaming returns true if the service has a JSON-RPC streaming -// endpoint (WebSocket or SSE). -func hasJSONRPCStreaming(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPC && m.ServerStream != nil { - return true - } - } - return false -} - -// hasJSONRPCWebSocket returns true if the service has a JSON-RPC streaming -// endpoint that uses the WebSocket transport. -func hasJSONRPCWebSocket(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPCWebSocket { - return true - } - } - return false -} - // streamInterfaceFor builds the data to generate the client and server stream // interfaces for the given endpoint. func streamInterfaceFor(typ string, m *MethodData, stream *StreamData) map[string]any { return map[string]any{ - "Type": typ, - "Endpoint": m.Name, - "Stream": stream, - "MethodVarName": m.VarName, - "EventDeclaration": m.EventDeclaration, - "IsJSONRPC": m.IsJSONRPC, - "IsJSONRPCSSE": m.IsJSONRPCSSE && typ == "server", - "IsJSONRPCWebSocket": m.IsJSONRPCWebSocket, + "Type": typ, + "Endpoint": m.Name, + "Stream": stream, // If a view is explicitly set (ViewName is not empty) in the Result // expression, we can use that view to render the result type instead // of iterating through the list of views defined in the result type. diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index edb24013fb..4141c02ceb 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -1,6 +1,6 @@ -// This file analyzes evaluated service designs into immutable render data. -// Public type declarations and references come from the frozen generated -// package catalog; mutable scopes are used only for private helper names. +// This file builds the values passed to service templates. Package-level Go +// names are shared by every file in the package, while each file may choose +// additional private helper names. package service import ( @@ -66,10 +66,6 @@ type ( ClientDeclaration *codegen.NameDeclaration // NewClientDeclaration is the exact client constructor record. NewClientDeclaration *codegen.NameDeclaration - // StreamDeclaration is the shared JSON-RPC stream record when emitted. - StreamDeclaration *codegen.NameDeclaration - // EventDeclaration is the shared JSON-RPC SSE event record when emitted. - EventDeclaration *codegen.NameDeclaration // ServerInterceptorsDeclaration is the server interceptor interface record. ServerInterceptorsDeclaration *codegen.NameDeclaration // ClientInterceptorsDeclaration is the client interceptor interface record. @@ -78,6 +74,10 @@ type ( ExampleStructDeclaration *codegen.NameDeclaration // ExampleConstructorDeclaration is the starter constructor record. ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleServerInterceptorsConstructorDeclaration creates the starter + // server interceptor implementation. It is nil when the service has no + // server interceptors. + ExampleServerInterceptorsConstructorDeclaration *codegen.NameDeclaration // Name is the service name. Name string // Description is the service description. @@ -88,13 +88,17 @@ type ( APIVersion string // StructName is the service struct name. StructName string - // VarName is the service variable name (first letter in lowercase). + // VarName is the local Go variable that holds the service implementation in + // generated starter programs. VarName string // PathName is the service name as used in file and import paths. PathName string // PkgName is the name of the package containing the generated service // code. PkgName string + // ViewsPkg is the final views package name kept for existing plugins. It + // is empty when the service does not generate a views package. + ViewsPkg string // Methods lists the service interface methods. Methods []*MethodData // Schemes is the list of security schemes required by the service methods. @@ -122,9 +126,9 @@ type ( // projectedTypes lists the types which uses pointers for all fields to // define view specific validation logic. projectedTypes []*ProjectedTypeData - // unions lists the sum-type unions defined for the service. + // unions lists the values that hold one selected branch for the service. unions []*UnionTypeData - // viewUnions lists the sum-type unions emitted by the views package. + // viewUnions lists the values that hold one selected branch in the views package. viewUnions []*UnionTypeData // viewedResultTypes lists all the viewed method result types. viewedResultTypes []*ViewedResultTypeData @@ -143,8 +147,6 @@ type ( ServerStreamDeclaration *codegen.NameDeclaration // ClientStreamDeclaration is the exact client stream interface record. ClientStreamDeclaration *codegen.NameDeclaration - // EventDeclaration is the exact JSON-RPC SSE event record. - EventDeclaration *codegen.NameDeclaration // RequestDeclaration is the exact JSON-RPC request data record. RequestDeclaration *codegen.NameDeclaration // ResponseDeclaration is the exact JSON-RPC response data record. @@ -171,8 +173,8 @@ type ( PayloadDef string // PayloadRef is a reference to the payload type if any, PayloadRef string - // PayloadDeclaration is the immutable generated declaration for a named - // payload type. It is nil for primitive payloads. + // PayloadDeclaration supplies the generated Go type name for a named payload. + // It is nil for primitive payloads. PayloadDeclaration *codegen.TypeDeclaration // PayloadDesc is the payload type description if any. PayloadDesc string @@ -186,8 +188,8 @@ type ( StreamingPayloadDef string // StreamingPayloadRef is a reference to the streaming payload type if any. StreamingPayloadRef string - // StreamingPayloadDeclaration is the immutable generated declaration for - // a named streaming payload type. It is nil for primitive payloads. + // StreamingPayloadDeclaration supplies the generated Go type name for a + // named streaming payload. It is nil for primitive payloads. StreamingPayloadDeclaration *codegen.TypeDeclaration // StreamingPayloadDesc is the streaming payload type description if any. StreamingPayloadDesc string @@ -199,8 +201,8 @@ type ( StreamingResultDef string // StreamingResultRef is the reference to the streaming result type if any. StreamingResultRef string - // StreamingResultDeclaration is the immutable generated declaration for a - // named streaming result type. It is nil for primitive results. + // StreamingResultDeclaration supplies the generated Go type name for a named + // streaming result. It is nil for primitive results. StreamingResultDeclaration *codegen.TypeDeclaration // StreamingResultDesc is the streaming result type description if any. StreamingResultDesc string @@ -215,8 +217,8 @@ type ( ResultDef string // ResultRef is the reference to the result type if any. ResultRef string - // ResultDeclaration is the immutable generated declaration for a named - // result type. It is nil for primitive results. + // ResultDeclaration supplies the generated Go type name for a named result. + // It is nil for primitive results. ResultDeclaration *codegen.TypeDeclaration // ResultDesc is the result type description if any. ResultDesc string @@ -227,12 +229,6 @@ type ( // ErrorLocs lists the file and Go package of the error type // if overridden via Meta indexed by error name. ErrorLocs map[string]*codegen.Location - // IsJSONRPC indicates if the endpoint is a JSON-RPC endpoint. - IsJSONRPC bool - // IsJSONRPCSSE indicates if the JSON-RPC endpoint uses SSE transport. - IsJSONRPCSSE bool - // IsJSONRPCWebSocket indicates if the JSON-RPC endpoint uses WebSocket transport. - IsJSONRPCWebSocket bool // Requirements contains the security requirements for the // method. Requirements RequirementsData @@ -257,9 +253,9 @@ type ( // StreamKind is the kind of the stream (payload or result or // bidirectional). StreamKind expr.StreamKind - // HasMixedResults indicates whether the method defines both Result and - // StreamingResult with different types, enabling content negotiation at - // the transport layer (e.g. JSON vs SSE over HTTP). + // HasMixedResults indicates whether the method defines Result and + // StreamingResult separately so HTTP can return one normal response or an + // SSE stream. HasMixedResults bool // SkipRequestBodyEncodeDecode is true if the method payload includes // the raw HTTP request body reader. @@ -294,8 +290,8 @@ type ( StreamData struct { // Interface is the name of the stream interface. Interface string - // VarName is the lexical implementation type name retained during service - // planning for transport generators. + // VarName is the unexported Go type name used by transport packages for this + // stream implementation. VarName string // SendName is the name of the send function. SendName string @@ -309,14 +305,6 @@ type ( SendTypeName string // SendTypeRef is the reference to the type sent through the stream. SendTypeRef string - // SendAndCloseName is the name of the send and close function (SSE only). - SendAndCloseName string - // SendAndCloseDesc is the description for the send and close function. - SendAndCloseDesc string - // SendAndCloseWithContextName is the name of the send and close function with context. - SendAndCloseWithContextName string - // SendAndCloseWithContextDesc is the description for the send and close function with context. - SendAndCloseWithContextDesc string // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -339,12 +327,18 @@ type ( Kind expr.StreamKind } - // ErrorInitData describes an error returned by a service method of type - // ErrorResult. + // ErrorInitData describes an error returned by a service method. ErrorInitData struct { - // Declaration is the exact package-level constructor record retained while - // the service was planned. + // Declaration is the package-level constructor submitted while the service + // was planned. It is nil for custom errors because the service package does + // not generate constructors for them. Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. It is empty for custom errors because they have no generated + // service constructor. + // + // Deprecated: Use Declaration.Name(). + Name string // Description is the error description. Description string // ErrName is the name of the error. @@ -380,6 +374,8 @@ type ( DesignName string // Description is the description of the interceptor from the design. Description string + // Service is the service name returned to this interceptor. + Service string // Methods Methods []*MethodInterceptorData // ReadPayload contains payload attributes that the interceptor can @@ -418,6 +414,21 @@ type ( // MethodInterceptorData contains the data required to render the // method-level interceptor code. MethodInterceptorData struct { + // InfoDeclaration is the private type that returns this method's name and + // provides its field access methods. + InfoDeclaration *codegen.NameDeclaration + // ServerUnaryInfoDeclaration is the private call information type used by + // the server endpoint call. + ServerUnaryInfoDeclaration *codegen.NameDeclaration + // ClientUnaryInfoDeclaration is the private call information type used by + // the client endpoint call. + ClientUnaryInfoDeclaration *codegen.NameDeclaration + // StreamingSendInfoDeclaration is the private call information type used + // while a stream value is sent. + StreamingSendInfoDeclaration *codegen.NameDeclaration + // StreamingRecvInfoDeclaration is the private call information type used + // while a stream value is received. + StreamingRecvInfoDeclaration *codegen.NameDeclaration // PayloadAccessDeclaration is the exact private payload accessor struct. PayloadAccessDeclaration *codegen.NameDeclaration // ResultAccessDeclaration is the exact private result accessor struct. @@ -509,8 +520,7 @@ type ( // UserTypeData contains the data describing a user-defined type. UserTypeData struct { - // Declaration is the immutable generated-package record that owns this - // type in a service, views, or relocated package. + // Declaration supplies this type's generated Go name and output package. Declaration *codegen.TypeDeclaration // Name is the type name. Name string @@ -518,7 +528,7 @@ type ( VarName string // Description is the type human description. Description string - // ErrorName is the retained Go expression returned by GoaErrorName. + // ErrorName is the Go expression returned by GoaErrorName during planning. ErrorName string // IsServiceError reports whether this is Goa's built-in service error. IsServiceError bool @@ -533,21 +543,26 @@ type ( Type expr.UserType } - // UnionTypeData describes a generated sum-type union for a service. + // UnionTypeData describes a generated value that holds exactly one branch. UnionTypeData struct { - // Declaration is the immutable generated-package record that owns this - // union in a service, views, or relocated package. - Declaration *codegen.UnionDeclaration - // Name is the Go type name of the union struct. + // TypeDeclaration supplies the generated union type name. + TypeDeclaration *codegen.NameDeclaration + // KindDeclaration supplies the generated type that records the selected branch. + KindDeclaration *codegen.NameDeclaration + // Name is the final union type name copied for existing plugins. + // + // Deprecated: Use TypeDeclaration. Name string - // KindName is the Go type name of the discriminator kind. + // KindName is the final selected-branch type name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindName string // Fields describes each union branch. Fields []*UnionFieldData // Loc defines the file and Go package of the union type if overridden via // Meta. When nil the type is generated in the default service file. Loc *codegen.Location - // TypeKey is the discriminator field name for JSON marshaling (defaults to "type"). + // TypeKey is the field that records the selected branch in JSON (defaults to "type"). TypeKey string // ValueKey is the value field name for JSON marshaling (defaults to "value"). ValueKey string @@ -557,16 +572,24 @@ type ( UnionFieldData struct { // Name is the branch name as defined in the DSL. Name string - // KindConst is the Go identifier for the kind constant of this branch. + // KindConst is the final branch constant name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindConst string - // Constructor is the Go identifier for the branch constructor function. + // Constructor is the final branch constructor name copied for existing plugins. + // + // Deprecated: Use ConstructorDeclaration. Constructor string + // KindDeclaration supplies the generated constant name for this branch. + KindDeclaration *codegen.NameDeclaration + // ConstructorDeclaration supplies the generated constructor name for this branch. + ConstructorDeclaration *codegen.NameDeclaration // FieldName is the struct field name in the union. FieldName string // FieldType is the Go type used in the union struct field and public API. FieldType string - // Nilable is true when the Go branch value can be nil even though the - // canonical union value is required. + // Nilable is true when the Go branch value can be nil even though selecting + // a non-nil Goa OneOf branch value is required. Nilable bool // EmitPrimitiveAlias is true when the branch uses a generated primitive alias // that must be declared in the same file as the union type. @@ -574,11 +597,8 @@ type ( // PrimitiveAliasType is the underlying Go type used by the generated branch // alias (for example "string" or "float64"). PrimitiveAliasType string - // TypeTag is the JSON "type" discriminator value for this branch. + // TypeTag is the JSON "type" value that selects this branch. TypeTag string - - reference *expr.AttributeExpr - definition *expr.AttributeExpr } // SchemeData describes a single security scheme. @@ -677,7 +697,8 @@ type ( TypeVarName string // MapDeclaration is the exact package-level view map record for this type. MapDeclaration *codegen.NameDeclaration - // ToProjected is the exact private constructor that applies this view. + // ToProjected is the private constructor that copies only this view's + // fields from a service result. ToProjected *codegen.NameDeclaration // ToResult is the exact private constructor that removes this view. ToResult *codegen.NameDeclaration @@ -704,6 +725,8 @@ type ( // corresponding service type. If the projected type corresponds to a // result type, then a function for each view is generated. TypeInits []*InitData + // ViewsPkg is the final views package name kept for existing plugins. + ViewsPkg string // Views lists the views defined on the projected type. Views []*ViewData } @@ -711,9 +734,14 @@ type ( // InitData contains the data to render a constructor to initialize service // types from viewed result types and vice versa. InitData struct { - // Declaration is the exact package-level constructor record retained while - // the service was planned. + // Declaration is the package-level constructor submitted while the service + // was planned. Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. + // + // Deprecated: Use Declaration.Name(). + Name string // Description is the function description. Description string // Args lists arguments to this function. @@ -737,9 +765,14 @@ type ( // ValidateData contains data to render a validate function to validate a // projected type or a viewed result type based on views. ValidateData struct { - // Declaration is the exact package-level function record retained while - // the service was planned. + // Declaration is the package-level validation function submitted while the + // service was planned. Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final validation function name kept for + // existing plugins. + // + // Deprecated: Use Declaration.Name(). + Name string // Ref is the reference to the type on which the validation function // is defined. Ref string @@ -751,8 +784,8 @@ type ( Calls []*ValidationCallData } - // ValidationCallData binds one nested validation call to the exact function - // declaration that owns the rendered name. + // ValidationCallData records the exact generated function called to validate + // one nested result value. ValidationCallData struct { // Declaration is the exact package-level validator function record. Declaration *codegen.NameDeclaration @@ -762,37 +795,37 @@ type ( Default bool } - // validationFieldData describes a nested result field validated by a - // projected parent validator. + // validationFieldData describes a nested result field checked by the + // validation function for its parent's selected view. validationFieldData struct { Name string Call *ValidationCallData IsRequired bool } - // constructorFieldData binds one nested result field to the exact retained - // private constructor called by its parent conversion. + // constructorFieldData associates one child result field with the private + // constructor called by its parent conversion. constructorFieldData struct { VarName string Declaration *codegen.NameDeclaration } - // unionDataKey identifies one emitted union definition in one generated Go - // package without encoding either fact into a string sentinel. + // unionDataKey selects one Goa OneOf definition by its generated definition + // key and Go package path. unionDataKey struct { packagePath string identity codegen.UnionTypeID } - // userTypeDataKey distinguishes exact in-memory declarations and the frozen - // package declaration selected for each one. + // userTypeDataKey distinguishes in-memory design types and the generated Go + // declaration selected for each one. userTypeDataKey struct { origin expr.UserType declaration *codegen.TypeDeclaration } - // projectedTypePair binds one rebuilt view declaration to the exact source - // declaration that gives it a stable DerivedTypeID. + // projectedTypePair records one result type rebuilt with only a view's fields + // and the exact source declaration used to find its DerivedTypeID. projectedTypePair struct { source expr.UserType projected expr.UserType @@ -801,8 +834,8 @@ type ( } ) -// linkServicesData resolves the exact service facts retained before generation -// freeze into immutable render data. +// linkServicesData builds service template data from the values copied during +// planning and the Go names chosen by Generation.Freeze. func linkServicesData(facts *rootFacts, generation *codegen.Generation, aliases *importAliases) (*ServicesData, error) { root := facts.root data := &ServicesData{ @@ -826,14 +859,15 @@ func linkServicesData(facts *rootFacts, generation *codegen.Generation, aliases return data, nil } -// Example computes attribute's example below the explicit semantic owner. +// Example computes an example for attribute. The supplied ExampleIdentity +// selects the repeatable sequence from which the values are drawn. func (d *ServicesData) Example(attribute *expr.AttributeExpr, owner expr.ExampleIdentity) any { return attribute.Example(d.examples.At(owner)) } -// FieldExample computes attribute's example using the same stable field -// identity as the corresponding field in parent. Named user types own their -// fields globally; anonymous parents keep the caller-supplied owner. +// FieldExample computes attribute's example from the same repeatable sequence +// as the matching field in parent. Fields of a named user type use that type's +// ExampleIdentity; fields of an anonymous parent use the caller's value. func (d *ServicesData) FieldExample(attribute, parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) any { if typ, ok := parent.Type.(expr.UserType); ok { owner = expr.UserTypeExampleIdentity(typ) @@ -853,57 +887,70 @@ func (d *ServicesData) GenPkg() string { return d.generation.GenPkg() } -// ServiceImport returns the frozen import alias for name's generated service -// package. The returned value is a copy that callers may add to one file. -func (d *ServicesData) ServiceImport(name string) *codegen.ImportSpec { +// ServiceImport returns the import path and Go name used by outputPackage for +// the generated package of service name. The returned value is a copy that +// callers may add to one file. +func (d *ServicesData) ServiceImport(outputPackage, name string) *codegen.ImportSpec { serviceFacts := d.facts.serviceByID[name] if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - spec := d.aliases.spec(serviceFacts.packagePath) + spec := d.aliases.spec(outputPackage, serviceFacts.packagePath) return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} } -// ViewImport returns the frozen import alias for name's generated views -// package. The returned value is a copy that callers may add to one file. -func (d *ServicesData) ViewImport(name string) *codegen.ImportSpec { +// ViewImport returns the import path and Go name used by outputPackage for the +// views package of service name. The returned value is a copy that callers may +// add to one file. +func (d *ServicesData) ViewImport(outputPackage, name string) *codegen.ImportSpec { serviceFacts := d.facts.serviceByID[name] if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - spec := d.aliases.spec(serviceFacts.viewsPath) + spec := d.aliases.spec(outputPackage, serviceFacts.viewsPath) return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} } -// PackageImport returns the frozen import alias for importPath. The returned -// value is a copy that callers may add to one generated file. -func (d *ServicesData) PackageImport(importPath string) *codegen.ImportSpec { - spec := d.aliases.spec(importPath) +// PackageImport returns the import path and Go name used by outputPackage for +// importPath. The returned value is a copy that callers may add to one file. +func (d *ServicesData) PackageImport(outputPackage, importPath string) *codegen.ImportSpec { + spec := d.aliases.spec(outputPackage, importPath) return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} } -// ServiceAttributor returns the frozen service declaration resolver for name -// as referenced from outputPackage. The returned resolver follows explicit -// generated package locations and uses the same import aliases as service -// rendering. +// ServiceAttributor returns a type writer for service name as referenced from +// outputPackage. It follows explicit generated package locations and uses the +// same import names as service templates. func (d *ServicesData) ServiceAttributor(name, outputPackage string) codegen.Attributor { serviceFacts := d.facts.serviceByID[name] if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - return newServiceResolver(d.generation, d.aliases, serviceFacts.service, outputPackage). + return newServiceResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.packagePath, + outputPackage, + ). withValidators(serviceFacts.validators) } -// ViewAttributor returns the frozen projected and viewed result declaration -// resolver for name as referenced from outputPackage. +// ViewAttributor returns a type writer for service name's result views as +// referenced from outputPackage. func (d *ServicesData) ViewAttributor(name, outputPackage string) codegen.Attributor { serviceFacts := d.facts.serviceByID[name] if serviceFacts == nil { panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } data := d.Services[name] - return newViewResolver(d.generation, d.aliases, serviceFacts.service, data.viewDerived). + return newViewResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.viewsPath, + data.viewDerived, + ). withValidators(serviceFacts.validators). withOutputPackage(outputPackage) } diff --git a/codegen/service/service_declaration_condition_contract_test.go b/codegen/service/service_declaration_condition_contract_test.go index 918359fa09..0d5c48904d 100644 --- a/codegen/service/service_declaration_condition_contract_test.go +++ b/codegen/service/service_declaration_condition_contract_test.go @@ -9,7 +9,6 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/dsl" - "goa.design/goa/v3/expr" ) // TestRelocatedResultViewConstructorsCompile catches constructor declarations @@ -38,73 +37,9 @@ func TestRelocatedResultViewConstructorsCompile(t *testing.T) { }) }) - plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + plan := retainedServicePlanForPackage(t, root) files, err := Files(plan) require.NoError(t, err) files = append(files, ExampleServiceFiles(plan)...) - compileGeneratedServiceFiles(t, "generated.local", files) -} - -// TestJSONRPCSSEEventNameIsDeclaredOnlyWhenEmitted catches an unused Event -// declaration that changes collision suffixes for methods with no result. -func TestJSONRPCSSEEventNameIsDeclaredOnlyWhenEmitted(t *testing.T) { - cases := []struct { - name string - result expr.DataType - wantEvent bool - wantName string - }{ - {name: "no result", result: expr.Empty}, - {name: "emits event", result: expr.String, wantEvent: true, wantName: "Event2"}, - } - for _, test := range cases { - t.Run(test.name, func(t *testing.T) { - generation := mustTestGeneration(t, "generated.local/gen", nil) - servicePackage := mustClaimTestPackage(t, generation, "generated.local/gen/events") - mustClaimTestPackage(t, generation, "generated.local/gen/events/views") - authored, err := servicePackage.DeclareUserType(&expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: expr.String}, - TypeName: "Event", - UID: "authored-event", - }) - require.NoError(t, err) - - result := &expr.AttributeExpr{Type: test.result} - method := &expr.MethodExpr{ - Name: "Watch", - Payload: &expr.AttributeExpr{Type: expr.Empty}, - Result: result, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Stream: expr.ServerStreamKind, - StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, - StreamingResult: result, - } - service := &expr.ServiceExpr{Name: "Events", Methods: []*expr.MethodExpr{method}} - method.Service = service - facts := &serviceFacts{ - service: service, - methods: []*expr.MethodExpr{method}, - methodByExpr: map[*expr.MethodExpr]*methodFacts{ - method: { - method: method, - varName: "Watch", - isJSONRPCSSE: true, - }, - }, - projections: make(map[*expr.MethodExpr]*projectionFacts), - } - - require.NoError(t, collectServiceNames(facts, &rootTypeSet{byOrigin: make(map[expr.UserType]expr.UserType)}, generation)) - require.NoError(t, generation.Freeze()) - require.Equal(t, "Event", authored.Name()) - event, exists := facts.names[serviceSymbolID{ - role: serviceEventNameRole, - service: service.Name, - }] - require.Equal(t, test.wantEvent, exists) - if test.wantEvent { - require.Equal(t, test.wantName, event.declaration.Name()) - } - }) - } + compileGeneratedServiceFiles(t, files) } diff --git a/codegen/service/service_dedup_test.go b/codegen/service/service_dedup_test.go deleted file mode 100644 index 886adc90d6..0000000000 --- a/codegen/service/service_dedup_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "bytes" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/codegen" - stest "goa.design/goa/v3/codegen/service/testdata" -) - -// TestService_DedupEventMarkers verifies that when multiple streaming methods share the -// same result type the generated service code only emits a single event marker method. -func TestService_DedupEventMarkers(t *testing.T) { - root := codegen.RunDSL(t, stest.StreamingDuplicateResultTypesDSL) - plan := mustServicePlan(t, root) - require.Len(t, root.Services, 1) - - files := mustServiceFiles(t, plan) - require.Greater(t, len(files), 0) - - // Generate the service.go content - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - code := buf.String() - - // Count occurrences of the service-level event marker method for SharedEvent - // The marker has the shape: func (*SharedEvent) isdupStreamServiceEvent() {} - occurrences := strings.Count(code, "func (*SharedEvent) isdupStreamServiceEvent()") - require.Equal(t, 1, occurrences, "expected a single event marker for SharedEvent, got %d", occurrences) -} diff --git a/codegen/service/service_fact_plan.go b/codegen/service/service_fact_plan.go index 56942b887a..68727a00f1 100644 --- a/codegen/service/service_fact_plan.go +++ b/codegen/service/service_fact_plan.go @@ -1,4 +1,5 @@ -// This file copies service method, error, streaming, and interceptor membership before generated package names freeze. +// This file copies the methods, errors, stream settings, and interceptors used +// by one service before generated Go names are chosen. package service import ( @@ -8,11 +9,13 @@ import ( "goa.design/goa/v3/expr" ) -// collectServiceFacts copies service membership and the transport decisions -// that renderers need so linking never consults mutable root collections. +// collectServiceFacts copies the service fields and transport choices needed by +// templates, so later steps do not walk design collections that plugins could +// change. func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, examples *expr.ExampleGenerator) *serviceFacts { facts := &serviceFacts{ service: service, + apiName: root.API.Name, name: service.Name, description: service.Description, methods: append([]*expr.MethodExpr(nil), service.Methods...), @@ -51,7 +54,6 @@ func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, example method.StreamingResult, examples.At(expr.MethodStreamingResultExampleIdentity(method)), ) - _, methodFacts.isJSONRPC = method.Meta["jsonrpc"] methodFacts.requirements, methodFacts.schemes = retainMethodSecurity(method) for _, methodError := range method.Errors { methodFacts.errors = append(methodFacts.errors, retainErrorRenderFacts(methodError)) @@ -60,17 +62,6 @@ func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, example methodFacts.serverStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ServerStream") methodFacts.clientStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ClientStream") } - if _, jsonRPC := method.Meta["jsonrpc"]; jsonRPC && method.IsStreaming() { - if jsonRPCService := root.API.JSONRPC.HTTPExpr.Service(service.Name); jsonRPCService != nil { - for _, endpoint := range jsonRPCService.HTTPEndpoints { - if endpoint.MethodExpr == method { - methodFacts.isJSONRPCSSE = endpoint.SSE != nil - methodFacts.isJSONRPCWebSocket = endpoint.SSE == nil - break - } - } - } - } for _, httpService := range root.API.HTTP.Services { if httpService.Name() != service.Name { continue @@ -115,9 +106,9 @@ func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, example return facts } -// retainServiceValueTypes records every named type reachable from one service -// value contract. External mappings use this set so stream and error values -// receive the same generated conversion ownership as payloads and results. +// retainServiceValueTypes records every named type reachable from a payload, +// result, error, or stream value. User-supplied Go type mappings use this set to +// generate conversions for all four kinds of service data. func retainServiceValueTypes(facts *serviceFacts, attribute *expr.AttributeExpr) { if attribute == nil || attribute.Type == expr.Empty { return @@ -133,8 +124,8 @@ func retainServiceValueTypes(facts *serviceFacts, attribute *expr.AttributeExpr) } } -// collectInterceptorFacts fixes method applicability during planning so -// linking never walks service methods or interceptor expression lists again. +// collectInterceptorFacts records the methods that call each interceptor, so +// template data can be built without walking the design again. func collectInterceptorFacts(interceptors []*expr.InterceptorExpr, methods []*expr.MethodExpr, methodFacts map[*expr.MethodExpr]*methodFacts, server bool) []*interceptorFacts { result := make([]*interceptorFacts, len(interceptors)) for index, interceptor := range interceptors { @@ -164,9 +155,8 @@ func collectInterceptorFacts(interceptors []*expr.InterceptorExpr, methods []*ex return result } -// retainMethodAttribute copies the top-level method contract and evaluates its -// example during collection. Nested type layout is retained separately by the -// generated Go type plan. +// retainMethodAttribute copies one payload or result's description, metadata, +// default, and example. GoTypePlan separately records its nested Go fields. func retainMethodAttribute(attribute *expr.AttributeExpr, examples *expr.ExampleGenerator) *methodAttributeFacts { if attribute == nil { return nil @@ -186,8 +176,9 @@ func retainMethodAttribute(attribute *expr.AttributeExpr, examples *expr.Example } } -// retainErrorRenderFacts copies the error text, type wrapper, location, and -// marker flags that generated constructors and client comments consume. +// retainErrorRenderFacts copies the error description, type, output location, +// and temporary, timeout, and fault settings used by generated constructors +// and client comments. func retainErrorRenderFacts(errorExpression *expr.ErrorExpr) *errorRenderFacts { _, temporary := errorExpression.Meta["goa:error:temporary"] _, timeout := errorExpression.Meta["goa:error:timeout"] @@ -204,12 +195,12 @@ func retainErrorRenderFacts(errorExpression *expr.ErrorExpr) *errorRenderFacts { temporary: temporary, timeout: timeout, fault: fault, - serviceType: errorExpression.Type == expr.ErrorResult, + serviceType: expr.IsErrorResult(errorExpression.Type), } } -// retainMethodSecurity evaluates scheme credential fields and scopes while -// the finalized method payload and requirements are still collection inputs. +// retainMethodSecurity copies credential fields and required authorization +// scope names from the evaluated method before template data is built. func retainMethodSecurity(method *expr.MethodExpr) (RequirementsData, SchemesData) { requirements := make(RequirementsData, 0, len(method.Requirements)) var schemes SchemesData @@ -228,7 +219,8 @@ func retainMethodSecurity(method *expr.MethodExpr) (RequirementsData, SchemesDat return requirements, schemes } -// cloneSchemeData detaches the collection values retained in one scheme. +// cloneSchemeData copies one security scheme and its slices so later changes to +// the design cannot change generated template data. func cloneSchemeData(source *SchemeData) *SchemeData { if source == nil { return nil @@ -243,8 +235,8 @@ func cloneSchemeData(source *SchemeData) *SchemeData { return &cloned } -// cloneRetainedValue copies the collection shapes accepted by Goa examples -// and defaults. Primitive values are immutable and may be shared. +// This helper copies maps and slices used in Goa examples and defaults. +// Numbers, strings, booleans, and other value types may be shared. func cloneRetainedValue(source any) any { switch actual := source.(type) { case expr.Val: @@ -290,8 +282,8 @@ func cloneRetainedValue(source any) any { } } -// retainedInterceptors returns one stable, name-ordered interceptor set without -// sorting or appending into any expression-owned slice. +// This helper returns each applicable interceptor once, sorted by name, without +// changing a slice stored in the design. func retainedInterceptors(api, service []*expr.InterceptorExpr, methods []*expr.MethodExpr, server bool) []*expr.InterceptorExpr { interceptors := append([]*expr.InterceptorExpr(nil), api...) interceptors = append(interceptors, service...) diff --git a/codegen/service/service_link.go b/codegen/service/service_link.go index a2b313270d..5da7f38a34 100644 --- a/codegen/service/service_link.go +++ b/codegen/service/service_link.go @@ -1,4 +1,5 @@ -// This file links retained service facts into immutable render data after names and import aliases freeze. +// This file turns stored service information into the data used by templates +// after all generated names and imported package names are final. package service import ( @@ -6,13 +7,15 @@ import ( "path" "slices" "sort" + "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) -// Link resolves the plan's collected facts through frozen declarations into -// the immutable template data consumed by renderers. +// Link reads the final Go declaration and import names and builds the data used +// by service templates. Generation.Freeze must run first so each definition +// and every reference to it use the same name. func (p *Plan) Link() error { if !p.generation.Frozen() { return fmt.Errorf("service plan cannot link before generation freeze") @@ -38,8 +41,8 @@ func (p *Plan) Link() error { return nil } -// Services returns the linked service render model. It panics before Link -// because no renderer or transport may observe provisional generated names. +// Services returns the service data passed to templates. It panics before Link +// because that data does not exist until the final Go names are available. func (p *Plan) Services() *ServicesData { if p.services == nil { panic("service render model requested before plan linking") @@ -62,7 +65,8 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { viewScope := d.generation.Package( facts.viewsPath, ).Scope() - pkgName := codegen.Goify(path.Base(servicePackage.ImportPath()), false) + pkgName := strings.ToLower(codegen.Goify(path.Base(servicePackage.ImportPath()), false)) + var viewsPkg string seenErrors := make(map[string]struct{}) type viewedResultKey struct { origin expr.UserType @@ -71,15 +75,15 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { seenViewed := make(map[viewedResultKey]*ViewedResultTypeData) seenViewedDeclarations := make(map[*codegen.TypeDeclaration]struct{}) viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) - serviceResolver := newRetainedServiceResolver( + serviceResolver := newServiceResolver( d.generation, d.aliases, facts.name, facts.packagePath, facts.packagePath, ).withValidators(facts.validators) - types = formatUserTypeFacts(facts.userTypes, facts.packagePath, d.aliases) - errTypes = formatUserTypeFacts(facts.errorTypes, facts.packagePath, d.aliases) + types = formatUserTypeFacts(facts.userTypes, d.aliases) + errTypes = formatUserTypeFacts(facts.errorTypes, d.aliases) // recordError formats each selected ErrorResult constructor once. recordError := func(errorFacts *errorRenderFacts) { @@ -100,15 +104,16 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { } for _, method := range facts.orderedMethods { - // Collect projected types + // Build template data for each result type containing only a view's fields. if projection := method.projection; projection != nil { + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name views := d.generation.Package(facts.viewsPath) for _, projectedFacts := range projection.types { pair := projectedFacts.pair identity := codegen.NewProjectedTypeID(pair.source) viewDerived[pair.projected.Origin()] = identity } - viewResolver := newRetainedViewResolver( + viewResolver := newViewResolver( d.generation, d.aliases, facts.name, @@ -128,6 +133,7 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { serviceResolver, viewResolver, declaration, + viewsPkg, ) projTypes = append(projTypes, projectedType) } @@ -136,10 +142,7 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { recordError(errorFacts) } } - viewUnions, err := d.formatViewUnions(facts) - if err != nil { - return nil, err - } + viewUnions := d.formatViewUnions(facts) var ( methods []*MethodData @@ -148,10 +151,7 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { methods = make([]*MethodData, len(facts.orderedMethods)) methodDataByFacts := make(map[*methodFacts]*MethodData, len(facts.orderedMethods)) for i, method := range facts.orderedMethods { - m, err := d.buildMethodData(method, serviceResolver, facts) - if err != nil { - return nil, err - } + m := buildMethodData(method, serviceResolver, facts) methods[i] = m methodDataByFacts[method] = m for _, s := range m.Schemes { @@ -161,6 +161,7 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { if viewedFacts == nil { continue } + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name key := viewedResultKey{origin: viewedFacts.origin, view: viewedFacts.viewName} if vrt, ok := seenViewed[key]; ok { m.ViewedResult = vrt @@ -168,9 +169,9 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { } vrt := buildViewedResultType( viewedFacts, - d.aliases.spec(facts.viewsPath).Name, + viewsPkg, serviceResolver, - newRetainedViewResolver( + newViewResolver( d.generation, d.aliases, facts.name, @@ -188,10 +189,7 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { seenViewed[key] = vrt } - unions, err := d.formatServiceUnions(facts) - if err != nil { - return nil, err - } + unions := d.formatServiceUnions(facts) desc := facts.description if desc == "" { @@ -210,38 +208,38 @@ func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { NewEndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewEndpointsNameRole, service: facts.name}), ClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceClientNameRole, service: facts.name}), NewClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewClientNameRole, service: facts.name}), - StreamDeclaration: facts.names[serviceSymbolID{role: serviceStreamNameRole, service: facts.name}].declaration, - EventDeclaration: facts.names[serviceSymbolID{role: serviceEventNameRole, service: facts.name}].declaration, ServerInterceptorsDeclaration: facts.names[serviceSymbolID{ role: serviceServerInterceptorsNameRole, service: facts.name, }].declaration, ClientInterceptorsDeclaration: facts.names[serviceSymbolID{ role: serviceClientInterceptorsNameRole, service: facts.name, }].declaration, - ExampleStructDeclaration: facts.exampleStruct, - ExampleConstructorDeclaration: facts.exampleConstructor, - Name: facts.name, - Description: desc, - APIName: d.facts.apiName, - APIVersion: d.facts.apiVersion, - VarName: varName, - PathName: codegen.SnakeCase(varName), - StructName: codegen.Goify(facts.name, true), - PkgName: pkgName, - Methods: methods, - Schemes: schemes, - ServerInterceptors: d.collectInterceptors(facts, facts.serverInterceptorFacts, methodDataByFacts, serviceResolver, true), - ClientInterceptors: d.collectInterceptors(facts, facts.clientInterceptorFacts, methodDataByFacts, serviceResolver, false), - Scope: scope, - ViewScope: viewScope, - errorTypes: errTypes, - errorInits: errorInits, - userTypes: types, - projectedTypes: projTypes, - viewedResultTypes: viewedRTs, - unions: unions, - viewUnions: viewUnions, - viewDerived: viewDerived, + ExampleStructDeclaration: facts.exampleStruct, + ExampleConstructorDeclaration: facts.exampleConstructor, + ExampleServerInterceptorsConstructorDeclaration: facts.exampleServerConstructor, + Name: facts.name, + Description: desc, + APIName: d.facts.apiName, + APIVersion: d.facts.apiVersion, + VarName: varName, + PathName: path.Base(facts.packagePath), + StructName: codegen.Goify(facts.name, true), + PkgName: pkgName, + ViewsPkg: viewsPkg, + Methods: methods, + Schemes: schemes, + ServerInterceptors: d.collectInterceptors(facts, facts.serverInterceptorFacts, methodDataByFacts, serviceResolver, true), + ClientInterceptors: d.collectInterceptors(facts, facts.clientInterceptorFacts, methodDataByFacts, serviceResolver, false), + Scope: scope, + ViewScope: viewScope, + errorTypes: errTypes, + errorInits: errorInits, + userTypes: types, + projectedTypes: projTypes, + viewedResultTypes: viewedRTs, + unions: unions, + viewUnions: viewUnions, + viewDerived: viewDerived, } return data, nil } @@ -266,32 +264,24 @@ func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.Attr } } -// formatUserTypeFacts resolves the final names and definitions of types whose -// reachability and declaration ownership were fixed during collection. -func formatUserTypeFacts(facts []*userTypeFacts, outputPath string, aliases *importAliases) []*UserTypeData { +// formatUserTypeFacts resolves the final names and definitions of types that +// collection already selected and assigned to generated packages. +func formatUserTypeFacts(facts []*userTypeFacts, aliases *importAliases) []*UserTypeData { data := make([]*UserTypeData, len(facts)) for index, facts := range facts { - description := facts.description - if description == "" && facts.location != nil { - description = fmt.Sprintf("%s is a generated service type.", facts.declaration.Name()) - } - definition := facts.layout.Link( + linked := facts.layout.Link( facts.declaration.PackagePath(), - retainedTypeQualifier(aliases), - ) - reference := facts.reference.Link( - outputPath, - retainedTypeQualifier(aliases), + retainedTypeQualifier(aliases, facts.declaration.PackagePath()), ) data[index] = &UserTypeData{ Declaration: facts.declaration, Name: facts.name, VarName: facts.declaration.Name(), - Description: description, + Description: facts.description, ErrorName: facts.errorName, IsServiceError: facts.serviceError, - Def: definition.Def(), - Ref: reference.Ref(), + Def: linked.Def(), + Ref: facts.declaration.Ref(facts.userType), Loc: facts.location, Type: facts.userType, } @@ -299,9 +289,9 @@ func formatUserTypeFacts(facts []*userTypeFacts, outputPath string, aliases *imp return data } -// formatServiceUnions resolves the exact service union declarations retained -// during collection and registers one render record per generated package. -func (d *ServicesData) formatServiceUnions(facts *serviceFacts) ([]*UnionTypeData, error) { +// formatServiceUnions builds template data for the Goa OneOf declarations +// recorded during collection and adds one entry for each generated package. +func (d *ServicesData) formatServiceUnions(facts *serviceFacts) []*UnionTypeData { unions := make([]*UnionTypeData, 0, len(facts.unions)) for _, facts := range facts.unions { union := buildRetainedUnionTypeData(facts, d.aliases) @@ -321,12 +311,12 @@ func (d *ServicesData) formatServiceUnions(facts *serviceFacts) ([]*UnionTypeDat } return left < right }) - return unions, nil + return unions } -// formatViewUnions resolves the exact view union expressions retained while -// their declarations were collected. It does not traverse projected types. -func (d *ServicesData) formatViewUnions(facts *serviceFacts) ([]*UnionTypeData, error) { +// formatViewUnions builds template data for the Goa OneOf declarations found +// while collecting result views. It does not walk the result types again. +func (d *ServicesData) formatViewUnions(facts *serviceFacts) []*UnionTypeData { unions := make([]*UnionTypeData, len(facts.viewUnions)) for index, union := range facts.viewUnions { unions[index] = buildRetainedUnionTypeData(union, d.aliases) @@ -334,34 +324,37 @@ func (d *ServicesData) formatViewUnions(facts *serviceFacts) ([]*UnionTypeData, sort.Slice(unions, func(i, j int) bool { return unions[i].Name < unions[j].Name }) - return unions, nil + return unions } -// buildRetainedUnionTypeData formats one union from the branch declarations -// and Go layouts selected before the generation froze. +// This helper builds template data for one Goa OneOf type from the branch names +// and Go types selected during planning. func buildRetainedUnionTypeData(facts *unionFacts, aliases *importAliases) *UnionTypeData { fields := make([]*UnionFieldData, len(facts.branches)) for index, branch := range facts.branches { fields[index] = &UnionFieldData{ - Name: branch.name, - KindConst: branch.declaration.KindConst(), - Constructor: branch.declaration.Constructor(), - FieldName: branch.fieldName, - FieldType: branch.layout.Link(facts.declaration.PackagePath(), retainedTypeQualifier(aliases)).Ref(), - Nilable: branch.nilable, - EmitPrimitiveAlias: branch.emitPrimitiveAlias, - PrimitiveAliasType: branch.primitiveAliasType, - TypeTag: branch.name, + Name: branch.name, + KindConst: branch.declaration.KindConst(), + Constructor: branch.declaration.Constructor(), + KindDeclaration: branch.declaration.KindDeclaration(), + ConstructorDeclaration: branch.declaration.ConstructorDeclaration(), + FieldName: branch.fieldName, + FieldType: branch.layout.Link(facts.declaration.PackagePath(), retainedTypeQualifier(aliases, facts.declaration.PackagePath())).Ref(), + Nilable: branch.nilable, + EmitPrimitiveAlias: branch.emitPrimitiveAlias, + PrimitiveAliasType: branch.primitiveAliasType, + TypeTag: branch.name, } } return &UnionTypeData{ - Declaration: facts.declaration, - Name: facts.declaration.Name(), - KindName: facts.declaration.KindName(), - Fields: fields, - Loc: facts.location, - TypeKey: facts.typeKey, - ValueKey: facts.valueKey, + TypeDeclaration: facts.declaration.Declaration(), + KindDeclaration: facts.declaration.KindDeclaration(), + Name: facts.declaration.Name(), + KindName: facts.declaration.KindName(), + Fields: fields, + Loc: facts.location, + TypeKey: facts.typeKey, + ValueKey: facts.valueKey, } } @@ -395,15 +388,20 @@ func primitiveAliasGoType(dt expr.DataType) (string, bool) { } } -// buildRetainedErrorInitData formats an error selected during collection -// without consulting the mutable design expression. +// This helper builds constructor data for an error copied during collection +// without reading the design expression again. func buildRetainedErrorInitData(facts *errorRenderFacts, resolver *declarationResolver, declaration *codegen.NameDeclaration) *ErrorInitData { if facts.layout == nil { panic(fmt.Sprintf("retained error %q has no Go type layout", facts.name)) } - linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + name := "" + if facts.serviceType { + name = declaration.Name() + } return &ErrorInitData{ Declaration: declaration, + Name: name, Description: facts.description, ErrName: facts.name, TypeName: linked.Name(), @@ -414,10 +412,10 @@ func buildRetainedErrorInitData(facts *errorRenderFacts, resolver *declarationRe } } -// retainedTypeQualifier returns the frozen qualifier assigned to one retained -// Go type import. -func retainedTypeQualifier(aliases *importAliases) codegen.GoTypeQualifier { +// This helper returns the Go import name chosen for a type recorded during +// planning. +func retainedTypeQualifier(aliases *importAliases, outputPackage string) codegen.GoTypeQualifier { return func(importPath string) string { - return aliases.name(importPath) + return aliases.name(outputPackage, importPath) } } diff --git a/codegen/service/service_name_collision_contract_test.go b/codegen/service/service_name_collision_contract_test.go index 2cefd51d7b..792bde69ac 100644 --- a/codegen/service/service_name_collision_contract_test.go +++ b/codegen/service/service_name_collision_contract_test.go @@ -29,11 +29,8 @@ func TestEveryServiceNameRoleSharesOnePackageNamespace(t *testing.T) { serviceAPIVersionNameRole, serviceNameConstantRole, serviceMethodNamesRole, - serviceMethodEventNameRole, serviceServerStreamNameRole, serviceClientStreamNameRole, - serviceStreamNameRole, - serviceEventNameRole, serviceErrorConstructorNameRole, serviceViewConstructorNameRole, servicePrivateProjectionConstructorNameRole, @@ -58,6 +55,11 @@ func TestEveryServiceNameRoleSharesOnePackageNamespace(t *testing.T) { serviceInterceptorResultAccessNameRole, serviceInterceptorStreamingPayloadAccessNameRole, serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, serviceServerEndpointWrapperNameRole, serviceClientEndpointWrapperNameRole, serviceServerInterceptorWrapperNameRole, diff --git a/codegen/service/service_names.go b/codegen/service/service_names.go index 400c7683a3..46f2b4c9c8 100644 --- a/codegen/service/service_names.go +++ b/codegen/service/service_names.go @@ -1,7 +1,6 @@ -// This file defines stable identities for every package-level declaration -// emitted by the core service and views generators. Retained service plans -// declare these records before generation freeze and render their final names -// from the same records afterward. +// This file records every package-level Go declaration written by the service +// and views generators. Each definition and reference reads its name from the +// same NameDeclaration. package service import ( @@ -13,15 +12,17 @@ import ( ) type ( - // serviceNameRole identifies one closed family of package-level declarations - // emitted by the core service and views generators. + // serviceNameRole identifies what one package-level declaration does in the + // generated service or views package. serviceNameRole uint8 - // serviceNameOrder contains only stable semantic values, giving colliding - // service declarations a deterministic total order across traversals. + // serviceNameOrder contains design names and fixed categories that order two + // declarations requesting the same Go name. It does not depend on the order + // in which generators find them. serviceNameOrder struct { role serviceNameRole service string + api string method string subject string view string @@ -32,13 +33,13 @@ type ( required bool } - // serviceSymbolID identifies one package declaration without using its - // provisional Go spelling. Source and target distinguish transform helpers; - // subject and view distinguish constructors and validators. + // serviceSymbolID identifies one package declaration without using the Go + // name that will be chosen later. Source and target distinguish conversion + // helpers; subject and view distinguish constructors and validators. serviceSymbolID serviceNameOrder - // serviceName retains the preferred spelling with its canonical declaration - // so repeated collection cannot silently rename one semantic symbol. + // serviceName stores the requested Go name and the NameDeclaration created + // for it. Repeated collection must return that same declaration. serviceName struct { preferred string base *codegen.NameDeclaration @@ -47,8 +48,8 @@ type ( declaration *codegen.NameDeclaration } - // serviceNames owns the core declarations collected for one retained service - // plan. The declaration itself remains owned by its generated Go package. + // serviceNames maps each service declaration purpose to the NameDeclaration + // stored in its generated Go package. serviceNames map[serviceSymbolID]serviceName ) @@ -59,11 +60,8 @@ const ( serviceAPIVersionNameRole serviceNameConstantRole serviceMethodNamesRole - serviceMethodEventNameRole serviceServerStreamNameRole serviceClientStreamNameRole - serviceStreamNameRole - serviceEventNameRole serviceErrorConstructorNameRole serviceViewConstructorNameRole servicePrivateProjectionConstructorNameRole @@ -88,6 +86,11 @@ const ( serviceInterceptorResultAccessNameRole serviceInterceptorStreamingPayloadAccessNameRole serviceInterceptorStreamingResultAccessNameRole + serviceInterceptorMethodInfoNameRole + serviceInterceptorServerUnaryInfoNameRole + serviceInterceptorClientUnaryInfoNameRole + serviceInterceptorStreamingSendInfoNameRole + serviceInterceptorStreamingRecvInfoNameRole serviceServerEndpointWrapperNameRole serviceClientEndpointWrapperNameRole serviceServerInterceptorWrapperNameRole @@ -103,8 +106,8 @@ const ( serviceExampleClientInterceptorsConstructorNameRole ) -// ComparePackageName orders declarations from the core service generator by -// their complete stable semantic identity. +// ComparePackageName orders service declarations by their purpose and design +// names, so discovery order cannot change generated Go names. func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { right := other.(serviceNameOrder) if compared := cmp.Compare(o.role, right.role); compared != 0 { @@ -113,6 +116,9 @@ func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int if compared := cmp.Compare(o.service, right.service); compared != 0 { return compared } + if compared := cmp.Compare(o.api, right.api); compared != 0 { + return compared + } if compared := cmp.Compare(o.method, right.method); compared != 0 { return compared } @@ -143,8 +149,8 @@ func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int return 1 } -// kind returns the package declaration category fixed by this service symbol -// family. An unknown role is an internal planner bug. +// kind returns whether this role writes a Go type, function, constant, or +// variable. An unknown role means the generator omitted a supported case. func (r serviceNameRole) kind() codegen.PackageNameKind { switch r { case serviceAPINameRole, serviceAPIVersionNameRole, serviceNameConstantRole: @@ -169,11 +175,8 @@ func (r serviceNameRole) kind() codegen.PackageNameKind { return codegen.NameFunction case serviceInterfaceNameRole, serviceAutherNameRole, - serviceMethodEventNameRole, serviceServerStreamNameRole, serviceClientStreamNameRole, - serviceStreamNameRole, - serviceEventNameRole, serviceEndpointsNameRole, serviceClientNameRole, serviceEndpointInputNameRole, @@ -190,6 +193,11 @@ func (r serviceNameRole) kind() codegen.PackageNameKind { serviceInterceptorResultAccessNameRole, serviceInterceptorStreamingPayloadAccessNameRole, serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, serviceServerStreamWrapperNameRole, serviceClientStreamWrapperNameRole, serviceExampleStructNameRole, @@ -201,8 +209,8 @@ func (r serviceNameRole) kind() codegen.PackageNameKind { } } -// visibility reports whether the emitted declaration is part of the generated -// package API or an implementation detail used only by neighboring sections. +// visibility reports whether callers outside the generated package can use the +// declaration. func (r serviceNameRole) visibility() codegen.PackageNameVisibility { switch r { case servicePrivateProjectionConstructorNameRole, @@ -210,6 +218,11 @@ func (r serviceNameRole) visibility() codegen.PackageNameVisibility { serviceInterceptorResultAccessNameRole, serviceInterceptorStreamingPayloadAccessNameRole, serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, serviceServerInterceptorWrapperNameRole, serviceClientInterceptorWrapperNameRole, serviceServerStreamWrapperNameRole, @@ -222,9 +235,15 @@ func (r serviceNameRole) visibility() codegen.PackageNameVisibility { } } -// declare records id in pkg and returns the same canonical declaration when a -// planning traversal encounters that exact semantic symbol again. +// declare submits one requested Go name to pkg. Repeated calls for the same id +// return the same NameDeclaration and reject a different requested name. func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred string) (*codegen.NameDeclaration, error) { + return n.declareForAPI(pkg, id, preferred, "") +} + +// declareForAPI submits one generated name using the API to distinguish two +// roots that intentionally contribute to the same service package. +func (n serviceNames) declareForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred, api string) (*codegen.NameDeclaration, error) { if existing, ok := n[id]; ok { if existing.base != nil || existing.preferred != preferred { return nil, fmt.Errorf( @@ -240,11 +259,13 @@ func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, return existing.declaration, nil } + order := serviceNameOrder(id) + order.api = api declaration := codegen.NewPreferredName( id.role.kind(), preferred, id.role.visibility(), - serviceNameOrder(id), + order, ) if err := pkg.DeclareName(declaration); err != nil { return nil, err @@ -253,10 +274,16 @@ func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, return declaration, nil } -// declareDependent records a companion whose preferred spelling follows the -// exact final name of base. Repeated collection must use the same base record -// and affixes, so one semantic symbol cannot silently change families. +// declareDependent submits a declaration whose Go name is built by adding +// prefix and suffix to base's final name. Repeated calls for the same id must +// use the same base, prefix, and suffix. func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix string) (*codegen.NameDeclaration, error) { + return n.declareDependentForAPI(pkg, id, base, prefix, suffix, "") +} + +// declareDependentForAPI submits one dependent generated name using the API to +// distinguish two roots that intentionally contribute to the same package. +func (n serviceNames) declareDependentForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix, api string) (*codegen.NameDeclaration, error) { if existing, ok := n[id]; ok { if existing.base != base || existing.prefix != prefix || existing.suffix != suffix { return nil, fmt.Errorf("service symbol role %d cannot change its dependent declaration family", id.role) @@ -267,12 +294,14 @@ func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id service return existing.declaration, nil } + order := serviceNameOrder(id) + order.api = api declaration, err := pkg.DeclareDependentName( id.role.kind(), base, prefix, suffix, - serviceNameOrder(id), + order, ) if err != nil { return nil, err @@ -286,8 +315,8 @@ func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id service return declaration, nil } -// declaration returns the canonical record for id. Calling it for a symbol -// that collection did not declare is an internal retained-plan bug. +// declaration returns the NameDeclaration previously stored for id. It panics +// when name collection did not submit that id. func (n serviceNames) declaration(id serviceSymbolID) *codegen.NameDeclaration { name, ok := n[id] if !ok { @@ -296,16 +325,8 @@ func (n serviceNames) declaration(id serviceSymbolID) *codegen.NameDeclaration { return name.declaration } -// transformDataTypeIdentity returns the authored declaration identity used by -// TransformPlan when the operation crosses copied named attributes. -func transformDataTypeIdentity(dataType expr.DataType) expr.DataType { - if userType, ok := dataType.(expr.UserType); ok { - return userType.Origin() - } - return dataType -} - -// transformDataTypeName returns stable semantic labels for one helper side. +// transformDataTypeName returns the design name and ID used to order one side +// of a generated conversion helper. func transformDataTypeName(dataType expr.DataType) (string, string) { if userType, ok := dataType.(expr.UserType); ok { return userType.Name(), userType.ID() @@ -313,8 +334,8 @@ func transformDataTypeName(dataType expr.DataType) (string, string) { return dataType.Name(), "" } -// canonicalValidatorView gives a default result view the same identity used by -// validation calls that omit an explicit view. +// canonicalValidatorView returns an empty string for the default result view +// so it matches validation calls that omit a view name. func canonicalValidatorView(view string) string { if view == expr.DefaultView { return "" diff --git a/codegen/service/service_names_test.go b/codegen/service/service_names_test.go index ecccaaebee..f10ff39310 100644 --- a/codegen/service/service_names_test.go +++ b/codegen/service/service_names_test.go @@ -134,11 +134,8 @@ func TestServiceNameRolesOwnDeclarationKinds(t *testing.T) { {serviceAPIVersionNameRole, codegen.NameConstant}, {serviceNameConstantRole, codegen.NameConstant}, {serviceMethodNamesRole, codegen.NameVariable}, - {serviceMethodEventNameRole, codegen.NameType}, {serviceServerStreamNameRole, codegen.NameType}, {serviceClientStreamNameRole, codegen.NameType}, - {serviceStreamNameRole, codegen.NameType}, - {serviceEventNameRole, codegen.NameType}, {serviceErrorConstructorNameRole, codegen.NameFunction}, {serviceViewConstructorNameRole, codegen.NameFunction}, {servicePrivateProjectionConstructorNameRole, codegen.NameFunction}, @@ -163,6 +160,11 @@ func TestServiceNameRolesOwnDeclarationKinds(t *testing.T) { {serviceInterceptorResultAccessNameRole, codegen.NameType}, {serviceInterceptorStreamingPayloadAccessNameRole, codegen.NameType}, {serviceInterceptorStreamingResultAccessNameRole, codegen.NameType}, + {serviceInterceptorMethodInfoNameRole, codegen.NameType}, + {serviceInterceptorServerUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorClientUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingSendInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingRecvInfoNameRole, codegen.NameType}, {serviceServerEndpointWrapperNameRole, codegen.NameFunction}, {serviceClientEndpointWrapperNameRole, codegen.NameFunction}, {serviceServerInterceptorWrapperNameRole, codegen.NameFunction}, diff --git a/codegen/service/service_package_path.go b/codegen/service/service_package_path.go new file mode 100644 index 0000000000..ed3487fc10 --- /dev/null +++ b/codegen/service/service_package_path.go @@ -0,0 +1,96 @@ +// This file assigns every generated service package path once for a complete +// planning run. Later planning phases read the retained path instead of +// rebuilding it from a service name. +package service + +import ( + "fmt" + "path" + "sort" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceDesignID identifies declarations contributed by one service in one + // API. Two roots with the same value cannot be ordered without another design + // fact, so planning rejects them. + serviceDesignID struct { + api string + service string + } +) + +// allocateServicePackagePaths returns one generated package path for every +// exact service name in inputs. Exact names share a path across APIs. Different +// names that produce the same normal path receive numeric suffixes ordered by +// their authored names. +func allocateServicePackagePaths(genpkg string, inputs []PlanInput) (map[string]string, error) { + names := make(map[string]struct{}) + designs := make(map[serviceDesignID]*expr.RootExpr) + for _, input := range inputs { + for _, service := range input.Root.Services { + identity := serviceDesignID{api: input.Root.API.Name, service: service.Name} + if root := designs[identity]; root != nil && root != input.Root { + return nil, fmt.Errorf( + "service %q in API %q is planned by more than one root", + service.Name, + input.Root.API.Name, + ) + } + designs[identity] = input.Root + names[service.Name] = struct{}{} + } + } + + orderedNames := make([]string, 0, len(names)) + groups := make(map[string][]string) + reserved := make(map[string]struct{}) + for name := range names { + orderedNames = append(orderedNames, name) + base := servicePackageName(name) + groups[base] = append(groups[base], name) + reserved[base] = struct{}{} + } + sort.Strings(orderedNames) + for _, names := range groups { + sort.Strings(names) + } + + assignedNames := make(map[string]string, len(names)) + used := make(map[string]struct{}, len(names)) + for _, name := range orderedNames { + base := servicePackageName(name) + if groups[base][0] == name { + assignedNames[name] = base + used[base] = struct{}{} + continue + } + for suffix := 2; ; suffix++ { + candidate := base + strconv.Itoa(suffix) + if _, exists := reserved[candidate]; exists { + continue + } + if _, exists := used[candidate]; exists { + continue + } + assignedNames[name] = candidate + used[candidate] = struct{}{} + break + } + } + + paths := make(map[string]string, len(assignedNames)) + for name, packageName := range assignedNames { + paths[name] = path.Join(genpkg, packageName) + } + return paths, nil +} + +// servicePackageName returns the package directory naturally produced by one +// authored service name before collisions are resolved. +func servicePackageName(name string) string { + return codegen.SnakeCase(codegen.Goify(name, false)) +} diff --git a/codegen/service/service_package_path_test.go b/codegen/service/service_package_path_test.go new file mode 100644 index 0000000000..9cdb0454f0 --- /dev/null +++ b/codegen/service/service_package_path_test.go @@ -0,0 +1,154 @@ +// This file verifies one complete service planning run assigns generated +// package paths before any service declarations are collected. +package service + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // servicePathTestInput names one API and service included in a complete + // planning run. + servicePathTestInput struct { + api string + service string + } +) + +// TestNewPlansAssignsStableServicePackagePaths verifies distinct service names +// that have the same normal path receive stable unique paths. A service whose +// name naturally contains a numeric suffix keeps that path. +func TestNewPlansAssignsStableServicePackagePaths(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "dash api", service: "read-value"}, + {api: "underscore api", service: "read_value"}, + {api: "numbered api", service: "read_value2"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "numbered api", service: "read_value2"}, + {api: "underscore api", service: "read_value"}, + {api: "dash api", service: "read-value"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "read-value": "generated.local/gen/read_value", + "read_value": "generated.local/gen/read_value3", + "read_value2": "generated.local/gen/read_value2", + }, forward) +} + +// TestNewPlansSharesServicePackagePathAcrossRoots verifies the same exact +// service name uses one generated package when two APIs contribute to it. +func TestNewPlansSharesServicePackagePathAcrossRoots(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "first api", service: "Shared"}, + {api: "second api", service: "Shared"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "second api", service: "Shared"}, + {api: "first api", service: "Shared"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "first api/Shared": "generated.local/gen/shared", + "second api/Shared": "generated.local/gen/shared", + }, forward) +} + +// TestNewPlansRejectsRepeatedAPIService verifies two roots cannot contribute +// indistinguishable declarations for the same API and service. +func TestNewPlansRejectsRepeatedAPIService(t *testing.T) { + first := servicePathTestRoot(t, "same api", "Shared") + second := servicePathTestRoot(t, "same api", "Shared") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + + require.EqualError(t, err, `service "Shared" in API "same api" is planned by more than one root`) +} + +// TestNewPlansKeepsNoncollidingServicePackagePaths verifies ordinary service +// names keep the paths generated by earlier Goa versions. +func TestNewPlansKeepsNoncollidingServicePackagePaths(t *testing.T) { + paths := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "storage api", service: "Storage"}, + {api: "audit api", service: "AuditLog"}, + }) + + require.Equal(t, map[string]string{ + "Storage": "generated.local/gen/storage", + "AuditLog": "generated.local/gen/audit_log", + }, paths) +} + +// plannedServicePackagePaths builds and freezes one complete planning run, then +// returns the retained package path for every input service. +func plannedServicePackagePaths(t *testing.T, inputs []servicePathTestInput) map[string]string { + t.Helper() + roots := make([]*expr.RootExpr, len(inputs)) + evaluated := make([]eval.Root, len(inputs)) + planInputs := make([]PlanInput, len(inputs)) + for index, input := range inputs { + root := servicePathTestRoot(t, input.api, input.service) + roots[index] = root + evaluated[index] = root + planInputs[index] = PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), + } + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + plans, err := NewPlans(generation, planInputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + paths := make(map[string]string, len(inputs)) + sharedNames := make(map[string]int) + for _, input := range inputs { + sharedNames[input.service]++ + } + for index, plan := range plans { + service := roots[index].Service(inputs[index].service) + serviceImport, _, err := plan.ServicePackageImports(service) + require.NoError(t, err) + require.NoError(t, plan.Link()) + require.Equal(t, path.Base(serviceImport.Path), plan.Services().Get(service.Name).PathName) + key := inputs[index].service + if sharedNames[key] > 1 { + key = inputs[index].api + "/" + key + } + paths[key] = serviceImport.Path + } + return paths +} + +// servicePathTestRoot creates one evaluated design with a single service and +// gives its API the identity used to order shared package declarations. +func servicePathTestRoot(t *testing.T, api, service string) *expr.RootExpr { + t.Helper() + root := codegen.RunDSL(t, func() { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + root.API.Name = api + return root +} diff --git a/codegen/service/service_plan_compile_contract_test.go b/codegen/service/service_plan_compile_contract_test.go index 58dd77e7e2..2a279ba5e4 100644 --- a/codegen/service/service_plan_compile_contract_test.go +++ b/codegen/service/service_plan_compile_contract_test.go @@ -14,21 +14,58 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service/testdata" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) -// TestServicePackageNameUsesClaimedImportPath verifies mixed-case service -// names keep the canonical Go casing derived from their claimed package path. +// TestServicePackageNameUsesClaimedImportPath verifies service package names +// remain lowercase after the final generated import path is claimed. func TestServicePackageNameUsesClaimedImportPath(t *testing.T) { root := codegen.RunDSL(t, func() { - dsl.Service("UnionValidation", func() { - dsl.Method("Read", func() {}) + dsl.Service("api_key_service", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.OneOf("credential", func() { + dsl.Attribute("api_key", dsl.String) + dsl.Attribute("token", dsl.String) + }) + dsl.Required("credential") + }) + }) }) }) - plan := retainedServicePlanForPackage(t, root, "generated.local/gen") - require.Equal(t, "unionValidation", plan.Services().Get("UnionValidation").PkgName) + plan := retainedServicePlanForPackage(t, root) + require.Equal(t, "apikeyservice", plan.Services().Get("api_key_service").PkgName) + + files, err := Files(plan) + require.NoError(t, err) + directory := t.TempDir() + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + source, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "service.go")) + require.NoError(t, err) + require.Contains(t, string(source), "package apikeyservice") + unionSource, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "unions.go")) + require.NoError(t, err) + require.Contains(t, string(unionSource), "package apikeyservice") +} + +// TestRepeatedInlineMethodErrorsCompile verifies equivalent method errors use +// one generated public error declaration. +func TestRepeatedInlineMethodErrorsCompile(t *testing.T) { + root := codegen.RunDSL(t, testdata.RepeatedInlineErrorsDSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) + + rendered := renderedServiceFiles(t, files) + serviceSource := string(rendered[filepath.Join(codegen.Gendir, "secured", "service.go")]) + require.Equal(t, 1, strings.Count(serviceSource, "type InvalidScopes string")) } // TestNestedViewValidatorCollisionCompiles catches a parent validator that @@ -69,7 +106,7 @@ func TestNestedViewValidatorCollisionCompiles(t *testing.T) { }) }) - plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + plan := retainedServicePlanForPackage(t, root) data := plan.Services().Get("Values") var childValidation, parentValidation *ValidateData for _, projected := range data.projectedTypes { @@ -91,14 +128,36 @@ func TestNestedViewValidatorCollisionCompiles(t *testing.T) { files, err := Files(plan) require.NoError(t, err) files = append(files, ExampleServiceFiles(plan)...) - compileGeneratedServiceFiles(t, "generated.local", files) + compileGeneratedServiceFiles(t, files) +} + +// TestMixedResultStarterCompiles checks that a fresh starter implements the +// service method that returns one normal result and may also send stream values. +func TestMixedResultStarterCompiles(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"result and stream", testdata.MixedResultsEndpointDSL}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) + }) + } } -// retainedServicePlanForPackage runs the service lifecycle with the generated -// import root used by the temporary compilation module. -func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr, generatedPackage string) *Plan { +// retainedServicePlanForPackage builds and links service generation data using +// the import path shared by these compilation tests. +func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr) *Plan { t.Helper() - generation, err := codegen.NewGeneration(generatedPackage, []eval.Root{root}) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) @@ -109,11 +168,17 @@ func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr, generatedP // compileGeneratedServiceFiles renders files into a temporary module and runs // the Go compiler against every generated package. -func compileGeneratedServiceFiles(t *testing.T, modulePath string, files []*codegen.File) { +func compileGeneratedServiceFiles(t *testing.T, files []*codegen.File) { + compileGeneratedServiceFilesWith(t, files, nil) +} + +// compileGeneratedServiceFilesWith renders files and additional test source +// into a temporary module, then runs every generated package test. +func compileGeneratedServiceFilesWith(t *testing.T, files []*codegen.File, additional map[string]string) { t.Helper() directory := t.TempDir() goaRoot := serviceModuleDirectory(t, "goa.design/goa/v3") - module := "module " + modulePath + "\n\ngo 1.24\n\n" + + module := "module generated.local\n\ngo 1.24\n\n" + "require goa.design/goa/v3 v3.0.0\n\n" + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) @@ -121,6 +186,11 @@ func compileGeneratedServiceFiles(t *testing.T, modulePath string, files []*code _, err := file.Render(directory) require.NoError(t, err) } + for path, source := range additional { + fullPath := filepath.Join(directory, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700)) + require.NoError(t, os.WriteFile(fullPath, []byte(source), 0o600)) + } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() diff --git a/codegen/service/service_plan_render_contract_test.go b/codegen/service/service_plan_render_contract_test.go index 14d81dc2c4..c38ccd9d1c 100644 --- a/codegen/service/service_plan_render_contract_test.go +++ b/codegen/service/service_plan_render_contract_test.go @@ -55,7 +55,7 @@ func TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder(t *testing.T for _, plan := range forwardPlans { compileFiles = append(compileFiles, ExampleServiceFiles(plan)...) } - compileGeneratedServiceFiles(t, "generated.local", compileFiles) + compileGeneratedServiceFiles(t, compileFiles) } // TestServicePlanRenderingIsPure catches renderers that rebuild analysis, diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index c266c0ec68..3cb9f9d5a5 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -64,7 +64,8 @@ func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { require.Same(t, firstShared.Declaration, secondShared.Declaration) require.Len(t, first.unions, 1) require.Len(t, second.unions, 1) - require.Same(t, first.unions[0].Declaration, second.unions[0].Declaration) + require.Same(t, first.unions[0].TypeDeclaration, second.unions[0].TypeDeclaration) + require.Same(t, first.unions[0].KindDeclaration, second.unions[0].KindDeclaration) require.Equal(t, "Value", first.unions[0].Name) require.Equal(t, "ValueKind", first.unions[0].KindName) @@ -366,6 +367,7 @@ func TestFilesEmitCanonicalSharedDeclarationAcrossRoots(t *testing.T) { sharedPath := filepath.Join("gen", "types", "shared.go") require.Equal(t, 1, countFiles(forwardFiles, sharedPath)) forward := renderSingleFileAtPath(t, forwardFiles, sharedPath) + require.Contains(t, forward, "// The canonical shared declaration.") reversePlans := sharedDeclarationPlans(t, true) reverseFiles := mustServiceFiles(t, reversePlans...) @@ -455,15 +457,17 @@ func TestNewPlansRejectSharedUnionBranchLayoutConflicts(t *testing.T) { } func TestNewPlanRejectsPartialMultiRootPlanning(t *testing.T) { - _, firstRoot, secondRoot := sharedDeclarationRoots(t) + firstRoot, secondRoot := sharedDeclarationRoots(t) generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) _, err := NewPlan(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) require.ErrorContains(t, err, "requires all 2 generation roots") } +// sharedDeclarationPlans builds and links both service plans, optionally in +// reverse order. func sharedDeclarationPlans(t *testing.T, reverse bool) []*Plan { t.Helper() - _, firstRoot, secondRoot := sharedDeclarationRoots(t) + firstRoot, secondRoot := sharedDeclarationRoots(t) roots := []*expr.RootExpr{firstRoot, secondRoot} if reverse { slices.Reverse(roots) @@ -486,7 +490,9 @@ func sharedDeclarationPlans(t *testing.T, reverse bool) []*Plan { return plans } -func sharedDeclarationRoots(t *testing.T) (expr.UserType, *expr.RootExpr, *expr.RootExpr) { +// sharedDeclarationRoots builds two services that use the same type declared +// by the first service. +func sharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { t.Helper() var shared expr.UserType firstRoot := codegen.RunDSL(t, func() { @@ -508,9 +514,11 @@ func sharedDeclarationRoots(t *testing.T) (expr.UserType, *expr.RootExpr, *expr. }) }) }) - return shared, firstRoot, secondRoot + return firstRoot, secondRoot } +// conflictingSharedDeclarationRoots builds two services whose copies of the +// same type have different definitions. func conflictingSharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { t.Helper() var shared expr.UserType @@ -526,7 +534,7 @@ func conflictingSharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.Root }) }) }) - conflicting := shared.Dup(expr.DupAtt(shared.Attribute())).(expr.UserType) + conflicting := shared.Dup(expr.DupAtt(shared.Attribute())) conflicting.Attribute().Description = "The conflicting retained declaration." secondRoot := codegen.RunDSL(t, func() { dsl.Service("SecondService", func() { @@ -553,7 +561,7 @@ func copiedSharedDeclarationRoots(t *testing.T, mutate func(expr.UserType)) (*ex dsl.Method("Read", func() { dsl.Payload(shared) }) }) }) - copy := shared.Dup(expr.DupAtt(shared.Attribute())).(expr.UserType) + copy := shared.Dup(expr.DupAtt(shared.Attribute())) if mutate != nil { mutate(copy) } @@ -581,7 +589,7 @@ func copiedSharedUnionRoots(t *testing.T, mutate func(*expr.Union)) (*expr.RootE dsl.Method("Read", func() { dsl.Payload(container) }) }) }) - copy := container.Dup(expr.DupAtt(container.Attribute())).(expr.UserType) + copy := container.Dup(expr.DupAtt(container.Attribute())) union := expr.AsObject(copy).Attribute("value").Type.(*expr.Union) if mutate != nil { mutate(union) @@ -642,6 +650,39 @@ func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T require.Contains(t, code, "type ValueText2 string") } +// TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences verifies +// that a generated types package can render one forced type nested in another +// without importing the package currently being written. +func TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences(t *testing.T) { + root := codegen.RunDSL(t, func() { + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("value", dsl.String) + }) + dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("inner", inner) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + typeFile := findFile( + mustServiceFiles(t, plan), + filepath.Join("gen", "types", "outer.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "Inner *Inner") + files := mustServiceFiles(t, plan) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + func TestService(t *testing.T) { cases := []struct { Name string @@ -668,6 +709,7 @@ func TestService(t *testing.T) { {"service-service-level-error", testdata.ServiceErrorDSL}, {"service-custom-errors", testdata.CustomErrorsDSL}, {"service-custom-errors-custom-field", testdata.CustomErrorsCustomFieldDSL}, + {"service-repeated-inline-errors", testdata.RepeatedInlineErrorsDSL}, {"service-force-generate-type", testdata.ForceGenerateTypeDSL}, {"service-force-generate-type-explicit", testdata.ForceGenerateTypeExplicitDSL}, {"service-streaming-result", testdata.StreamingResultMethodDSL}, @@ -710,6 +752,7 @@ func TestStructPkgPath(t *testing.T) { recursiveFooPath := filepath.Join("gen", "foo", "recursive_foo.go") barPath := filepath.Join("gen", "bar", "bar.go") bazPath := filepath.Join("gen", "baz", "baz.go") + sharedPath := filepath.Join("gen", "shared", "shared.go") cases := []struct { Name string DSL func() @@ -722,6 +765,7 @@ func TestStructPkgPath(t *testing.T) { {"multiple", testdata.PkgPathMultipleDSL, []string{barPath, bazPath}}, {"nopkg", testdata.PkgPathNoDirDSL, nil}, {"dupes", testdata.PkgPathDupeDSL, []string{fooPath}}, + {"shared_roles", testdata.PkgPathSharedRolesDSL, []string{sharedPath}}, {"payload_attribute", testdata.PkgPathPayloadAttributeDSL, []string{fooPath}}, } for _, c := range cases { @@ -765,6 +809,53 @@ func TestStructPkgPath(t *testing.T) { } } +func TestRelocatedTypeDescriptions(t *testing.T) { + cases := []struct { + name string + dsl func() + path string + want string + }{ + { + name: "payload and result", + dsl: testdata.PkgPathDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is the payload and result type of the PkgPathMethod service A method.", + }, + { + name: "nested only", + dsl: testdata.PkgPathArrayDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is a named type defined in the service design.", + }, + { + name: "all method roles", + dsl: testdata.PkgPathSharedRolesDSL, + path: filepath.Join("gen", "shared", "shared.go"), + want: "Shared is the payload, streaming payload, result, and streaming result type\n// of the PkgPathSharedRoles service Exchange method.", + }, + { + name: "several methods and services", + dsl: testdata.PkgPathDupeDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is used by these service methods:\n" + + "// - PkgPathDupeMethod A: payload and result\n" + + "// - PkgPathDupeMethod B: payload and result\n" + + "// - PkgPathDupeMethod2 A: payload and result\n" + + "// - PkgPathDupeMethod2 B: payload and result", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, test.dsl) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), test.path) + require.NotNil(t, file) + require.Contains(t, renderSections(t, file.SectionTemplates), test.want) + }) + } +} + func TestStructPkgPath_UnionImportsJSON(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionDSL) plan := mustServicePlan(t, root) @@ -807,6 +898,8 @@ func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { require.Equal(t, []string{"Value", "Value", "Value"}, []string{firstUsesValue, secondUsesValue, thirdUsesValue}) } +// unionFieldType returns the generated type of the Value field in the named +// struct. func unionFieldType(code, owner string) string { prefix := "type " + owner + " struct {\n\tValue " start := strings.Index(code, prefix) diff --git a/codegen/service/templates.go b/codegen/service/templates.go index 8359ee40e7..3ea0f22e2e 100644 --- a/codegen/service/templates.go +++ b/codegen/service/templates.go @@ -36,7 +36,6 @@ const ( exampleServiceInitT = "example_service_init" exampleSecurityAuthfuncsT = "example_security_authfuncs" endpointT = "endpoint" - jsonrpcHandleStreamT = "jsonrpc_handle_stream" // Service templates serviceT = "service" diff --git a/codegen/service/templates/client_interceptor_wrappers.go.tpl b/codegen/service/templates/client_interceptor_wrappers.go.tpl index 6f074d0f38..91e2212147 100644 --- a/codegen/service/templates/client_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_wrappers.go.tpl @@ -7,11 +7,8 @@ func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } res, err := i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} @@ -25,11 +22,8 @@ func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep ctx: ctx, {{- if $interceptor.HasStreamingPayloadAccess }} sendWithContext: func(ctx context.Context, req {{ .ClientStream.SendTypeRef }}) error { - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ClientStream.SendTypeRef }}) @@ -40,10 +34,8 @@ func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep {{- end }} {{- if $interceptor.HasStreamingResultAccess }} recvWithContext: func(ctx context.Context) ({{ .ClientStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ClientStream.RecvWithContextName }}(ctx) @@ -55,11 +47,8 @@ func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep stream: stream, }, nil {{- else }} - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/client_interceptors.go.tpl b/codegen/service/templates/client_interceptors.go.tpl index ab89722bb9..50725935b7 100644 --- a/codegen/service/templates/client_interceptors.go.tpl +++ b/codegen/service/templates/client_interceptors.go.tpl @@ -7,6 +7,6 @@ type {{ .ClientInterceptorsDeclaration.Name }} interface { {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/endpoint.go.tpl b/codegen/service/templates/endpoint.go.tpl index d874434107..e15f537a4c 100644 --- a/codegen/service/templates/endpoint.go.tpl +++ b/codegen/service/templates/endpoint.go.tpl @@ -1,6 +1,10 @@ {{ comment .Description }} {{- if .ServerStream }} + {{- if .HasMixedResults }} +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { + {{- else }} func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { + {{- end }} {{- else }} func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { {{- end }} @@ -8,7 +12,7 @@ func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Contex // req is the HTTP request body stream. defer req.Close() {{- end }} -{{- if and .Result .ResultIsStruct (not .ServerStream) }} +{{- if and .Result .ResultIsStruct (or (not .ServerStream) .HasMixedResults) }} res = &{{ .ResultFullName }}{} {{- end }} {{- if .SkipResponseBodyEncodeDecode }} @@ -17,7 +21,9 @@ func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Contex {{- end }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} - {{- if .ServerStream }} + {{- if .HasMixedResults }} + view = {{ printf "%q" .ResultView }} + {{- else if .ServerStream }} stream.SetView({{ printf "%q" .ResultView }}) {{- else }} view = {{ printf "%q" .ResultView }} @@ -25,16 +31,5 @@ func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Contex {{- end }} {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") -{{- if and .ServerStream .IsJSONRPC .ResultFullName }} - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"progress"{{ else }}0{{ end }}){{ end }} - if err := stream.Send(ctx, notif); err != nil { return err } - // Final response - final := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"done"{{ else }}0{{ end }}){{ end }} - return stream.SendAndClose(ctx, final) - } -{{- end }} return } diff --git a/codegen/service/templates/example_client_interceptor.go.tpl b/codegen/service/templates/example_client_interceptor.go.tpl index 700b23b48b..d77925209a 100644 --- a/codegen/service/templates/example_client_interceptor.go.tpl +++ b/codegen/service/templates/example_client_interceptor.go.tpl @@ -11,7 +11,7 @@ func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info *{{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_server_interceptor.go.tpl b/codegen/service/templates/example_server_interceptor.go.tpl index 92dc0bc65c..172f9b449c 100644 --- a/codegen/service/templates/example_server_interceptor.go.tpl +++ b/codegen/service/templates/example_server_interceptor.go.tpl @@ -11,7 +11,7 @@ func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info *{{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/interceptors.go.tpl b/codegen/service/templates/interceptors.go.tpl index ce267ae0c6..18b01d2803 100644 --- a/codegen/service/templates/interceptors.go.tpl +++ b/codegen/service/templates/interceptors.go.tpl @@ -1,155 +1,100 @@ -// Public accessor methods for Info types +// Methods that provide information about each service call {{- range . }} + {{- $interceptor := . }} + {{- range .Methods }} -// Service returns the name of the service handling the request. +// Service returns the service selected for this interceptor call. func (info *{{ .InfoDeclaration.Name }}) Service() string { - return info.service + return "{{ $interceptor.Service }}" } -// Method returns the name of the method handling the request. +// Method returns the method selected for this interceptor call. func (info *{{ .InfoDeclaration.Name }}) Method() string { - return info.method -} - -// CallType returns the type of call the interceptor is handling. -func (info *{{ .InfoDeclaration.Name }}) CallType() goa.InterceptorCallType { - return info.callType + return "{{ .MethodName }}" } -// RawPayload returns the raw payload of the request. +// RawPayload returns the payload supplied for this interceptor call. func (info *{{ .InfoDeclaration.Name }}) RawPayload() any { return info.rawPayload } - {{- if .HasPayloadAccess }} - -// Payload returns a type-safe accessor for the method payload. -func (info *{{ .InfoDeclaration.Name }}) Payload() {{ .PayloadDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - {{- if hasEndpointStruct . }} - switch pay := info.RawPayload().(type) { - case *{{ .ServerStream.EndpointStruct }}: - return &{{ .PayloadAccessDeclaration.Name }}{payload: pay.Payload} - default: - return &{{ .PayloadAccessDeclaration.Name }}{payload: pay.({{ .PayloadRef }})} - } - {{- else }} - return &{{ .PayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ .PayloadRef }})} - {{- end }} - {{- end }} - default: - return nil - } - {{- else }} - {{- if hasEndpointStruct (index .Methods 0) }} - switch pay := info.RawPayload().(type) { - case *{{ (index .Methods 0).ServerStream.EndpointStruct }}: - return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: pay.Payload} - default: - return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: pay.({{ (index .Methods 0).PayloadRef }})} - } - {{- else }} - return &{{ (index .Methods 0).PayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ (index .Methods 0).PayloadRef }})} - {{- end }} - {{- end }} + {{- if .ServerUnaryInfoDeclaration }} + +// CallType reports that this is a server endpoint call. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } - {{- end }} + {{- end }} + {{- if .ClientUnaryInfoDeclaration }} - {{- if .HasResultAccess }} -// Result returns a type-safe accessor for the method result. -func (info *{{ .InfoDeclaration.Name }}) Result(res any) {{ .ResultDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .ResultAccessDeclaration.Name }}{result: res.({{ .ResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).ResultAccessDeclaration.Name }}{result: res.({{ (index .Methods 0).ResultRef }})} +// CallType reports that this is a client endpoint call. +func (info *{{ .ClientUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} {{- end }} + {{- if .StreamingSendInfoDeclaration }} + +// CallType reports that this is a stream send. +func (info *{{ .StreamingSendInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend } - {{- end }} + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} - {{- if .HasStreamingPayloadAccess }} -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *{{ .InfoDeclaration.Name }}) ClientStreamingPayload() {{ .StreamingPayloadDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccessDeclaration.Name }}{payload: info.RawPayload().({{ (index .Methods 0).StreamingPayloadRef }})} +// CallType reports that this is a stream receive. +func (info *{{ .StreamingRecvInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} {{- end }} + {{- if $interceptor.HasPayloadAccess }} + +// Payload returns this method's payload fields. +func (info *{{ .InfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .PayloadRef }})} } - {{- end }} + {{- if and .ServerUnaryInfoDeclaration (hasEndpointStruct .) }} - {{- if .HasStreamingResultAccess }} -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *{{ .InfoDeclaration.Name }}) ClientStreamingResult(res any) {{ .StreamingResultDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccessDeclaration.Name }}{result: res.({{ .StreamingResultRef }})} +// Payload returns this server method's payload fields. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.(*{{ .ServerStream.EndpointStruct }}).Payload} +} {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccessDeclaration.Name }}{result: res.({{ (index .Methods 0).StreamingResultRef }})} {{- end }} -} - {{- end }} + {{- if $interceptor.HasResultAccess }} - {{- if .HasStreamingPayloadAccess }} -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *{{ .InfoDeclaration.Name }}) ServerStreamingPayload(pay any) {{ .StreamingPayloadDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: pay.({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccessDeclaration.Name }}{payload: pay.({{ (index .Methods 0).StreamingPayloadRef }})} +// Result returns this method's result fields. +func (info *{{ .InfoDeclaration.Name }}) Result(res any) {{ $interceptor.ResultDeclaration.Name }} { + return &{{ .ResultAccessDeclaration.Name }}{result: res.({{ .ResultRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingPayloadAccess }} + +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingPayload() {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .StreamingPayloadRef }})} } - {{- end }} - {{- if .HasStreamingResultAccess }} -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *{{ .InfoDeclaration.Name }}) ServerStreamingResult() {{ .StreamingResultDeclaration.Name }} { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccessDeclaration.Name }}{result: info.RawPayload().({{ .StreamingResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccessDeclaration.Name }}{result: info.RawPayload().({{ (index .Methods 0).StreamingResultRef }})} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingPayload(payload any) {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: payload.({{ .StreamingPayloadRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingResultAccess }} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingResult(result any) {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: result.({{ .StreamingResultRef }})} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingResult() {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: info.rawPayload.({{ .StreamingResultRef }})} } + {{- end }} {{- end }} {{- end }} -{{- if hasPrivateImplementationTypes . }} -// Private implementation methods +{{- if hasPrivateAccessorMethods . }} +// Methods that read and write the selected payload and result fields {{- range . }} {{ $interceptor := . }} {{- range .Methods }} diff --git a/codegen/service/templates/interceptors_types.go.tpl b/codegen/service/templates/interceptors_types.go.tpl index 511409cfd3..1fd34d8f43 100644 --- a/codegen/service/templates/interceptors_types.go.tpl +++ b/codegen/service/templates/interceptors_types.go.tpl @@ -2,13 +2,36 @@ // Access interfaces for interceptor payloads and results type ( {{- range . }} - // {{ .InfoDeclaration.Name }} provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - {{ .InfoDeclaration.Name }} struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // {{ .InfoDeclaration.Name }} describes the service call currently passed to the interceptor. + {{ .InfoDeclaration.Name }} interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + {{- if .HasPayloadAccess }} + // Payload returns the selected fields from the method payload. + Payload() {{ .PayloadDeclaration.Name }} + {{- end }} + {{- if .HasResultAccess }} + // Result returns the selected fields from the method result. + Result(any) {{ .ResultDeclaration.Name }} + {{- end }} + {{- if .HasStreamingPayloadAccess }} + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() {{ .StreamingPayloadDeclaration.Name }} + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) {{ .StreamingPayloadDeclaration.Name }} + {{- end }} + {{- if .HasStreamingResultAccess }} + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) {{ .StreamingResultDeclaration.Name }} + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() {{ .StreamingResultDeclaration.Name }} + {{- end }} } {{- if .HasPayloadAccess }} @@ -70,8 +93,36 @@ type ( ) {{- if hasPrivateImplementationTypes . }} -// Private implementation types +// Types used to provide information about each service call type ( + {{- range . }} + {{- range .Methods }} + {{ .InfoDeclaration.Name }} struct { + rawPayload any + } + {{- if .ServerUnaryInfoDeclaration }} + {{ .ServerUnaryInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .ClientUnaryInfoDeclaration }} + {{ .ClientUnaryInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingSendInfoDeclaration }} + {{ .StreamingSendInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} + {{ .StreamingRecvInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- end }} + {{- end }} + {{- range . }} {{- range .Methods }} {{- if .PayloadAccessDeclaration }} diff --git a/codegen/service/templates/jsonrpc_handle_stream.go.tpl b/codegen/service/templates/jsonrpc_handle_stream.go.tpl deleted file mode 100644 index d4f2f78b82..0000000000 --- a/codegen/service/templates/jsonrpc_handle_stream.go.tpl +++ /dev/null @@ -1,17 +0,0 @@ -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *{{ .ExampleStructDeclaration.Name }}) HandleStream(ctx context.Context, stream {{ .ServicePkg }}.{{ .StreamDeclaration.Name }}) error { - log.Printf(ctx, "{{ .VarName }}.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/codegen/service/templates/server_interceptor_wrappers.go.tpl b/codegen/service/templates/server_interceptor_wrappers.go.tpl index 72662045c1..0f0c481ac4 100644 --- a/codegen/service/templates/server_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_wrappers.go.tpl @@ -11,11 +11,8 @@ func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep ctx: ctx, {{- if $interceptor.HasStreamingResultAccess }} sendWithContext: func(ctx context.Context, req {{ .ServerStream.SendTypeRef }}) error { - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ServerStream.SendTypeRef }}) @@ -26,10 +23,8 @@ func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep {{- end }} {{- if $interceptor.HasStreamingPayloadAccess }} recvWithContext: func(ctx context.Context) ({{ .ServerStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ServerStream.RecvWithContextName }}(ctx) @@ -41,22 +36,16 @@ func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.Intercep stream: stream, } {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} return endpoint(ctx, req) {{- end }} {{- else }} - info := &{{ $interceptor.InfoDeclaration.Name }}{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/server_interceptors.go.tpl b/codegen/service/templates/server_interceptors.go.tpl index 61f30c4a91..694b1b83b3 100644 --- a/codegen/service/templates/server_interceptors.go.tpl +++ b/codegen/service/templates/server_interceptors.go.tpl @@ -7,6 +7,6 @@ type {{ .ServerInterceptorsDeclaration.Name }} interface { {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/service.go.tpl b/codegen/service/templates/service.go.tpl index c30b7af3fc..7a71c78497 100644 --- a/codegen/service/templates/service.go.tpl +++ b/codegen/service/templates/service.go.tpl @@ -1,10 +1,6 @@ {{ comment .Description }} type {{ .ServiceDeclaration.Name }} interface { -{{- if isJSONRPCWebSocket . }} - {{ comment "HandleStream handles the JSON-RPC WebSocket streaming connection. Calling Recv() on the stream will dispatch requests to the appropriate methods below." }} - HandleStream(context.Context, Stream) error -{{- end }} {{- range .Methods }} {{ comment .Description }} {{- if .SkipResponseBodyEncodeDecode }} @@ -23,21 +19,12 @@ type {{ .ServiceDeclaration.Name }} interface { {{- end }} {{- end }} {{- if .ServerStream }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 2) }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}err error) - {{- else if .HasMixedResults }} - {{- /* Mixed results: the method may be invoked in a unary (JSON) or streaming (SSE) mode. - The server stream is non-nil only when the transport negotiates streaming. */}} + {{- if .HasMixedResults }} + {{- /* Mixed results always receive a server stream. Ordinary HTTP supplies + one that discards sent values, while SSE sends them to the client. */}} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) - {{- else if and .IsJSONRPCWebSocket (eq .ServerStream.Kind 4) }} - {{ .VarName }}(context.Context, {{ .ServerStream.Interface }}) (err error) {{- else }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 3) .PayloadRef }} - {{- /* JSON-RPC WebSocket server streaming with non-streaming payload */ -}} - {{ .VarName }}(context.Context, {{ .PayloadRef }}, {{ .ServerStream.Interface }}) (err error) - {{- else }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) - {{- end }} + {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) {{- end }} {{- else }} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}body io.ReadCloser, {{ end }}{{ if .Result }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}{{ end }}err error) @@ -74,86 +61,29 @@ var {{ .MethodNamesDeclaration.Name }} = [{{ len .Methods }}]string{ {{ range .M {{- range .Methods }} {{- if .ServerStream }} {{ template "stream_interface" (streamInterfaceFor "server" . .ServerStream) }} - {{- /* Emit client stream interface */ -}} - {{- if .IsJSONRPC }} - {{- if .ClientStream }} - {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- else }} {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- end }} -{{- end }} - -{{- if hasJSONRPCStreaming . }} - {{- if isJSONRPCWebSocket . }} - {{ template "jsonrpc_websocket_stream" . }} - {{- else }} - {{ template "jsonrpc_sse_stream" . }} {{- end }} {{- end }} {{- define "stream_interface" }} -{{- if and .IsJSONRPCSSE (eq .Type "server") }} -{{ printf "%s is the interface implemented by the result type for the %s method." .EventDeclaration.Name .Endpoint | comment }} -type {{ .EventDeclaration.Name }} interface { - is{{ .MethodVarName }}Event() -} - -{{ printf "is%sEvent implements the %s interface." .MethodVarName .EventDeclaration.Name | comment }} -func ({{ .Stream.SendTypeRef }}) is{{ .MethodVarName }}Event() {} - -{{ printf "%s allows streaming instances of %s over SSE." .Stream.Interface .Stream.SendTypeRef | comment }} -type {{ .Stream.Interface }} interface { - {{- if .Stream.SendTypeRef }} - {{ comment .Stream.SendDesc }} - {{ comment "IMPORTANT: Send only sends JSON-RPC notifications. Use SendAndClose to send a final response." }} - Send(ctx context.Context, event {{ .EventDeclaration.Name }}) error - {{- if .Stream.SendAndCloseName }} - {{ comment .Stream.SendAndCloseDesc }} - {{ comment "The result will be sent as a JSON-RPC response with the original request ID." }} - {{ comment "If the result has an ID field populated, that ID will be used instead of the request ID." }} - {{ .Stream.SendAndCloseName }}(ctx context.Context, event {{ .EventDeclaration.Name }}) error - {{- end }} - {{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error - {{- if .IsViewedResult }} - {{ comment "SetView sets the result view applied to later values sent on this stream." }} - SetView(view string) - {{- end }} -} -{{- else }} {{- $elemType := .Stream.SendTypeRef -}} {{- if not $elemType }}{{- $elemType = .Stream.RecvTypeRef }}{{- end }} {{ printf "%s allows streaming instances of %s to the client." .Stream.Interface $elemType | comment }} type {{ .Stream.Interface }} interface { {{- if .Stream.SendTypeRef }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "SendNotification sends a JSON-RPC notification (no response expected)." }} - SendNotification(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendResponse sends a JSON-RPC response with the original request ID." }} - SendResponse(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(context.Context, error) error - {{- else }} {{ comment .Stream.SendDesc }} {{ .Stream.SendName }}({{ .Stream.SendTypeRef }}) error {{ comment .Stream.SendWithContextDesc }} {{ .Stream.SendWithContextName }}(context.Context, {{ .Stream.SendTypeRef }}) error - {{- end }} {{- end }} - {{- if and .Stream.RecvTypeRef (not .IsJSONRPCWebSocket) }} + {{- if .Stream.RecvTypeRef }} {{ comment .Stream.RecvDesc }} {{ .Stream.RecvName }}() ({{ .Stream.RecvTypeRef }}, error) {{ comment .Stream.RecvWithContextDesc }} {{ .Stream.RecvWithContextName }}(context.Context) ({{ .Stream.RecvTypeRef }}, error) {{- end }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "Close closes the stream." }} - Close() error - {{- else if .Stream.MustClose }} + {{- if .Stream.MustClose }} {{ comment "Close closes the stream." }} Close() error {{- end }} @@ -164,71 +94,3 @@ type {{ .Stream.Interface }} interface { {{- end }} } {{- end }} -{{- end }} - -{{- define "jsonrpc_websocket_stream" }} -{{ printf "Stream defines the interface for managing a WebSocket streaming connection in the %s server. It allows sending results, sending errors, receiving requests, and closing the connection. This interface is used by the service to interact with clients over WebSocket using JSON-RPC." .Name | comment }} -type {{ .StreamDeclaration.Name }} interface { -{{- range .Methods }} - {{- if .Result }} - {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method (no response expected)." .VarName .Name | comment }} - Send{{ .VarName }}Notification(ctx context.Context, result {{ .ResultRef }}{{ if and .ViewedResult (not .ViewedResult.ViewName) }}, view string{{ end }}) error - {{ printf "Send%sResponse sends a JSON-RPC response for the %s method with the given ID." .VarName .Name | comment }} - Send{{ .VarName }}Response(ctx context.Context, id any, result {{ .ResultRef }}{{ if and .ViewedResult (not .ViewedResult.ViewName) }}, view string{{ end }}) error - {{- end }} -{{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id any, err error) error - {{ printf "Recv reads JSON-RPC requests from the %s service WebSocket stream and dispatches them to the appropriate method." .Name | comment }} - Recv(ctx context.Context) error - {{ comment "Close closes the stream." }} - Close() error -} -{{- end }} - -{{- define "jsonrpc_sse_stream" }} -{{- $hasResults := false }} -{{- $hasErrors := false }} -{{- $resultTypes := "" }} -{{- range (dedupeByResult .Methods) }} - {{- if .Result }} - {{- $hasResults = true }} - {{- if $resultTypes }} - {{- $resultTypes = printf "%s, %s" $resultTypes .ResultRef }} - {{- else }} - {{- $resultTypes = .ResultRef }} - {{- end }} - {{- end }} -{{- end }} -{{- range .Methods }} - {{- if .Errors }}{{ $hasErrors = true }}{{ end }} -{{- end }} -{{ printf "Stream defines the interface for managing an SSE streaming connection in the %s server. It allows sending notifications and final responses. This interface is used by the service to interact with clients over SSE using JSON-RPC." .Name | comment }} -type {{ .StreamDeclaration.Name }} interface { -{{- if $hasResults }} - {{ comment "Send sends an event (notification or response) to the client." }} - {{ comment "For notifications, the result should not have an ID field." }} - {{ comment "For responses, the result must have an ID field." }} - {{ printf "Accepted types: %s" $resultTypes | comment }} - Send(ctx context.Context, event {{ .EventDeclaration.Name }}) error -{{- end }} -{{- if $hasErrors }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error -{{- end }} -} - -{{- if $hasResults }} -{{ printf "Event is the interface implemented by all result types that can be sent via the %s Stream." .Name | comment }} -type {{ .EventDeclaration.Name }} interface { - is{{ .VarName }}Event() -} - - {{- range (dedupeByResult .Methods) }} - {{- if .Result }} -{{ printf "is%sEvent implements the Event interface." $.VarName | comment }} -func ({{ .ResultRef }}) is{{ $.VarName }}Event() {} - {{- end }} - {{- end }} -{{- end }} -{{- end }} diff --git a/codegen/service/templates/service_endpoint_method.go.tpl b/codegen/service/templates/service_endpoint_method.go.tpl index 089d6d1e7f..638ea18c98 100644 --- a/codegen/service/templates/service_endpoint_method.go.tpl +++ b/codegen/service/templates/service_endpoint_method.go.tpl @@ -121,6 +121,7 @@ func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .S {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} {{- if .HasMixedResults }} + {{- if .ResultRef }} res, {{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view, {{ end }}{{ end }}err := s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) if err != nil { return nil, err @@ -138,28 +139,11 @@ func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .S {{- else }} return res, nil {{- end }} - {{- else }} - {{- if and .IsJSONRPCWebSocket (eq .ServerStream.Kind 4) }} - return nil, s.{{ .VarName }}(ctx, ep.Stream) {{- else }} return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} - {{- end }} - {{- else }} - {{- /* JSON-RPC WebSocket client streaming: no stream parameter, just payload */ -}} - {{- if .PayloadRef }} - p := req.({{ .PayloadRef }}) - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx, p) - {{- else }} - return nil, s.{{ .VarName }}(ctx, p) - {{- end }} {{- else }} - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx) - {{- else }} - return nil, s.{{ .VarName }}(ctx) - {{- end }} + return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} {{- end }} {{- else if .SkipRequestBodyEncodeDecode }} diff --git a/codegen/service/templates/service_endpoint_stream_struct.go.tpl b/codegen/service/templates/service_endpoint_stream_struct.go.tpl index b26b32f855..04a4ae33e4 100644 --- a/codegen/service/templates/service_endpoint_stream_struct.go.tpl +++ b/codegen/service/templates/service_endpoint_stream_struct.go.tpl @@ -5,10 +5,6 @@ type {{ .ServerStream.EndpointStruct }} struct { {{- if .PayloadRef }} {{ comment "Payload is the method payload." }} Payload {{ .PayloadRef }} -{{- end }} -{{- if .IsJSONRPC }} - {{ comment "RequestID is the JSON-RPC request ID (available for JSON-RPC transports)." }} - RequestID any {{- end }} {{ printf "Stream is the server stream used by the %q method to send data." .Name | comment }} Stream {{ .ServerStream.Interface }} diff --git a/codegen/service/templates/type_validate.go.tpl b/codegen/service/templates/type_validate.go.tpl index 0bb57bda84..93ef668580 100644 --- a/codegen/service/templates/type_validate.go.tpl +++ b/codegen/service/templates/type_validate.go.tpl @@ -2,8 +2,10 @@ switch {{ .ArgVar }}.View { {{- range .ValidationCalls }} case {{ printf "%q" .View }}{{ if .Default }}, ""{{ end }}: + {{- if .Declaration }} err = {{ .Declaration.Name }}({{ $.ArgVar }}.Projected) {{- end }} + {{- end }} default: err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .ValidationCalls }}{{ printf "%q" .View }}, {{ end }} }) } @@ -15,18 +17,20 @@ for _, {{ $.Source }} := range {{ $.ArgVar }} { } } {{- else -}} - {{ .Validate }} - {{- range .Fields -}} - {{- if .IsRequired -}} + {{ .Validate }} + {{- range .Fields }} + {{- if .IsRequired }} if {{ $.Source }}.{{ goify .Name true }} == nil { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Source }})) } - {{- end }} + {{- end }} + {{- if .Call }} if {{ $.Source }}.{{ goify .Name true }} != nil { if err2 := {{ .Call.Declaration.Name }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { err = goa.MergeErrors(err, err2) } } - {{- end -}} + {{- end }} + {{- end }} {{- end -}} {{- end -}} diff --git a/codegen/service/templates/union_type.go.tpl b/codegen/service/templates/union_type.go.tpl index 7b5939bbd4..9957d801e3 100644 --- a/codegen/service/templates/union_type.go.tpl +++ b/codegen/service/templates/union_type.go.tpl @@ -1,71 +1,71 @@ -{{- /* Union sum-type definition and helpers. */ -}} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} {{- range .Fields }} {{- if .EmitPrimitiveAlias }} type {{ .FieldType }} {{ .PrimitiveAliasType }} {{- end }} {{- end }} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} {{ .FieldName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// {{ .Constructor }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func {{ .Constructor }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, {{ .FieldName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } return u.{{ .FieldName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -73,14 +73,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -89,11 +89,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: value = u.{{ .FieldName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -105,7 +105,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -114,28 +114,28 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/codegen/service/test_helpers_test.go b/codegen/service/test_helpers_test.go index f0cc9044b2..820f5813af 100644 --- a/codegen/service/test_helpers_test.go +++ b/codegen/service/test_helpers_test.go @@ -3,6 +3,7 @@ package service import ( + "path" "testing" "github.com/stretchr/testify/require" @@ -34,3 +35,9 @@ func planTestServices(root *expr.RootExpr, generation *codegen.Generation) error _, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) return err } + +// servicePackagePath returns the natural generated package path used by tests +// that build one noncolliding service package directly. +func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { + return path.Join(genpkg, servicePackageName(service.Name)) +} diff --git a/codegen/service/testdata/dedup_event_marker_dsls.go b/codegen/service/testdata/dedup_event_marker_dsls.go deleted file mode 100644 index 7df9ac05dc..0000000000 --- a/codegen/service/testdata/dedup_event_marker_dsls.go +++ /dev/null @@ -1,28 +0,0 @@ -// This file defines streaming service designs used to verify that shared result -// types emit one event marker method in generated service code. -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// StreamingDuplicateResultTypesDSL defines two streaming methods that share the same -// result type to ensure event marker methods are not duplicated in generated service code. -var StreamingDuplicateResultTypesDSL = func() { - API("dedup-streaming", func() { JSONRPC(func() {}) }) - var SharedEvent = Type("SharedEvent", func() { - Attribute("message", String) - Required("message") - }) - Service("DupStreamService", func() { - JSONRPC(func() { POST("/stream") }) - Method("A", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("B", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) -} diff --git a/codegen/service/testdata/endpoint_dsls.go b/codegen/service/testdata/endpoint_dsls.go index d118014c9a..a2dbb20355 100644 --- a/codegen/service/testdata/endpoint_dsls.go +++ b/codegen/service/testdata/endpoint_dsls.go @@ -138,6 +138,32 @@ var MixedResultsEndpointDSL = func() { }) } +var MixedResultsWithViewsEndpointDSL = func() { + var ResultType = ResultType("application/vnd.mixed-result", func() { + TypeName("MixedResult") + Attributes(func() { + Attribute("id", String) + Attribute("detail", String) + }) + View("default", func() { + Attribute("id") + }) + View("detailed", func() { + Attribute("id") + Attribute("detail") + }) + }) + var EventType = Type("MixedEvent", func() { + Attribute("message", String) + }) + Service("MixedResultsWithViewsEndpoint", func() { + Method("MixedResultsWithViewsMethod", func() { + Result(ResultType) + StreamingResult(EventType) + }) + }) +} + var StreamingPayloadEndpointDSL = func() { var AType = Type("AType", func() { Attribute("a", String) diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden index d0b28f08e3..88a2fce5cb 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden @@ -21,7 +21,7 @@ type APIInterceptorServiceClientInterceptors struct { func NewAPIInterceptorServiceClientInterceptors() *APIInterceptorServiceClientInterceptors { return &APIInterceptorServiceClientInterceptors{} } -func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info *apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden index 218c82a154..5f6ada02ef 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden @@ -21,7 +21,7 @@ type APIInterceptorServiceServerInterceptors struct { func NewAPIInterceptorServiceServerInterceptors() *APIInterceptorServiceServerInterceptors { return &APIInterceptorServiceServerInterceptors{} } -func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info *apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden index 7715b020c9..9249be24da 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden @@ -21,7 +21,7 @@ type ChainedInterceptorServiceClientInterceptors struct { func NewChainedInterceptorServiceClientInterceptors() *ChainedInterceptorServiceClientInterceptors { return &ChainedInterceptorServiceClientInterceptors{} } -func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info *chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info *chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -41,7 +41,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info *chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden index 6634a3ba3c..8c55f6e51a 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden @@ -21,7 +21,7 @@ type ChainedInterceptorServiceServerInterceptors struct { func NewChainedInterceptorServiceServerInterceptors() *ChainedInterceptorServiceServerInterceptors { return &ChainedInterceptorServiceServerInterceptors{} } -func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info *chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info *chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -41,7 +41,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info *chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden index 4c196ac129..ed5daef245 100644 --- a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden @@ -21,7 +21,7 @@ type ClientInterceptorServiceClientInterceptors struct { func NewClientInterceptorServiceClientInterceptors() *ClientInterceptorServiceClientInterceptors { return &ClientInterceptorServiceClientInterceptors{} } -func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info *clientinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { +func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info clientinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden index f6d3f0370a..39e416240c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden @@ -21,7 +21,7 @@ type MultipleInterceptorsServiceClientInterceptors struct { func NewMultipleInterceptorsServiceClientInterceptors() *MultipleInterceptorsServiceClientInterceptors { return &MultipleInterceptorsServiceClientInterceptors{} } -func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *multipleinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *multipleinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden index 2f449e7b7a..dfe0a82193 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden @@ -21,7 +21,7 @@ type MultipleInterceptorsServiceServerInterceptors struct { func NewMultipleInterceptorsServiceServerInterceptors() *MultipleInterceptorsServiceServerInterceptors { return &MultipleInterceptorsServiceServerInterceptors{} } -func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *multipleinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *multipleinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden index 962a815f08..53d6685774 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden @@ -21,7 +21,7 @@ type MultipleServicesInterceptorsService2ClientInterceptors struct { func NewMultipleServicesInterceptorsService2ClientInterceptors() *MultipleServicesInterceptorsService2ClientInterceptors { return &MultipleServicesInterceptorsService2ClientInterceptors{} } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info *multipleservicesinterceptorsservice2.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice2.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx conte log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info *multipleservicesinterceptorsservice2.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice2.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden index 11eb2c4b7a..0433a02bf3 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden @@ -21,7 +21,7 @@ type MultipleServicesInterceptorsService2ServerInterceptors struct { func NewMultipleServicesInterceptorsService2ServerInterceptors() *MultipleServicesInterceptorsService2ServerInterceptors { return &MultipleServicesInterceptorsService2ServerInterceptors{} } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info *multipleservicesinterceptorsservice2.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice2.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx contex log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info *multipleservicesinterceptorsservice2.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice2.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden index ce06395fa3..5fe53ff08c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden @@ -21,7 +21,7 @@ type MultipleServicesInterceptorsServiceClientInterceptors struct { func NewMultipleServicesInterceptorsServiceClientInterceptors() *MultipleServicesInterceptorsServiceClientInterceptors { return &MultipleServicesInterceptorsServiceClientInterceptors{} } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *multipleservicesinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *multipleservicesinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden index 4126620994..12cab72bae 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden @@ -21,7 +21,7 @@ type MultipleServicesInterceptorsServiceServerInterceptors struct { func NewMultipleServicesInterceptorsServiceServerInterceptors() *MultipleServicesInterceptorsServiceServerInterceptors { return &MultipleServicesInterceptorsServiceServerInterceptors{} } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *multipleservicesinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -31,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *multipleservicesinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden index b973a20e52..510d569f8b 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden @@ -21,7 +21,7 @@ type ServerInterceptorByNameServiceServerInterceptors struct { func NewServerInterceptorByNameServiceServerInterceptors() *ServerInterceptorByNameServiceServerInterceptors { return &ServerInterceptorByNameServiceServerInterceptors{} } -func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info *serverinterceptorbynameservice.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorbynameservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden index 93d2b2f494..001136cab3 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden @@ -21,7 +21,7 @@ type ServerInterceptorServiceServerInterceptors struct { func NewServerInterceptorServiceServerInterceptors() *ServerInterceptorServiceServerInterceptors { return &ServerInterceptorServiceServerInterceptors{} } -func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info *serverinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden new file mode 100644 index 0000000000..3469fe70fe --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden @@ -0,0 +1,25 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultswithviewsendpoint "goa.design/goa/example/mixed_results_with_views_endpoint" +) + +// MixedResultsWithViewsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsWithViewsEndpointsrvc struct{} + +// NewMixedResultsWithViewsEndpoint returns the MixedResultsWithViewsEndpoint +// service implementation. +func NewMixedResultsWithViewsEndpoint() mixedresultswithviewsendpoint.Service { + return &mixedResultsWithViewsEndpointsrvc{} +} + +// MixedResultsWithViewsMethod implements MixedResultsWithViewsMethod. +func (s *mixedResultsWithViewsEndpointsrvc) MixedResultsWithViewsMethod(ctx context.Context, stream mixedresultswithviewsendpoint.MixedResultsWithViewsMethodServerStream) (res *mixedresultswithviewsendpoint.MixedResult, view string, err error) { + res = &mixedresultswithviewsendpoint.MixedResult{} + view = "default" + log.Printf(ctx, "mixedResultsWithViewsEndpoint.MixedResultsWithViewsMethod") + return +} diff --git a/codegen/service/testdata/golden/example_service-mixed-results.go.golden b/codegen/service/testdata/golden/example_service-mixed-results.go.golden new file mode 100644 index 0000000000..9589677a8f --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results.go.golden @@ -0,0 +1,24 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultsendpoint "goa.design/goa/example/mixed_results_endpoint" +) + +// MixedResultsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsEndpointsrvc struct{} + +// NewMixedResultsEndpoint returns the MixedResultsEndpoint service +// implementation. +func NewMixedResultsEndpoint() mixedresultsendpoint.Service { + return &mixedResultsEndpointsrvc{} +} + +// MixedResultsMethod implements MixedResultsMethod. +func (s *mixedResultsEndpointsrvc) MixedResultsMethod(ctx context.Context, p *mixedresultsendpoint.Payload, stream mixedresultsendpoint.MixedResultsMethodServerStream) (res *mixedresultsendpoint.ResultType, err error) { + res = &mixedresultsendpoint.ResultType{} + log.Printf(ctx, "mixedResultsEndpoint.MixedResultsMethod") + return +} diff --git a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden index 8002781164..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is a generated service type. +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden index 8002781164..a4b734a959 100644 --- a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden @@ -1,4 +1,8 @@ -// Foo is a generated service type. +// Foo is used by these service methods: +// - PkgPathDupeMethod A: payload and result +// - PkgPathDupeMethod B: payload and result +// - PkgPathDupeMethod2 A: payload and result +// - PkgPathDupeMethod2 B: payload and result type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden index 6c52fcfa6e..ee47af5611 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden @@ -1,4 +1,5 @@ -// Bar is a generated service type. +// Bar is the payload and result type of the MultiplePkgPathMethod service A +// method. type Bar struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden index c7e710ef14..ea6948e82c 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden @@ -1,4 +1,5 @@ -// Baz is a generated service type. +// Baz is the payload and result type of the MultiplePkgPathMethod service B +// method. type Baz struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden index 8002781164..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is a generated service type. +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden index 8002781164..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is a generated service type. +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden index 560f526744..f66c17de5b 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden @@ -1,4 +1,5 @@ -// RecursiveFoo is a generated service type. +// RecursiveFoo is the payload and result type of the PkgPathRecursiveMethod +// service A method. type RecursiveFoo struct { Foo *Foo } diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden new file mode 100644 index 0000000000..9751c6d575 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden @@ -0,0 +1,54 @@ + +// Service is the PkgPathSharedRoles service interface. +type Service interface { + // Exchange implements Exchange. + Exchange(context.Context, *shared.Shared, ExchangeServerStream) (res *shared.Shared, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "PkgPathSharedRoles" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [1]string{"Exchange"} + +// ExchangeServerStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeServerStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} + +// ExchangeClientStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeClientStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden new file mode 100644 index 0000000000..750d39f997 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden @@ -0,0 +1,5 @@ +// Shared is the payload, streaming payload, result, and streaming result type +// of the PkgPathSharedRoles service Exchange method. +type Shared struct { + IntField *int +} diff --git a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden index 8002781164..f7ff6cd37b 100644 --- a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is a generated service type. +// Foo is the payload and result type of the PkgPathMethod service A method. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/service_service-multi-union.go.golden b/codegen/service/testdata/golden/service_service-multi-union.go.golden index 94cb54030e..594b7ba7c7 100644 --- a/codegen/service/testdata/golden/service_service-multi-union.go.golden +++ b/codegen/service/testdata/golden/service_service-multi-union.go.golden @@ -34,24 +34,24 @@ type Union struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind A *TypeA B *TypeB } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindA identifies the a branch of the union. + // ValuesKindA identifies the a branch. ValuesKindA ValuesKind = "a" - // ValuesKindB identifies the b branch of the union. + // ValuesKindB identifies the b branch. ValuesKindB ValuesKind = "b" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -64,7 +64,7 @@ func NewValuesA(v *TypeA) Values { } } -// AsA returns the value of the a branch if set. +// AsA returns the value when the a branch is selected. func (u Values) AsA() (_ *TypeA, ok bool) { if u.kind != ValuesKindA { return @@ -72,7 +72,7 @@ func (u Values) AsA() (_ *TypeA, ok bool) { return u.A, true } -// SetA sets the a branch of the union. +// SetA selects the a branch and stores v. func (u *Values) SetA(v *TypeA) { u.kind = ValuesKindA u.A = v @@ -86,7 +86,7 @@ func NewValuesB(v *TypeB) Values { } } -// AsB returns the value of the b branch if set. +// AsB returns the value when the b branch is selected. func (u Values) AsB() (_ *TypeB, ok bool) { if u.kind != ValuesKindB { return @@ -94,13 +94,13 @@ func (u Values) AsB() (_ *TypeB, ok bool) { return u.B, true } -// SetB sets the b branch of the union. +// SetB selects the b branch and stores v. func (u *Values) SetB(v *TypeB) { u.kind = ValuesKindB u.B = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -140,7 +140,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindB: value = u.B default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden new file mode 100644 index 0000000000..2e573b0a27 --- /dev/null +++ b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden @@ -0,0 +1,45 @@ + +// Service is the Secured service interface. +type Service interface { + // Read implements Read. + Read(context.Context) (err error) + // Write implements Write. + Write(context.Context) (err error) + // Delete implements Delete. + Delete(context.Context) (err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "Secured" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [3]string{"Read", "Write", "Delete"} + +type InvalidScopes string + +// Error returns an error description. +func (e InvalidScopes) Error() string { + return "" +} + +// ErrorName returns the error name. +// +// Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 +func (e InvalidScopes) ErrorName() string { + return e.GoaErrorName() +} + +// GoaErrorName returns the error name. +func (e InvalidScopes) GoaErrorName() string { + return "invalid_scopes" +} diff --git a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden index 1187d54b64..34b64f592b 100644 --- a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden @@ -38,24 +38,24 @@ type U struct { Item *Item } -// Result is a sum-type union. +// Result holds exactly one of its branch values. type Result struct { kind ResultKind T *T U *U } -// ResultKind enumerates the union variants for Result. +// ResultKind records which Result branch is selected. type ResultKind string const ( - // ResultKindT identifies the t branch of the union. + // ResultKindT identifies the t branch. ResultKindT ResultKind = "t" - // ResultKindU identifies the u branch of the union. + // ResultKindU identifies the u branch. ResultKindU ResultKind = "u" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Result) Kind() ResultKind { return u.kind } @@ -68,7 +68,7 @@ func NewResultT(v *T) Result { } } -// AsT returns the value of the t branch if set. +// AsT returns the value when the t branch is selected. func (u Result) AsT() (_ *T, ok bool) { if u.kind != ResultKindT { return @@ -76,7 +76,7 @@ func (u Result) AsT() (_ *T, ok bool) { return u.T, true } -// SetT sets the t branch of the union. +// SetT selects the t branch and stores v. func (u *Result) SetT(v *T) { u.kind = ResultKindT u.T = v @@ -90,7 +90,7 @@ func NewResultU(v *U) Result { } } -// AsU returns the value of the u branch if set. +// AsU returns the value when the u branch is selected. func (u Result) AsU() (_ *U, ok bool) { if u.kind != ResultKindU { return @@ -98,13 +98,13 @@ func (u Result) AsU() (_ *U, ok bool) { return u.U, true } -// SetU sets the u branch of the union. +// SetU selects the u branch and stores v. func (u *Result) SetU(v *U) { u.kind = ResultKindU u.U = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Result) Validate() error { switch u.kind { case "": @@ -144,7 +144,7 @@ func (u Result) MarshalJSON() ([]byte, error) { case ResultKindU: value = u.U default: - return nil, fmt.Errorf("unexpected Result discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Result kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -218,13 +218,19 @@ func newResultOneof(vres *resultwithoneoftypeviews.ResultOneofView) *ResultOneof switch string(vres.Result.Kind()) { case "t": actual, _ := vres.Result.AsT() - obj := transformResultwithoneoftypeviewsTViewToT(actual) + var obj *T + if actual != nil { + obj = transformResultwithoneoftypeviewsTViewToT(actual) + } u := res.Result u.SetT((*T)(obj)) res.Result = u case "u": actual, _ := vres.Result.AsU() - obj := transformResultwithoneoftypeviewsUViewToU(actual) + var obj *U + if actual != nil { + obj = transformResultwithoneoftypeviewsUViewToU(actual) + } u := res.Result u.SetU((*U)(obj)) res.Result = u @@ -241,13 +247,19 @@ func newResultOneofView(res *ResultOneof) *resultwithoneoftypeviews.ResultOneofV switch string(res.Result.Kind()) { case "t": actual, _ := res.Result.AsT() - obj := transformTToResultwithoneoftypeviewsTView(actual) + var obj *resultwithoneoftypeviews.TView + if actual != nil { + obj = transformTToResultwithoneoftypeviewsTView(actual) + } u := vres.Result u.SetT((*resultwithoneoftypeviews.TView)(obj)) vres.Result = u case "u": actual, _ := res.Result.AsU() - obj := transformUToResultwithoneoftypeviewsUView(actual) + var obj *resultwithoneoftypeviews.UView + if actual != nil { + obj = transformUToResultwithoneoftypeviewsUView(actual) + } u := vres.Result u.SetU((*resultwithoneoftypeviews.UView)(obj)) vres.Result = u diff --git a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden index 0b42c17758..f790e24ecd 100644 --- a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden +++ b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden @@ -26,24 +26,24 @@ type Scope struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind Device alias.Alias Metric alias.Alias } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindDevice identifies the Device branch of the union. + // ValuesKindDevice identifies the Device branch. ValuesKindDevice ValuesKind = "Device" - // ValuesKindMetric identifies the Metric branch of the union. + // ValuesKindMetric identifies the Metric branch. ValuesKindMetric ValuesKind = "Metric" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -56,7 +56,7 @@ func NewValuesDevice(v alias.Alias) Values { } } -// AsDevice returns the value of the Device branch if set. +// AsDevice returns the value when the Device branch is selected. func (u Values) AsDevice() (_ alias.Alias, ok bool) { if u.kind != ValuesKindDevice { return @@ -64,7 +64,7 @@ func (u Values) AsDevice() (_ alias.Alias, ok bool) { return u.Device, true } -// SetDevice sets the Device branch of the union. +// SetDevice selects the Device branch and stores v. func (u *Values) SetDevice(v alias.Alias) { u.kind = ValuesKindDevice u.Device = v @@ -78,7 +78,7 @@ func NewValuesMetric(v alias.Alias) Values { } } -// AsMetric returns the value of the Metric branch if set. +// AsMetric returns the value when the Metric branch is selected. func (u Values) AsMetric() (_ alias.Alias, ok bool) { if u.kind != ValuesKindMetric { return @@ -86,13 +86,13 @@ func (u Values) AsMetric() (_ alias.Alias, ok bool) { return u.Metric, true } -// SetMetric sets the Metric branch of the union. +// SetMetric selects the Metric branch and stores v. func (u *Values) SetMetric(v alias.Alias) { u.kind = ValuesKindMetric u.Metric = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -126,7 +126,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindMetric: value = u.Metric default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/golden/service_service-union.go.golden b/codegen/service/testdata/golden/service_service-union.go.golden index 4f505a5b0c..2b3e8a8340 100644 --- a/codegen/service/testdata/golden/service_service-union.go.golden +++ b/codegen/service/testdata/golden/service_service-union.go.golden @@ -34,7 +34,7 @@ type ValuesInt int type ValuesString string -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind Int ValuesInt @@ -43,21 +43,21 @@ type Values struct { Bytes ValuesBytes } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindInt identifies the Int branch of the union. + // ValuesKindInt identifies the Int branch. ValuesKindInt ValuesKind = "Int" - // ValuesKindString identifies the String branch of the union. + // ValuesKindString identifies the String branch. ValuesKindString ValuesKind = "String" - // ValuesKindBoolean identifies the Boolean branch of the union. + // ValuesKindBoolean identifies the Boolean branch. ValuesKindBoolean ValuesKind = "Boolean" - // ValuesKindBytes identifies the Bytes branch of the union. + // ValuesKindBytes identifies the Bytes branch. ValuesKindBytes ValuesKind = "Bytes" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -70,7 +70,7 @@ func NewValuesInt(v ValuesInt) Values { } } -// AsInt returns the value of the Int branch if set. +// AsInt returns the value when the Int branch is selected. func (u Values) AsInt() (_ ValuesInt, ok bool) { if u.kind != ValuesKindInt { return @@ -78,7 +78,7 @@ func (u Values) AsInt() (_ ValuesInt, ok bool) { return u.Int, true } -// SetInt sets the Int branch of the union. +// SetInt selects the Int branch and stores v. func (u *Values) SetInt(v ValuesInt) { u.kind = ValuesKindInt u.Int = v @@ -92,7 +92,7 @@ func NewValuesString(v ValuesString) Values { } } -// AsString returns the value of the String branch if set. +// AsString returns the value when the String branch is selected. func (u Values) AsString() (_ ValuesString, ok bool) { if u.kind != ValuesKindString { return @@ -100,7 +100,7 @@ func (u Values) AsString() (_ ValuesString, ok bool) { return u.String, true } -// SetString sets the String branch of the union. +// SetString selects the String branch and stores v. func (u *Values) SetString(v ValuesString) { u.kind = ValuesKindString u.String = v @@ -114,7 +114,7 @@ func NewValuesBoolean(v ValuesBoolean) Values { } } -// AsBoolean returns the value of the Boolean branch if set. +// AsBoolean returns the value when the Boolean branch is selected. func (u Values) AsBoolean() (_ ValuesBoolean, ok bool) { if u.kind != ValuesKindBoolean { return @@ -122,7 +122,7 @@ func (u Values) AsBoolean() (_ ValuesBoolean, ok bool) { return u.Boolean, true } -// SetBoolean sets the Boolean branch of the union. +// SetBoolean selects the Boolean branch and stores v. func (u *Values) SetBoolean(v ValuesBoolean) { u.kind = ValuesKindBoolean u.Boolean = v @@ -136,7 +136,7 @@ func NewValuesBytes(v ValuesBytes) Values { } } -// AsBytes returns the value of the Bytes branch if set. +// AsBytes returns the value when the Bytes branch is selected. func (u Values) AsBytes() (_ ValuesBytes, ok bool) { if u.kind != ValuesKindBytes { return @@ -144,13 +144,13 @@ func (u Values) AsBytes() (_ ValuesBytes, ok bool) { return u.Bytes, true } -// SetBytes sets the Bytes branch of the union. +// SetBytes selects the Bytes branch and stores v. func (u *Values) SetBytes(v ValuesBytes) { u.kind = ValuesKindBytes u.Bytes = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -167,6 +167,9 @@ func (u Values) Validate() error { case ValuesKindBoolean: return nil case ValuesKindBytes: + if u.Bytes == nil { + return goa.MissingFieldError("value", "Values") + } return nil default: return goa.InvalidEnumValueError("type", u.kind, []any{ @@ -196,7 +199,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindBytes: value = u.Bytes default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..834ff3c45f --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden @@ -0,0 +1,11 @@ + + +// wrapAppendIdentify applies the identify server interceptor to endpoints. +func wrapAppendIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyAppendServerUnaryInfo{ + identifyAppendInfo: &identifyAppendInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..5b405a9606 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden @@ -0,0 +1,86 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyAppendInfo struct { + rawPayload any + } + identifyAppendServerUnaryInfo struct { + *identifyAppendInfo + } + identifyAppendPayload struct { + payload *types.Event + } +) + +// WrapAppendEndpoint wraps the Append endpoint with the server-side +// interceptors defined in the design. +func WrapAppendEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapAppendIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyAppendInfo) Service() string { + return "InterceptorWithExternalPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyAppendInfo) Method() string { + return "Append" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyAppendInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyAppendServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyAppendInfo) Payload() IdentifyPayload { + return &identifyAppendPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyAppendPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..f1df8ec7f9 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden @@ -0,0 +1,12 @@ + + +// wrapMethodAuthorization applies the authorization server interceptor to +// endpoints. +func wrapMethodAuthorization(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodServerUnaryInfo{ + authorizationMethodInfo: &authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..f0bcb449f5 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden @@ -0,0 +1,86 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodServerUnaryInfo struct { + *authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "InterceptorWithExternalReadPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *authorizationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden index 51c7d29aa9..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden index 73f2ce2eb7..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } @@ -17,11 +14,8 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden index 3c42633ff7..748c957019 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -41,34 +55,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden index 9609b832d4..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden index 15d5541a6f..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden index 9c7190d004..f3ba5bb029 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -41,34 +55,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden index 51c7d29aa9..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden index 0b49fb0bf0..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } @@ -17,11 +14,8 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden index 2c3ee03c9c..0028d9cea3 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -26,8 +31,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -42,34 +56,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden index 9609b832d4..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden index 6d2691de1d..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden index 5e5b38a05a..995ba966d3 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -26,8 +31,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -42,34 +56,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden index 51c7d29aa9..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden index 3285bd4c01..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } @@ -17,11 +14,8 @@ func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpo // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden index 5ee1ac3121..a49e821e22 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -41,34 +55,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) SetName(v string) { p.payload.Name = v diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden index 9609b832d4..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden index 0d0cb30e3a..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden index 027ad83acc..1bc223970c 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -41,34 +55,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) SetData(v string) { r.result.Data = v diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden new file mode 100644 index 0000000000..8a060fcb93 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// WrapGetInfoClientEndpoint wraps the GetInfo endpoint with the client +// interceptors defined in the design. +func WrapGetInfoClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientGetInfoJWTAuth(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..1ef244dd89 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden @@ -0,0 +1,21 @@ + + +// wrapGetInfoJWTAuth applies the JWTAuth server interceptor to endpoints. +func wrapGetInfoJWTAuth(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoServerUnaryInfo{ + jwtAuthGetInfoInfo: &jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} + +// wrapClientGetInfoJWTAuth applies the JWTAuth client interceptor to endpoints. +func wrapClientGetInfoJWTAuth(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoClientUnaryInfo{ + jwtAuthGetInfoInfo: &jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden new file mode 100644 index 0000000000..0bf6c0af3e --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden @@ -0,0 +1,71 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // JWTAuthInfo describes the service call currently passed to the interceptor. + JWTAuthInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + jwtAuthGetInfoInfo struct { + rawPayload any + } + jwtAuthGetInfoServerUnaryInfo struct { + *jwtAuthGetInfoInfo + } + jwtAuthGetInfoClientUnaryInfo struct { + *jwtAuthGetInfoInfo + } +) + +// WrapGetInfoEndpoint wraps the GetInfo endpoint with the server-side +// interceptors defined in the design. +func WrapGetInfoEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapGetInfoJWTAuth(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Service() string { + return "LeadingInitialismInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Method() string { + return "GetInfo" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *jwtAuthGetInfoInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *jwtAuthGetInfoServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *jwtAuthGetInfoClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..b9208fde0f --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// WrapClientMethodClientEndpoint wraps the ClientMethod endpoint with the +// client interceptors defined in the design. +func WrapClientMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientClientMethodIdentify(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..98ae184bc6 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,23 @@ + + +// wrapServerMethodIdentify applies the identify server interceptor to +// endpoints. +func wrapServerMethodIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyServerMethodServerUnaryInfo{ + identifyServerMethodInfo: &identifyServerMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} + +// wrapClientClientMethodIdentify applies the identify client interceptor to +// endpoints. +func wrapClientClientMethodIdentify(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyClientMethodClientUnaryInfo{ + identifyClientMethodInfo: &identifyClientMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..289aa32444 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,123 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyServerMethodInfo struct { + rawPayload any + } + identifyServerMethodServerUnaryInfo struct { + *identifyServerMethodInfo + } + identifyClientMethodInfo struct { + rawPayload any + } + identifyClientMethodClientUnaryInfo struct { + *identifyClientMethodInfo + } + identifyServerMethodPayload struct { + payload *ServerMethodPayload + } + identifyClientMethodPayload struct { + payload *types.Event + } +) + +// WrapServerMethodEndpoint wraps the ServerMethod endpoint with the +// server-side interceptors defined in the design. +func WrapServerMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapServerMethodIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyServerMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyServerMethodInfo) Method() string { + return "ServerMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyServerMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyServerMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyServerMethodInfo) Payload() IdentifyPayload { + return &identifyServerMethodPayload{payload: info.rawPayload.(*ServerMethodPayload)} +} + +// Service returns the service selected for this interceptor call. +func (info *identifyClientMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyClientMethodInfo) Method() string { + return "ClientMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyClientMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *identifyClientMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyClientMethodInfo) Payload() IdentifyPayload { + return &identifyClientMethodPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyServerMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} +func (p *identifyClientMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..fb28fa6e29 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,86 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodClientUnaryInfo struct { + *authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *authorizationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..78200bfb17 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,22 @@ + + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, + } + return i.Logging(ctx, info, endpoint) + } +} + +// wrapClientMethodAuthorization applies the authorization client interceptor +// to endpoints. +func wrapClientMethodAuthorization(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodClientUnaryInfo{ + authorizationMethodInfo: &authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..5b2948932f --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,63 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden new file mode 100644 index 0000000000..7f47a35c66 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodLogging(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..0335dd085c --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden @@ -0,0 +1,104 @@ + + +// wrappedMethodServerStream is a server interceptor wrapper for the +// MethodServerStream stream. +type wrappedMethodServerStream struct { + ctx context.Context + sendWithContext func(context.Context, *Event) error + stream MethodServerStream +} + +// wrappedMethodClientStream is a client interceptor wrapper for the +// MethodClientStream stream. +type wrappedMethodClientStream struct { + ctx context.Context + recvWithContext func(context.Context) (*Event, error) + stream MethodClientStream +} + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + stream := req.(*MethodEndpointInput).Stream + req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ + ctx: ctx, + sendWithContext: func(ctx context.Context, req *Event) error { + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, + } + _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { + castReq, _ := req.(*Event) + return nil, stream.SendWithContext(ctx, castReq) + }) + return err + }, + stream: stream, + } + return endpoint(ctx, req) + } +} + +// wrapClientMethodLogging applies the logging client interceptor to endpoints. +func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + res, err := endpoint(ctx, req) + if err != nil { + return res, err + } + stream := res.(MethodClientStream) + return &wrappedMethodClientStream{ + ctx: ctx, + recvWithContext: func(ctx context.Context) (*Event, error) { + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, + } + res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { + return stream.RecvWithContext(ctx) + }) + castRes, _ := res.(*Event) + return castRes, err + }, + stream: stream, + }, nil + } +} + +// Unwrap returns the underlying stream type. +func (w *wrappedMethodServerStream) Unwrap() any { + return w.stream +} + +// Send streams instances of "MethodServerStream" after executing the applied +// interceptor. +func (w *wrappedMethodServerStream) Send(v *Event) error { + return w.SendWithContext(w.ctx, v) +} + +// SendWithContext streams instances of "MethodServerStream" after executing +// the applied interceptor with context. +func (w *wrappedMethodServerStream) SendWithContext(ctx context.Context, v *Event) error { + if w.sendWithContext == nil { + return w.stream.SendWithContext(ctx, v) + } + return w.sendWithContext(ctx, v) +} + +// Close closes the stream. +func (w *wrappedMethodServerStream) Close() error { + return w.stream.Close() +} + +// Recv reads instances of "MethodClientStream" from the stream after executing +// the applied interceptor. +func (w *wrappedMethodClientStream) Recv() (*Event, error) { + return w.RecvWithContext(w.ctx) +} + +// RecvWithContext reads instances of "MethodClientStream" from the stream +// after executing the applied interceptor with context. +func (w *wrappedMethodClientStream) RecvWithContext(ctx context.Context) (*Event, error) { + if w.recvWithContext == nil { + return w.stream.RecvWithContext(ctx) + } + return w.recvWithContext(ctx) +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden new file mode 100644 index 0000000000..4cc6602cd1 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden @@ -0,0 +1,105 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult + } + + // LoggingStreamingResult provides type-safe access to the method streaming result. + // It allows reading and writing specific fields of the streaming result as defined + // in the design. + LoggingStreamingResult interface { + Message() string + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingResult struct { + result *Event + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedResultStreamingInterceptors" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*Event)} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*Event)} +} + +// Methods that read and write the selected payload and result fields + +func (r *loggingMethodStreamingResult) Message() string { + if r.result.Message == nil { + var zero string + return zero + } + return *r.result.Message +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden index 2279c68e7b..3aa5aa8505 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden @@ -3,28 +3,50 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Test2(ctx context.Context, info *Test2Info, next goa.Endpoint) (any, error) - Test4(ctx context.Context, info *Test4Info, next goa.Endpoint) (any, error) + Test2(ctx context.Context, info Test2Info, next goa.Endpoint) (any, error) + Test4(ctx context.Context, info Test4Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // Test2Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test2Info struct { - service string - method string - callType goa.InterceptorCallType + // Test2Info describes the service call currently passed to the interceptor. + Test2Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } + // Test4Info describes the service call currently passed to the interceptor. + Test4Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + test2MethodInfo struct { rawPayload any } - // Test4Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test4Info struct { - service string - method string - callType goa.InterceptorCallType + test2MethodClientUnaryInfo struct { + *test2MethodInfo + } + test4MethodInfo struct { rawPayload any } + test4MethodClientUnaryInfo struct { + *test4MethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client @@ -37,44 +59,44 @@ func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.E return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *Test2Info) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *Test2Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test2MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test2Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test2MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test2Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test2MethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test4Info) Service() string { - return info.service +// CallType reports that this is a client endpoint call. +func (info *test2MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test4Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test4MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test4Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test4MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test4Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test4MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a client endpoint call. +func (info *test4MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden index 4035a2680b..0cb4c3721e 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodTest applies the test server interceptor to endpoints. func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TestInfo{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &testMethodServerUnaryInfo{ + testMethodInfo: &testMethodInfo{rawPayload: req}, } return i.Test(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { // wrapMethodTest3 applies the test3 server interceptor to endpoints. func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test3Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test3MethodServerUnaryInfo{ + test3MethodInfo: &test3MethodInfo{rawPayload: req}, } return i.Test3(ctx, info, endpoint) } @@ -29,11 +23,8 @@ func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { // wrapClientMethodTest2 applies the test2 client interceptor to endpoints. func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test2Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test2MethodClientUnaryInfo{ + test2MethodInfo: &test2MethodInfo{rawPayload: req}, } return i.Test2(ctx, info, endpoint) } @@ -42,11 +33,8 @@ func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endp // wrapClientMethodTest4 applies the test4 client interceptor to endpoints. func wrapClientMethodTest4(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test4Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test4MethodClientUnaryInfo{ + test4MethodInfo: &test4MethodInfo{rawPayload: req}, } return i.Test4(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden index fd74e78e0b..06a5cd579e 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden @@ -3,28 +3,50 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Test(ctx context.Context, info *TestInfo, next goa.Endpoint) (any, error) - Test3(ctx context.Context, info *Test3Info, next goa.Endpoint) (any, error) + Test(ctx context.Context, info TestInfo, next goa.Endpoint) (any, error) + Test3(ctx context.Context, info Test3Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TestInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TestInfo struct { - service string - method string - callType goa.InterceptorCallType + // TestInfo describes the service call currently passed to the interceptor. + TestInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } + // Test3Info describes the service call currently passed to the interceptor. + Test3Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + testMethodInfo struct { rawPayload any } - // Test3Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test3Info struct { - service string - method string - callType goa.InterceptorCallType + testMethodServerUnaryInfo struct { + *testMethodInfo + } + test3MethodInfo struct { rawPayload any } + test3MethodServerUnaryInfo struct { + *test3MethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side @@ -37,44 +59,44 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *TestInfo) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *TestInfo) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *testMethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *TestInfo) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *testMethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *TestInfo) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *testMethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test3Info) Service() string { - return info.service +// CallType reports that this is a server endpoint call. +func (info *testMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test3Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test3MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test3Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test3MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test3Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test3MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *test3MethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden index 2a415546c0..f9c6e5766a 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: &loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden index edcaea754e..444cb0dcd0 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden @@ -3,19 +3,38 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + *loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side @@ -36,24 +55,44 @@ func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoi return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleAPIServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleAPIServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden index dc2562dae3..2aeaec7b85 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden @@ -3,19 +3,32 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Tracing(ctx context.Context, info *TracingInfo, next goa.Endpoint) (any, error) + Tracing(ctx context.Context, info TracingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TracingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TracingInfo struct { - service string - method string - callType goa.InterceptorCallType + // TracingInfo describes the service call currently passed to the interceptor. + TracingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + tracingMethodInfo struct { rawPayload any } + tracingMethodClientUnaryInfo struct { + *tracingMethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client @@ -27,24 +40,24 @@ func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.E return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *TracingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *tracingMethodInfo) Service() string { + return "SingleClientInterceptor" } -// Method returns the name of the method handling the request. -func (info *TracingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *tracingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *TracingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *tracingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *TracingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a client endpoint call. +func (info *tracingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden index 9c9e165a12..fc83673e5a 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapClientMethodTracing applies the tracing client interceptor to endpoints. func wrapClientMethodTracing(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TracingInfo{ - service: "SingleClientInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &tracingMethodClientUnaryInfo{ + tracingMethodInfo: &tracingMethodInfo{rawPayload: req}, } return i.Tracing(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden index 4e934d90ec..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleMethodServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden index b5e887ec35..d5749cc45a 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden @@ -3,19 +3,32 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side @@ -27,24 +40,24 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleMethodServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden index 2415c5cadd..f9c6e5766a 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } @@ -16,11 +13,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: &loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden index edcaea754e..46f8d24697 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden @@ -3,19 +3,38 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + *loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side @@ -36,24 +55,44 @@ func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoi return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden index 8aa673f2a2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden index 7c9dab65ae..484d5342fe 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden @@ -23,10 +23,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -36,11 +34,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint }, stream: stream, } - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } @@ -49,11 +44,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint // wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodClientUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } res, err := i.Logging(ctx, info, endpoint) if err != nil { @@ -63,11 +55,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden index 2895be4b3b..4460790930 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden @@ -3,18 +3,27 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -32,8 +41,23 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethodClientUnaryInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -51,49 +75,64 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// CallType reports that this is a client endpoint call. +func (info *loggingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden index f5381427fd..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden index 169bf2ec99..2d06a3fc3c 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -25,8 +30,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -41,39 +52,39 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Initial() string { if p.payload.Initial == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden index 058523014a..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden @@ -3,11 +3,8 @@ // wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden index 2112144a00..d5e604ff2a 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) LoggingResult } // LoggingResult provides type-safe access to the method result. @@ -25,8 +30,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } loggingMethodResult struct { result *MethodResult } @@ -41,34 +52,34 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *LoggingInfo) Result(res any) LoggingResult { +// Result returns this method's result fields. +func (info *loggingMethodInfo) Result(res any) LoggingResult { return &loggingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden index 8aa673f2a2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden index 6ef02da2ca..f37b954c22 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden @@ -23,11 +23,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -52,10 +49,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden index 5c3dee24ab..1bf1f623ab 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden @@ -3,18 +3,25 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingResult provides type-safe access to the method streaming result. @@ -25,8 +32,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodStreamingResult struct { result *MethodResult } @@ -41,39 +57,44 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadStreamingResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodStreamingResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden index 8aa673f2a2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden @@ -3,7 +3,7 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden index 79a78a3c4f..1ab9f190fc 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden @@ -25,11 +25,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -38,10 +35,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint return err }, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -66,11 +61,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) @@ -79,10 +71,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return err }, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden index 3018463916..b36825136d 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden @@ -3,18 +3,29 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingPayload provides type-safe access to the method streaming payload. @@ -34,8 +45,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodStreamingPayload struct { payload *MethodStreamingPayload } @@ -53,49 +73,54 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptors" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodStreamingPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors_dsls.go b/codegen/service/testdata/interceptors_dsls.go index d901825e2c..7ab1a01e9a 100644 --- a/codegen/service/testdata/interceptors_dsls.go +++ b/codegen/service/testdata/interceptors_dsls.go @@ -73,6 +73,25 @@ var SingleClientInterceptorDSL = func() { }) } +// LeadingInitialismInterceptorDSL defines an interceptor whose name starts with +// the common JWT initialism. +var LeadingInitialismInterceptorDSL = func() { + Interceptor("JWTAuth") + Service("LeadingInitialismInterceptor", func() { + ServerInterceptor("JWTAuth") + ClientInterceptor("JWTAuth") + Method("GetInfo", func() { + Payload(func() { + Attribute("id", Int) + }) + Result(func() { + Attribute("value", String) + }) + HTTP(func() { GET("/") }) + }) + }) +} + var MultipleInterceptorsDSL = func() { Interceptor("logging") Interceptor("tracing") @@ -112,6 +131,98 @@ var InterceptorWithReadPayloadDSL = func() { }) } +var InterceptorWithExternalReadPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("InterceptorWithExternalReadPayload", func() { + ServerInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var InterceptorWithExternalPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("InterceptorWithExternalPayload", func() { + ServerInterceptor("identify") + Method("Append", func() { + Payload(Event) + HTTP(func() { POST("/") }) + }) + }) +} + +var MixedInterceptorsWithExternalClientPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("logging") + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("MixedInterceptorsWithExternalClientPayload", func() { + ServerInterceptor("logging") + ClientInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var MergedInterceptorsWithExternalClientPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("MergedInterceptorsWithExternalClientPayload", func() { + Method("ServerMethod", func() { + ServerInterceptor("identify") + Payload(func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + }) + HTTP(func() { POST("/server") }) + }) + Method("ClientMethod", func() { + ClientInterceptor("identify") + Payload(Event) + HTTP(func() { POST("/client") }) + }) + }) +} + var InterceptorWithWritePayloadDSL = func() { Interceptor("validation", func() { WritePayload(func() { @@ -285,6 +396,32 @@ var StreamingInterceptorsWithReadStreamingResultDSL = func() { }) } +var MixedResultStreamingInterceptorsDSL = func() { + Summary := Type("Summary", func() { + Field(1, "count", Int) + }) + Event := Type("Event", func() { + Field(1, "message", String) + }) + Interceptor("logging", func() { + ReadStreamingResult(func() { + Attribute("message") + }) + }) + Service("MixedResultStreamingInterceptors", func() { + ServerInterceptor("logging") + ClientInterceptor("logging") + Method("Method", func() { + Result(Summary) + StreamingResult(Event) + HTTP(func() { + GET("/stream") + ServerSentEvents() + }) + }) + }) +} + var StreamingInterceptorsWithReadPayloadDSL = func() { Interceptor("logging", func() { ReadPayload(func() { diff --git a/codegen/service/testdata/service_dsls.go b/codegen/service/testdata/service_dsls.go index f4502cc925..995a1d34e5 100644 --- a/codegen/service/testdata/service_dsls.go +++ b/codegen/service/testdata/service_dsls.go @@ -82,6 +82,16 @@ var MultipleMethodsDSL = func() { }) } +var RepeatedInlineErrorsDSL = func() { + Service("Secured", func() { + for _, method := range []string{"Read", "Write", "Delete"} { + Method(method, func() { + Error("invalid_scopes", String) + }) + } + }) +} + var UnionMethodDSL = func() { var AUnion = Type("AUnion", func() { OneOf("Values", func() { @@ -1086,6 +1096,22 @@ var PkgPathDupeDSL = func() { }) } +var PkgPathSharedRolesDSL = func() { + var Shared = Type("Shared", func() { + Attribute("IntField", Int) + Meta("struct:pkg:path", "shared") + }) + + Service("PkgPathSharedRoles", func() { + Method("Exchange", func() { + Payload(Shared) + StreamingPayload(Shared) + Result(Shared) + StreamingResult(Shared) + }) + }) +} + var PkgPathPayloadAttributeDSL = func() { var Foo = Type("Foo", func() { Attribute("IntField", Int) diff --git a/codegen/service/testdata/views_code.go b/codegen/service/testdata/views_code.go index d4690ae3d2..857b6ac3b2 100644 --- a/codegen/service/testdata/views_code.go +++ b/codegen/service/testdata/views_code.go @@ -229,12 +229,6 @@ func ValidateResultTypeViewTiny(result *ResultTypeView) (err error) { } return } - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` const ResultWithResultTypeCode = `// RT is the viewed result type that is projected based on a view. @@ -328,11 +322,17 @@ func ValidateRT(result *RT) (err error) { // view. func ValidateRTView(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewExtended(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -345,11 +345,17 @@ func ValidateRTView(result *RTView) (err error) { // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewTiny(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -391,12 +397,6 @@ func ValidateRT2ViewTiny(result *RT2View) (err error) { return } -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} - // ValidateRT3View runs the validations defined on RT3View using the "default" // view. func ValidateRT3View(result *RT3View) (err error) { @@ -460,6 +460,7 @@ func ValidateRT(result *RT) (err error) { // ValidateRTView runs the validations defined on RTView using the "default" // view. func ValidateRTView(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -474,6 +475,7 @@ func ValidateRTView(result *RTView) (err error) { // ValidateRTViewTiny runs the validations defined on RTView using the "tiny" // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -646,6 +648,9 @@ func ValidateAnotherResult(result *AnotherResult) (err error) { // "default" view. func ValidateSomeRTView(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionViewTiny(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -658,6 +663,9 @@ func ValidateSomeRTView(result *SomeRTView) (err error) { // "tiny" view. func ValidateSomeRTViewTiny(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -692,6 +700,9 @@ func ValidateSomeRTCollectionViewTiny(result SomeRTCollectionView) (err error) { // using the "default" view. func ValidateAnotherResultView(result *AnotherResultView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateAnotherResultCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -738,19 +749,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} ` const ResultWithEnumType = `// Result is the viewed result type that is projected based on a view. @@ -840,25 +843,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` -const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag is a sum-type union.\ntype Flag struct {\n\tkind FlagKind\n\tAstring FlagAstringView\n\tAint FlagAintView\n}\n\n// FlagKind enumerates the union variants for Flag.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch of the union.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch of the union.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the discriminator value of the union.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tAstring: v,\n\t}\n}\n\n// AsAstring returns the value of the astring branch if set.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.Astring, true\n}\n\n// SetAstring sets the astring branch of the union.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\tu.kind = FlagKindAstring\n\tu.Astring = v\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\tAint: v,\n\t}\n}\n\n// AsAint returns the value of the aint branch if set.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.Aint, true\n}\n\n// SetAint sets the aint branch of the union.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\tu.kind = FlagKindAint\n\tu.Aint = v\n}\n\n// Validate ensures the union discriminant is valid.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.Astring\n\tcase FlagKindAint:\n\t\tvalue = u.Aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag discriminant %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAstring\n\t\tu.Astring = v\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAint\n\t\tu.Aint = v\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n\n// ValidateOneOfValueView runs the validations defined on OneOfValueView.\nfunc ValidateOneOfValueView(result *OneOfValueView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAstringView runs the validations defined on FlagAstringView.\nfunc ValidateFlagAstringView(result FlagAstringView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAintView runs the validations defined on FlagAintView.\nfunc ValidateFlagAintView(result FlagAintView) (err error) {\n\n\treturn\n}\n" +const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag holds exactly one of its branch values.\ntype Flag struct {\n\tkind FlagKind\n\tAstring FlagAstringView\n\tAint FlagAintView\n}\n\n// FlagKind records which Flag branch is selected.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the selected branch.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tAstring: v,\n\t}\n}\n\n// AsAstring returns the value when the astring branch is selected.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.Astring, true\n}\n\n// SetAstring selects the astring branch and stores v.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\tu.kind = FlagKindAstring\n\tu.Astring = v\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\tAint: v,\n\t}\n}\n\n// AsAint returns the value when the aint branch is selected.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.Aint, true\n}\n\n// SetAint selects the aint branch and stores v.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\tu.kind = FlagKindAint\n\tu.Aint = v\n}\n\n// Validate ensures exactly one valid branch is selected.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.Astring\n\tcase FlagKindAint:\n\t\tvalue = u.Aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag kind %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAstring\n\t\tu.Astring = v\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAint\n\t\tu.Aint = v\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n" diff --git a/codegen/service/transform_helper_operation_contract_test.go b/codegen/service/transform_helper_operation_contract_test.go index 808e6b2711..0256519024 100644 --- a/codegen/service/transform_helper_operation_contract_test.go +++ b/codegen/service/transform_helper_operation_contract_test.go @@ -61,10 +61,10 @@ func TestRecursiveTransformHelpersRetainRequiredness(t *testing.T) { compileFiles := append([]*codegen.File(nil), forwardFiles...) compileFiles = append(compileFiles, ExampleServiceFiles(forwardPlan)...) - compileGeneratedServiceFiles(t, "generated.local", compileFiles) + compileGeneratedServiceFiles(t, compileFiles) reverseCompileFiles := append([]*codegen.File(nil), reverseFiles...) reverseCompileFiles = append(reverseCompileFiles, ExampleServiceFiles(reversePlan)...) - compileGeneratedServiceFiles(t, "generated.local", reverseCompileFiles) + compileGeneratedServiceFiles(t, reverseCompileFiles) } // TestRecursiveTransformHelpersRetainSiblingOccurrences catches package-name @@ -92,7 +92,7 @@ func TestRecursiveTransformHelpersRetainSiblingOccurrences(t *testing.T) { }) }) }) - plan := retainedServicePlanForPackage(t, root, "generated.local/gen") + plan := retainedServicePlanForPackage(t, root) facts := plan.facts.serviceByID["Trees"] require.NotNil(t, facts) projected := facts.projections[facts.methods[0]].types[0] @@ -139,7 +139,7 @@ func TestRecursiveTransformHelpersRetainSiblingOccurrences(t *testing.T) { files, err := Files(plan) require.NoError(t, err) files = append(files, ExampleServiceFiles(plan)...) - compileGeneratedServiceFiles(t, "generated.local", files) + compileGeneratedServiceFiles(t, files) } // recursiveTransformPlan builds equivalent result designs in either field and @@ -193,7 +193,7 @@ func recursiveTransformPlan(t *testing.T, reverse bool) *Plan { }) }) }) - return retainedServicePlanForPackage(t, root, "generated.local/gen") + return retainedServicePlanForPackage(t, root) } // retainedRecursiveTransformOperations returns the service-to-view helper diff --git a/codegen/service/type_plan.go b/codegen/service/type_plan.go index a2cbfbfc8c..38d01cec11 100644 --- a/codegen/service/type_plan.go +++ b/codegen/service/type_plan.go @@ -1,4 +1,5 @@ -// This file collects retained service user-type and union emission facts before generated package names freeze. +// This file records the fields, pointers, tags, declarations, and unions +// needed to write generated service types. package service import ( @@ -9,8 +10,9 @@ import ( "goa.design/goa/v3/expr" ) -// planServiceTypeLayouts retains every field, pointer, tag, owner, and exact -// declaration used to spell core service types after names freeze. +// planServiceTypeLayouts records every field, pointer, struct tag, package, and +// declaration needed to write service types after Generation.Freeze chooses +// every declaration and import name. func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { binder := serviceGoTypeBinder(rootTypes, generation) plan := func(attribute *expr.AttributeExpr, owner string) (*codegen.GoTypePlan, error) { @@ -33,11 +35,6 @@ func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generat return err } userType.layout = layout - reference, err := plan(&expr.AttributeExpr{Type: userType.userType}, facts.packagePath) - if err != nil { - return err - } - userType.reference = reference } for _, errorFacts := range facts.errorFacts { layout, err := plan(errorFacts.attribute, facts.packagePath) @@ -100,8 +97,8 @@ func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generat {interceptor.writeResult, method.result, &interceptor.writeResultFields}, {interceptor.readStreamingPayload, method.streamingPayload, &interceptor.readStreamingPayloadFields}, {interceptor.writeStreamingPayload, method.streamingPayload, &interceptor.writeStreamingPayloadFields}, - {interceptor.readStreamingResult, method.result, &interceptor.readStreamingResultFields}, - {interceptor.writeStreamingResult, method.result, &interceptor.writeStreamingResultFields}, + {interceptor.readStreamingResult, method.streamingResult, &interceptor.readStreamingResultFields}, + {interceptor.writeStreamingResult, method.streamingResult, &interceptor.writeStreamingResultFields}, } for _, access := range accesses { planned, err := planInterceptorAccess(access.selection, access.parent, facts.packagePath, binder) @@ -119,8 +116,8 @@ func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generat return nil } -// planUnionRenderFacts retains every semantic branch decision and exact type -// layout before generated names and import aliases freeze. +// planUnionRenderFacts records every Goa OneOf branch, nil rule, and Go type +// before Generation.Freeze chooses declaration and import names. func planUnionRenderFacts(facts *unionFacts, binder codegen.GoTypeBinder, generatedPackage *codegen.GeneratedPackage) error { facts.identity = codegen.NewUnionTypeID(facts.union) facts.typeKey = facts.union.GetTypeKey() @@ -158,8 +155,8 @@ func planUnionRenderFacts(facts *unionFacts, binder codegen.GoTypeBinder, genera return nil } -// planInterceptorAccess retains the selected generated field names, pointer -// behavior, and exact type layouts while the design expressions are inputs. +// planInterceptorAccess records the field names, pointer choices, and Go types +// exposed to an interceptor while the design expressions are available. func planInterceptorAccess(selection *expr.AttributeExpr, parent *methodAttributeFacts, owner string, binder codegen.GoTypeBinder) ([]*interceptorAccessFacts, error) { if selection == nil { return nil, nil @@ -189,16 +186,17 @@ func planInterceptorAccess(selection *expr.AttributeExpr, parent *methodAttribut return nil, err } result[index] = &interceptorAccessFacts{ - name: codegen.Goify(field.Name, true), - pointer: parent.attribute.IsPrimitivePointer(field.Name, true), - layout: layout, + attribute: expr.DupAtt(attribute), + name: codegen.Goify(field.Name, true), + pointer: parent.attribute.IsPrimitivePointer(field.Name, true), + layout: layout, } } return result, nil } -// serviceGoTypeBinder binds authored and normalized service occurrences to -// the package declarations selected during collection. +// serviceGoTypeBinder maps authored service types and compiler-created copies +// to the generated Go declarations selected during collection. func serviceGoTypeBinder(rootTypes *rootTypeSet, generation *codegen.Generation) codegen.GoTypeBinder { return func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { owner := request.InheritedOwner @@ -227,13 +225,13 @@ func serviceGoTypeBinder(rootTypes *rootTypeSet, generation *codegen.Generation) } } -// collectServiceUnionFacts selects every service sum type once per generated -// package and retains the declaration allocated during planning. +// collectServiceUnionFacts selects each service Goa OneOf type once in every +// package that writes it and records its generated declaration. func collectServiceUnionFacts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { seenTypes := make(map[plannedUserType]struct{}) seenUnions := make(map[unionDataKey]struct{}) collect := func(attribute *expr.AttributeExpr, location *codegen.Location) error { - return collectUnionFacts(attribute, facts.service, location, rootTypes, generation, seenTypes, seenUnions, &facts.unions) + return collectUnionFacts(attribute, facts.packagePath, location, rootTypes, generation, seenTypes, seenUnions, &facts.unions) } for _, userType := range facts.userTypes { if err := collect(&expr.AttributeExpr{Type: userType.userType}, userType.location); err != nil { @@ -270,12 +268,12 @@ func collectServiceUnionFacts(facts *serviceFacts, rootTypes *rootTypeSet, gener // collectUnionFacts recursively records union declarations while keeping // unlocated nested types in the package inherited from their enclosing type. -func collectUnionFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, rootTypes *rootTypeSet, generation *codegen.Generation, seenTypes map[plannedUserType]struct{}, seenUnions map[unionDataKey]struct{}, unions *[]*unionFacts) error { +func collectUnionFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, rootTypes *rootTypeSet, generation *codegen.Generation, seenTypes map[plannedUserType]struct{}, seenUnions map[unionDataKey]struct{}, unions *[]*unionFacts) error { if attribute == nil || attribute.Type == expr.Empty { return nil } recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { - return collectUnionFacts(attribute, service, location, rootTypes, generation, seenTypes, seenUnions, unions) + return collectUnionFacts(attribute, servicePath, location, rootTypes, generation, seenTypes, seenUnions, unions) } switch actual := attribute.Type.(type) { case expr.UserType: @@ -283,7 +281,7 @@ func collectUnionFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, if typeLocation == nil { typeLocation = location } - owner := generation.Package(generatedPackagePath(generation.GenPkg(), service, typeLocation)) + owner := generation.Package(generatedPackagePath(generation.GenPkg(), servicePath, typeLocation)) key := plannedUserType{userType: rootTypes.canonical(actual), owner: owner} if _, exists := seenTypes[key]; exists { return nil @@ -304,7 +302,7 @@ func collectUnionFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, } return recurse(actual.ElemType, location) case *expr.Union: - packagePath := generatedPackagePath(generation.GenPkg(), service, location) + packagePath := generatedPackagePath(generation.GenPkg(), servicePath, location) key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(actual)} if _, exists := seenUnions[key]; !exists { declaration, err := generation.Package(packagePath).Union(actual) @@ -330,20 +328,21 @@ func collectUnionFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, return nil } -// typeMapMatchesFacts reports whether a mapping's user type belongs to the -// retained method or nested service types selected during collection. +// typeMapMatchesFacts reports whether a user-supplied Go type mapping applies +// to a payload, result, error, stream value, or child type selected for this +// service. func typeMapMatchesFacts(typeMap *expr.TypeMap, facts *serviceFacts) bool { _, reachable := facts.reachableTypes[typeMap.User.Origin()] return reachable } // collectServiceTypeFacts selects the exact named types emitted for one -// service. Linking later formats these records without repeating reachability -// or package-ownership decisions. +// service. Linking later formats these records without searching for the types +// again or deciding which generated package contains them. func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, canonical *rootTypeSet, generation *codegen.Generation) error { seen := make(map[userTypeDataKey]struct{}) for _, serviceError := range facts.errors { - selected, err := collectUserTypeFacts(serviceError.AttributeExpr, facts.service, nil, canonical, generation, seen) + selected, err := collectUserTypeFacts(serviceError.AttributeExpr, facts.packagePath, nil, canonical, generation, seen) if err != nil { return err } @@ -366,14 +365,14 @@ func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, can inner = userType.Attribute() } } - selected, err := collectUserTypeFacts(inner, facts.service, location, canonical, generation, seen) + selected, err := collectUserTypeFacts(inner, facts.packagePath, location, canonical, generation, seen) if err != nil { return err } facts.userTypes = append(facts.userTypes, selected...) } for _, methodError := range method.Errors { - selected, err := collectUserTypeFacts(methodError.AttributeExpr, facts.service, nil, canonical, generation, seen) + selected, err := collectUserTypeFacts(methodError.AttributeExpr, facts.packagePath, nil, canonical, generation, seen) if err != nil { return err } @@ -396,7 +395,7 @@ func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, can } if userType, ok := attribute.Type.(expr.UserType); ok { declaration, err := generation.Package(generatedPackagePath( - generation.GenPkg(), facts.service, codegen.UserTypeLocation(userType), + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), )).Type(userType) if err != nil { return err @@ -411,7 +410,7 @@ func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, can continue } selected, err := collectUserTypeFacts( - &expr.AttributeExpr{Type: userType}, facts.service, nil, canonical, generation, seen, + &expr.AttributeExpr{Type: userType}, facts.packagePath, nil, canonical, generation, seen, ) if err != nil { return err @@ -426,12 +425,12 @@ func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, can // collectUserTypeFacts recursively selects named types while carrying the // package location inherited from an enclosing generated type. -func collectUserTypeFacts(attribute *expr.AttributeExpr, service *expr.ServiceExpr, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation, seen map[userTypeDataKey]struct{}) ([]*userTypeFacts, error) { +func collectUserTypeFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation, seen map[userTypeDataKey]struct{}) ([]*userTypeFacts, error) { if attribute == nil || attribute.Type == expr.Empty { return nil, nil } collect := func(attribute *expr.AttributeExpr, location *codegen.Location) ([]*userTypeFacts, error) { - return collectUserTypeFacts(attribute, service, location, canonical, generation, seen) + return collectUserTypeFacts(attribute, servicePath, location, canonical, generation, seen) } var result []*userTypeFacts switch actual := attribute.Type.(type) { @@ -441,7 +440,7 @@ func collectUserTypeFacts(attribute *expr.AttributeExpr, service *expr.ServiceEx typeLocation = location } declaration, err := generation.Package( - generatedPackagePath(generation.GenPkg(), service, typeLocation), + generatedPackagePath(generation.GenPkg(), servicePath, typeLocation), ).Type(canonical.canonical(actual)) if err != nil { return nil, err @@ -456,7 +455,7 @@ func collectUserTypeFacts(attribute *expr.AttributeExpr, service *expr.ServiceEx name: actual.Name(), description: actual.Attribute().Description, errorName: retainedErrorName(actual), - serviceError: actual == expr.ErrorResult, + serviceError: expr.IsErrorResult(actual), location: typeLocation, declaration: declaration, }) @@ -499,8 +498,8 @@ func collectUserTypeFacts(attribute *expr.AttributeExpr, service *expr.ServiceEx return result, nil } -// retainedErrorName copies the exact Go expression returned by GoaErrorName -// before error metadata can be changed by a later generator phase. +// This helper copies the Go expression returned by GoaErrorName while the +// design error metadata is still available. func retainedErrorName(userType expr.UserType) string { if object := expr.AsObject(userType); object != nil { for _, field := range *object { diff --git a/codegen/service/view_data.go b/codegen/service/view_data.go index fe22d3f1dd..1e04d1f06b 100644 --- a/codegen/service/view_data.go +++ b/codegen/service/view_data.go @@ -1,4 +1,5 @@ -// This file formats retained projected types, views, constructors, validators, and their exact declaration references. +// This file builds generated view types, constructors, and validation +// functions from the data selected during planning. package service import ( @@ -62,9 +63,10 @@ func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserT } } -// projectedResultRoot returns the root attribute used to collect projected -// view types for m.Result. Compiler-created method wrappers retain their exact -// provenance in generation, so authored types with matching text stay intact. +// projectedResultRoot returns the root attribute used to build result types +// containing only the fields in each view of m.Result. Generation records +// which method wrappers Goa created, so authored types with matching text stay +// unchanged. func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { if _, normalized := generation.NormalizedMethodType(ut); !normalized { @@ -114,9 +116,10 @@ func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) return false } -// buildProjectedType returns the render data for one pointer-backed view -// declaration and its conversions to the exact source service type. -func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ProjectedTypeData { +// buildProjectedType returns render data for one view-specific declaration +// whose fields use pointers, plus conversions to and from the source service +// type. +func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration, viewsPkg string) *ProjectedTypeData { var ( projections []*InitData typeInits []*InitData @@ -130,13 +133,13 @@ func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver projections = buildViewConversions(facts, serviceResolver, viewResolver, false) serviceName := facts.source.Link( serviceResolver.outputPath, - retainedTypeQualifier(serviceResolver.aliases), + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), ).Name() views = buildViews(facts.views, serviceName, facts.mapDeclaration, facts.conversions) } validations := buildValidations(facts, viewResolver) - linked := facts.projected.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases)) - definition := facts.definition.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases)) + linked := facts.projected.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) + definition := facts.definition.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) return &ProjectedTypeData{ UserTypeData: &UserTypeData{ Declaration: declaration, @@ -150,6 +153,7 @@ func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver Projections: projections, TypeInits: typeInits, Validations: validations, + ViewsPkg: viewsPkg, Views: views, } } @@ -180,15 +184,16 @@ func buildViews(facts []*viewRenderFacts, typeName string, mapDeclaration *codeg return views } -// buildViewedResultType formats the retained viewed result wrapper and its -// constructors without consulting the mutable design expression. +// buildViewedResultType formats the viewed-result wrapper copied during +// planning and its constructors without rereading the mutable design +// expression. func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { isarr := facts.isCollection viewName := facts.viewName views := buildViews(facts.views, declaration.Name(), facts.mapDeclaration, facts.conversions) // build validation data - qualifier := retainedTypeQualifier(serviceResolver.aliases) + qualifier := retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath) serviceType := facts.source.layout.Link(serviceResolver.outputPath, qualifier) resvar, serviceRef := declaration.Name(), serviceType.Ref() projT := facts.wrapped @@ -215,6 +220,7 @@ func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceRes name := validatorDeclaration.Name() validate := &ValidateData{ Declaration: validatorDeclaration, + Name: validatorDeclaration.Name(), Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), Ref: resref, Validate: buf.String(), @@ -243,6 +249,7 @@ func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceRes name = facts.toViewed.Name() init := &InitData{ Declaration: facts.toViewed, + Name: facts.toViewed.Name(), Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), Args: []*InitArgData{ {Name: "res", Ref: serviceRef}, @@ -268,6 +275,7 @@ func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceRes name = facts.toResult.Name() resinit := &InitData{ Declaration: facts.toResult, + Name: facts.toResult.Name(), Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), Args: []*InitArgData{{Name: "vres", Ref: vresref}}, ReturnTypeRef: resref, @@ -296,8 +304,8 @@ func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceRes } } -// wrapProjected builds a viewed result type by wrapping the given projected -// in a result type with "projected" and "view" attributes. +// wrapProjected builds a viewed result type with two fields: "projected" holds +// the supplied view-specific result, and "view" records the selected view name. func wrapProjected(projected expr.UserType) expr.UserType { rt := projected.(*expr.ResultTypeExpr) pratt := &expr.NamedAttributeExpr{ @@ -321,17 +329,16 @@ func wrapProjected(projected expr.UserType) expr.UserType { } } -// buildViewConversions builds the data to generate the constructor code that -// converts between a result type and its projected type, one constructor per -// view. When toResult is true the constructors initialize the result type from -// the projected type, otherwise they project the result type to the projected -// type based on the view. +// buildViewConversions builds one constructor per view to convert between a +// complete service result and the result fields selected by that view. When +// toResult is true, each constructor rebuilds the service result. Otherwise it +// copies only the selected view fields from the service result. func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { init := make([]*InitData, 0, len(facts.conversions)/2) - serviceType := facts.source.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases)) + serviceType := facts.source.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)) serviceName, serviceRef := serviceType.Name(), serviceType.Ref() projectedDeclaration := facts.declaration - projectedRef := facts.projected.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases)).Ref() + projectedRef := facts.projected.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)).Ref() serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) for _, conversion := range facts.conversions { if conversion.toResult != toResult { @@ -343,7 +350,7 @@ func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolv } targetType := conversion.targetLayout.Link( serviceResolver.outputPath, - retainedTypeQualifier(serviceResolver.aliases), + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), ).Name() if toResult { srcCtx := declarationContext(viewedResolver, true) @@ -360,6 +367,7 @@ func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolv ) init = append(init, &InitData{ Declaration: conversion.constructor, + Name: conversion.constructor.Name(), Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), Args: []*InitArgData{{Name: "vres", Ref: projectedRef}}, ReturnTypeRef: serviceRef, @@ -381,6 +389,7 @@ func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolv ) init = append(init, &InitData{ Declaration: conversion.constructor, + Name: conversion.constructor.Name(), Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, conversion.viewName), Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, ReturnTypeRef: projectedRef, @@ -392,16 +401,19 @@ func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolv return init } -// buildValidations builds the data required to generate validations for the -// projected types. +// buildValidations builds the data required to validate result types containing +// only the fields in their selected views. func buildValidations(projected *projectedTypeFacts, resolver *declarationResolver) []*ValidateData { - linkedType := projected.projected.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + linkedType := projected.projected.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) tname := linkedType.Name() var validations []*ValidateData if projected.resultType { // for result types we create a validation function containing view // specific validation logic for each view for _, facts := range projected.validations { + if !facts.needed { + continue + } viewName := facts.viewName data := map[string]any{ "Projected": tname, @@ -422,13 +434,21 @@ func buildValidations(projected *projectedTypeFacts, resolver *declarationResolv } else { fields := make([]*validationFieldData, 0, len(facts.fields)) for _, field := range facts.fields { - call := newRetainedValidationCall(field.call, field.view) + if !field.required && field.call == nil { + continue + } + var call *ValidationCallData + if field.call != nil { + call = newRetainedValidationCall(field.call, field.view) + } fields = append(fields, &validationFieldData{ Name: field.name, Call: call, IsRequired: field.required, }) - calls = append(calls, call) + if call != nil { + calls = append(calls, call) + } } data["Validate"] = renderRetainedValidation(facts, resolver) data["Fields"] = fields @@ -441,6 +461,7 @@ func buildValidations(projected *projectedTypeFacts, resolver *declarationResolv validations = append(validations, &ValidateData{ Declaration: declaration, + Name: declaration.Name(), Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, viewName), Ref: linkedType.Ref(), Validate: buf.String(), @@ -451,10 +472,14 @@ func buildValidations(projected *projectedTypeFacts, resolver *declarationResolv // for a user type or a result type with single view, we generate only one validation // function containing the validation logic facts := projected.validations[0] + if !facts.needed { + return nil + } declaration := facts.declaration name := declaration.Name() validations = append(validations, &ValidateData{ Declaration: declaration, + Name: declaration.Name(), Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), Ref: linkedType.Ref(), Validate: renderRetainedValidation(facts, resolver), @@ -463,10 +488,10 @@ func buildValidations(projected *projectedTypeFacts, resolver *declarationResolv return validations } -// renderRetainedValidation formats a symbolic validation plan against the -// frozen view-package declarations and aliases. +// This helper formats a saved validation plan using the completed declarations +// and import names from the views package. func renderRetainedValidation(facts *validationFacts, resolver *declarationResolver) string { - linkedLayout := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases)) + linkedLayout := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) linked, err := facts.plan.Link(linkedLayout) if err != nil { panic(err) // bug @@ -474,8 +499,8 @@ func renderRetainedValidation(facts *validationFacts, resolver *declarationResol return linked.Render("result", "result") } -// newRetainedValidationCall formats one nested call from the exact declaration -// bound during planning. +// This helper formats one nested validation call from the exact function +// declaration recorded during planning. func newRetainedValidationCall(declaration *codegen.NameDeclaration, view string) *ValidationCallData { return &ValidationCallData{ Declaration: declaration, @@ -484,12 +509,11 @@ func newRetainedValidationCall(declaration *codegen.NameDeclaration, view string } } -// newValidationCall binds a nested call spelling to the exact validator -// declaration retained for attribute and view. -// buildConstructorCode builds the transformation code to create a projected -// type from a service type and vice versa. +// buildConstructorCode builds code that copies fields between a complete +// service result and a result containing only one view's fields. // -// source and target contains the projected/service contextual attributes +// sourceCtx and targetCtx provide the package names, pointer rules, and field +// names used to read the source value and write the target value. // // sourceVar and targetVar contains the variable name that holds the source and // target data structures in the transformation code. diff --git a/codegen/service/view_validation_plan.go b/codegen/service/view_validation_plan.go index d7499c8405..e476854a76 100644 --- a/codegen/service/view_validation_plan.go +++ b/codegen/service/view_validation_plan.go @@ -1,5 +1,6 @@ -// This file binds projected service validation rules to exact view-package -// layouts and validator declarations before generated names freeze. +// This file records validation for result types narrowed to their declared +// views. Each field check and child call uses the Go type and function +// declarations submitted for the generated views package. package service import ( @@ -9,8 +10,19 @@ import ( "goa.design/goa/v3/expr" ) -// planServiceValidations retains every rule and nested validator call emitted -// by core service view validation after all view declarations exist. +// viewValidationPolicy states that generated view fields use pointers, apply +// defaults, and represent Goa OneOf values with generated structs. Both type +// layout and validation use these same choices. +func viewValidationPolicy() codegen.GoLayoutPolicy { + return codegen.GoLayoutPolicy{ + Pointer: true, + UseDefault: true, + SumType: true, + } +} + +// planServiceValidations records every field check and child validation call +// written for service result views after all view type names are submitted. func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { hasProjection := false for _, method := range facts.orderedMethods { @@ -63,13 +75,8 @@ func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generat return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported view validation type %s", request.Kind) } } - viewPolicy := codegen.GoLayoutPolicy{ - Pointer: true, - UseDefault: true, - SumType: true, - } planLayout := func(attribute *expr.AttributeExpr, pointer bool) (*codegen.GoTypePlan, error) { - policy := viewPolicy + policy := viewValidationPolicy() policy.Pointer = pointer return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ Owner: facts.viewsPath, @@ -92,16 +99,24 @@ func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generat } return retained, nil } - validatorCall := func(attribute *expr.AttributeExpr, view string) (*codegen.NameDeclaration, error) { + validatorCall := func(attribute *expr.AttributeExpr, view string, required bool) (*codegen.NameDeclaration, error) { layout, err := planLayout(attribute, true) if err != nil { return nil, err } - return validator(codegen.ValidatorBindingRequest{ + request := codegen.ValidatorBindingRequest{ Attribute: attribute, Layout: layout, View: view, - }) + } + declaration := facts.validators[validatorKey{ + declaration: layout.TypeDeclaration(), + view: canonicalValidatorView(view), + }] + if declaration == nil && required { + return validator(request) + } + return declaration, nil } for _, method := range facts.orderedMethods { if method.projection == nil { @@ -173,8 +188,11 @@ func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generat } } for _, validation := range projected.validations { + if !validation.needed { + continue + } if validation.collectionElem != nil { - declaration, err := validatorCall(validation.collectionElem, validation.viewName) + declaration, err := validatorCall(validation.collectionElem, validation.viewName, true) if err != nil { return err } @@ -200,7 +218,7 @@ func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generat validation.layout = layout validation.plan = plan for _, field := range validation.fields { - declaration, err := validatorCall(field.attribute, field.view) + declaration, err := validatorCall(field.attribute, field.view, false) if err != nil { return err } diff --git a/codegen/service/views.go b/codegen/service/views.go index 29ab133725..9072dad73b 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -1,5 +1,5 @@ -// This file renders projected and viewed result declarations in one service's -// views package, including unions required by those declarations. +// This file renders result types containing only the fields in selected views, +// their viewed-result wrappers, and any unions those declarations require. package service import ( @@ -17,7 +17,7 @@ type viewedType struct { Views []*ViewData } -// viewsFile renders the views for the exact service retained by plan. +// viewsFile renders views from the service data copied into plan. func viewsFile(plan *Plan, facts *serviceFacts) *codegen.File { services := plan.Services() svc := services.Get(facts.name) diff --git a/codegen/service/views_test.go b/codegen/service/views_test.go index 22fe4cf82c..767b83f6f9 100644 --- a/codegen/service/views_test.go +++ b/codegen/service/views_test.go @@ -1,8 +1,16 @@ +// This file verifies generated view declarations, validators, and converters. package service import ( "bytes" + "flag" + "go/ast" "go/format" + "go/parser" + "go/token" + "os" + "slices" + "strconv" "strings" "testing" @@ -13,24 +21,28 @@ import ( "goa.design/goa/v3/codegen/service/testdata" ) +var updateViewGolden = flag.Bool("update-views", false, "update view code expectations") + func TestViews(t *testing.T) { cases := []struct { - Name string - DSL func() - Code string + Name string + Constant string + DSL func() + Code string }{ - {"result-with-multiple-views", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, - {"result-collection-multiple-views", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, - {"result-with-user-type", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, - {"result-with-result-type", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, - {"result-with-recursive-result-type", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, - {"result-type-with-custom-fields", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, - {"result-with-recursive-collection-of-result-type", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, - {"result-with-multiple-methods", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, - {"result-with-enum-type", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, - {"result-with-pkg-path", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, - {"result-with-oneof-in-result-type", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, + {"result-with-multiple-views", "ResultWithMultipleViewsCode", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, + {"result-collection-multiple-views", "ResultCollectionMultipleViewsCode", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, + {"result-with-user-type", "ResultWithUserTypeCode", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, + {"result-with-result-type", "ResultWithResultTypeCode", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, + {"result-with-recursive-result-type", "ResultWithRecursiveResultTypeCode", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, + {"result-type-with-custom-fields", "ResultWithCustomFieldsCode", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, + {"result-with-recursive-collection-of-result-type", "ResultWithRecursiveCollectionOfResultTypeCode", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, + {"result-with-multiple-methods", "ResultWithMultipleMethodsCode", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, + {"result-with-enum-type", "ResultWithEnumType", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, + {"result-with-pkg-path", "ResultWithPkgPathCode", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, + {"result-with-oneof-in-result-type", "ResultWithOneOfInResultTypeCode", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, } + updates := make(map[string]string, len(cases)) for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) @@ -46,7 +58,70 @@ func TestViews(t *testing.T) { require.NoError(t, err, buf.String()) code := string(bs) code = strings.ReplaceAll(code, "\r\n", "\n") + if *updateViewGolden { + updates[c.Constant] = code + return + } assert.Equal(t, c.Code, code) }) } + if *updateViewGolden { + updateViewCodeExpectations(t, updates) + } +} + +// updateViewCodeExpectations replaces only the named string literals and +// leaves the surrounding test fixtures unchanged. +func updateViewCodeExpectations(t *testing.T, updates map[string]string) { + t.Helper() + path := "testdata/views_code.go" + source, err := os.ReadFile(path) + require.NoError(t, err) + files := token.NewFileSet() + parsed, err := parser.ParseFile(files, path, source, 0) + require.NoError(t, err) + type replacement struct { + start int + end int + value string + } + var replacements []replacement + for _, declaration := range parsed.Decls { + generic, ok := declaration.(*ast.GenDecl) + if !ok || generic.Tok != token.CONST { + continue + } + for _, specification := range generic.Specs { + value := specification.(*ast.ValueSpec) + for index, name := range value.Names { + updated, exists := updates[name.Name] + if !exists { + continue + } + literal := value.Values[index].(*ast.BasicLit) + replacements = append(replacements, replacement{ + start: files.Position(literal.Pos()).Offset, + end: files.Position(literal.End()).Offset, + value: viewCodeLiteral(updated), + }) + } + } + } + require.Len(t, replacements, len(updates)) + slices.SortFunc(replacements, func(left, right replacement) int { + return right.start - left.start + }) + for _, replacement := range replacements { + source = append(source[:replacement.start], append([]byte(replacement.value), source[replacement.end:]...)...) + } + require.NoError(t, os.WriteFile(path, source, 0o644)) +} + +// viewCodeLiteral keeps readable raw strings unless generated Go tags require +// an interpreted string. +func viewCodeLiteral(source string) string { + if !strings.Contains(source, "`") { + return "`" + source + "`" + } + return strconv.Quote(source) } diff --git a/codegen/templates/transform_go_array.go.tpl b/codegen/templates/transform_go_array.go.tpl index e88ac974b9..a0c35ff259 100644 --- a/codegen/templates/transform_go_array.go.tpl +++ b/codegen/templates/transform_go_array.go.tpl @@ -1,4 +1,4 @@ -{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) +{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ if .TargetElemPointer }}*{{ end }}{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for {{ .LoopVar }}, val := range {{ .SourceVar }} { {{ if .SourceIsObject -}} if val == nil { @@ -6,9 +6,19 @@ for {{ .LoopVar }}, val := range {{ .SourceVar }} { continue } {{ end -}} +{{ if .TargetElemPointer -}} + var transformed {{ .ElemTypeRef }} {{ if .UseHelper -}} - {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}(val) + transformed = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) {{ else -}} - {{ transformAttribute .SourceElem .TargetElem "val" (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement "transformed" false .TransformAttrs -}} +{{ end -}} + {{ .TargetVar }}[{{ .LoopVar }}] = &transformed +{{ else -}} +{{ if .UseHelper -}} + {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) +{{ else -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} +{{ end -}} {{ end -}} } diff --git a/codegen/templates/transform_go_union.go.tpl b/codegen/templates/transform_go_union.go.tpl index 81d7bde5bb..1ca58a0131 100644 --- a/codegen/templates/transform_go_union.go.tpl +++ b/codegen/templates/transform_go_union.go.tpl @@ -4,20 +4,27 @@ switch string({{ .SourceVar }}.Kind()) { {{- range .Cases }} case {{ printf "%q" .CaseName }}: actual, _ := {{ $.SourceVar }}.As{{ .SourceFieldName }}() + {{- if .SourceNilable }} + var {{ $.TempVarName }} {{ .TargetCastType }} + if actual != nil { + {{- if .UseHelper }} + {{ $.TempVarName }} = {{ .HelperName }}(actual) + {{- else }} + {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName false $.TransformAttrs -}} + {{- end }} + } + {{- else }} {{- if .UseHelper }} {{ $.TempVarName }} := {{ .HelperName }}(actual) {{- else }} {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName true $.TransformAttrs -}} {{- end }} + {{- end }} {{- if $.NewVar }} var u {{ $.ValueTypeRef }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) - {{- if $.TargetIsPointer }} {{ $.TargetVar }} = &u {{- else }} - {{ $.TargetVar }} = u - {{- end }} - {{- else }} u := {{ $.TargetVar }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) {{ $.TargetVar }} = u diff --git a/codegen/templates/validation/array.go.tpl b/codegen/templates/validation/array.go.tpl index 2332fa52b1..a9672c3343 100644 --- a/codegen/templates/validation/array.go.tpl +++ b/codegen/templates/validation/array.go.tpl @@ -1,10 +1,11 @@ for _, e := range {{ .target }} { -{{- if .nonNullableElems }} +{{- if .checkNilElements }} if e == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .context }}", "[*]")) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.MissingFieldError({{ validationPath .context }}, "[*]")) } {{- end }} {{- if .validation }} {{ .validation }} {{- end }} -} \ No newline at end of file +} +{{- "" -}} diff --git a/codegen/templates/validation/enum.go.tpl b/codegen/templates/validation/enum.go.tpl index 4238f7691c..426d2a6186 100644 --- a/codegen/templates/validation/enum.go.tpl +++ b/codegen/templates/validation/enum.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if !({{ oneof .targetVal .values }}) { - err = goa.MergeErrors(err, goa.InvalidEnumValueError({{ printf "%q" .context }}, {{ .targetVal }}, {{ slice .values }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidEnumValueError({{ validationPath .context }}, {{ .targetVal }}, {{ slice .values }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/excl_min_max.go.tpl b/codegen/templates/validation/excl_min_max.go.tpl index 67d19a2852..ac8b5ac6d8 100644 --- a/codegen/templates/validation/excl_min_max.go.tpl +++ b/codegen/templates/validation/excl_min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isExclMin }}<={{ else }}>={{ end }} {{ if .isExclMin }}{{ .exclMin }}{{ else }}{{ .exclMax }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/format.go.tpl b/codegen/templates/validation/format.go.tpl index da2999f03b..c31ca71f91 100644 --- a/codegen/templates/validation/format.go.tpl +++ b/codegen/templates/validation/format.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidateFormat({{ printf "%q" .context }}, {{ .targetVal}}, {{ constant .format }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidateFormat({{ validationPath .context }}, {{ .targetVal}}, {{ constant .format }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/length.go.tpl b/codegen/templates/validation/length.go.tpl index 69b41dc487..80feb15fc9 100644 --- a/codegen/templates/validation/length.go.tpl +++ b/codegen/templates/validation/length.go.tpl @@ -2,8 +2,8 @@ {{ if and .isPointer .string -}} if {{ .target }} != nil { {{ end -}} -if {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidLengthError({{ printf "%q" .context }}, {{ $target }}, {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) +if {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidLengthError({{ validationPath .context }}, {{ $target }}, {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) }{{- if and .isPointer .string }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/min_max.go.tpl b/codegen/templates/validation/min_max.go.tpl index 44fef2c234..51d938a0af 100644 --- a/codegen/templates/validation/min_max.go.tpl +++ b/codegen/templates/validation/min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer -}}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isMin }}<{{ else }}>{{ end }} {{ if .isMin }}{{ .min }}{{ else }}{{ .max }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/pattern.go.tpl b/codegen/templates/validation/pattern.go.tpl index 4841eff60d..c5e0671bf8 100644 --- a/codegen/templates/validation/pattern.go.tpl +++ b/codegen/templates/validation/pattern.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidatePattern({{ printf "%q" .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidatePattern({{ validationPath .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/required.go.tpl b/codegen/templates/validation/required.go.tpl index 19ef72c589..4138d7ae61 100644 --- a/codegen/templates/validation/required.go.tpl +++ b/codegen/templates/validation/required.go.tpl @@ -1,9 +1,9 @@ {{- if and (isUnion .reqAtt) (isSumType .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }}.Kind() == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- else }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }} == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- end }} diff --git a/codegen/templates/validation/union.go.tpl b/codegen/templates/validation/union.go.tpl index 2abda6cf35..1ad02e44c5 100644 --- a/codegen/templates/validation/union.go.tpl +++ b/codegen/templates/validation/union.go.tpl @@ -3,12 +3,12 @@ switch v := {{ .Target }}.(type) { case {{ .Type }}: {{- if $.Protobuf }} if v == nil { - err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Context }})) + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) break } {{- if .PayloadRequiresPresence }} if v.{{ .Field }} == nil { - err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Context }})) + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) break } {{- end }} diff --git a/codegen/templates/validation/user.go.tpl b/codegen/templates/validation/user.go.tpl index c85d5202cd..5bddddcf43 100644 --- a/codegen/templates/validation/user.go.tpl +++ b/codegen/templates/validation/user.go.tpl @@ -1,4 +1,4 @@ -if err2 := {{ .name }}({{ .target }}); err2 != nil { - err = goa.MergeErrors(err, err2) +if err2 := {{ .call }}; err2 != nil { + err = {{ .goa }}.MergeErrors(err, err2) } {{- "" -}} diff --git a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden index 0e2e496ccd..50bef0f9da 100644 --- a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden +++ b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero json.RawMessage - if target.RawJSON == zero { + if target.RawJSON == nil { target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} } } @@ -29,14 +28,12 @@ func transform() { } } { - var zero []byte - if target.Bytes == zero { + if target.Bytes == nil { target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} } } { - var zero any - if target.Any == zero { + if target.Any == nil { target.Any = "something" } } diff --git a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden index 30b0772b94..1ce022fd9c 100644 --- a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden +++ b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden @@ -3,7 +3,10 @@ func transform() { switch string(source.Kind()) { case "SomeType": actual, _ := source.AsSomeType() - obj := transformSomeTypeToSomeType(actual) + var obj *SomeType + if actual != nil { + obj = transformSomeTypeToSomeType(actual) + } var u UnionSomeType2 u.SetSomeType((*SomeType)(obj)) target = &u diff --git a/codegen/testdata/golden/go_transform_union_nil_branch.go.golden b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden new file mode 100644 index 0000000000..c84416a8a9 --- /dev/null +++ b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden @@ -0,0 +1,78 @@ +func transform() { + var target *State + switch string(source.Kind()) { + case "details": + actual, _ := source.AsDetails() + var obj *Details + if actual != nil { + obj = transformDetailsToDetails(actual) + } + var u State + u.SetDetails((*Details)(obj)) + target = &u + case "empty": + actual, _ := source.AsEmpty() + var obj *Empty + if actual != nil { + obj = transformEmptyToEmpty(actual) + } + var u State + u.SetEmpty((*Empty)(obj)) + target = &u + case "aliases": + actual, _ := source.AsAliases() + var obj []string + if actual != nil { + obj = make([]string, len(actual)) + for i, val := range actual { + obj[i] = val + } + + } + var u State + u.SetAliases(([]string)(obj)) + target = &u + case "labels": + actual, _ := source.AsLabels() + var obj map[string]string + if actual != nil { + obj = make(map[string]string, len(actual)) + for key, val := range actual { + tk := key + tv := val + obj[tk] = tv + } + + } + var u State + u.SetLabels((map[string]string)(obj)) + target = &u + case "blob": + actual, _ := source.AsBlob() + var obj []byte + if actual != nil { + obj = actual + + } + var u State + u.SetBlob(([]byte)(obj)) + target = &u + case "anything": + actual, _ := source.AsAnything() + var obj any + if actual != nil { + obj = actual + + } + var u State + u.SetAnything((any)(obj)) + target = &u + case "name": + actual, _ := source.AsName() + obj := actual + + var u State + u.SetName((string)(obj)) + target = &u + } +} diff --git a/codegen/testdata/golden/validation_alias-type.go.golden b/codegen/testdata/golden/validation_alias-type.go.golden index 4e19459d5d..7eff466ad9 100644 --- a/codegen/testdata/golden/validation_alias-type.go.golden +++ b/codegen/testdata/golden/validation_alias-type.go.golden @@ -8,13 +8,9 @@ func Validate() (err error) { } if target.Alias != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.alias", string(*target.Alias), "^[A-z].*[a-z]$")) - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 1, true)) } - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 10, false)) } diff --git a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden index ff87e2cfd9..fef37a7d31 100644 --- a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden @@ -6,11 +6,7 @@ func Validate() (err error) { if !(string(*target.ReqMid) == "ab" || string(*target.ReqMid) == "abc" || string(*target.ReqMid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.req_mid", string(*target.ReqMid), []any{"ab", "abc", "abcd"})) } - } - if target.ReqMid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.req_mid", string(*target.ReqMid), "^[a-z]+$")) - } - if target.ReqMid != nil { if utf8.RuneCountInString(string(*target.ReqMid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.req_mid", string(*target.ReqMid), utf8.RuneCountInString(string(*target.ReqMid)), 2, true)) } @@ -19,11 +15,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -32,11 +24,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_chain-holder-required.go.golden b/codegen/testdata/golden/validation_chain-holder-required.go.golden index b3c3da78cc..690def43b6 100644 --- a/codegen/testdata/golden/validation_chain-holder-required.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-required.go.golden @@ -10,11 +10,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -23,11 +19,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_float-pointer.go.golden b/codegen/testdata/golden/validation_float-pointer.go.golden index 80ca882e8e..f69e7157ec 100644 --- a/codegen/testdata/golden/validation_float-pointer.go.golden +++ b/codegen/testdata/golden/validation_float-pointer.go.golden @@ -21,8 +21,6 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { if *target.ExclusiveFloat64 >= 100.1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } diff --git a/codegen/testdata/golden/validation_float-required.go.golden b/codegen/testdata/golden/validation_float-required.go.golden index e3390a1534..e74c9ac4bd 100644 --- a/codegen/testdata/golden/validation_float-required.go.golden +++ b/codegen/testdata/golden/validation_float-required.go.golden @@ -16,8 +16,6 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { if *target.ExclusiveFloat64 >= 100.1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } diff --git a/codegen/testdata/golden/validation_float-use-default.go.golden b/codegen/testdata/golden/validation_float-use-default.go.golden index cb21d4b8ac..f14511e34f 100644 --- a/codegen/testdata/golden/validation_float-use-default.go.golden +++ b/codegen/testdata/golden/validation_float-use-default.go.golden @@ -14,8 +14,6 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { if *target.ExclusiveFloat64 >= 100.1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } diff --git a/codegen/testdata/golden/validation_integer-pointer.go.golden b/codegen/testdata/golden/validation_integer-pointer.go.golden index b0af595b72..28c328f6f7 100644 --- a/codegen/testdata/golden/validation_integer-pointer.go.golden +++ b/codegen/testdata/golden/validation_integer-pointer.go.golden @@ -21,8 +21,6 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { if *target.ExclusiveInteger >= 100 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } diff --git a/codegen/testdata/golden/validation_integer-required.go.golden b/codegen/testdata/golden/validation_integer-required.go.golden index 3c59c8b777..160130d2de 100644 --- a/codegen/testdata/golden/validation_integer-required.go.golden +++ b/codegen/testdata/golden/validation_integer-required.go.golden @@ -16,8 +16,6 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { if *target.ExclusiveInteger >= 100 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } diff --git a/codegen/testdata/golden/validation_integer-use-default.go.golden b/codegen/testdata/golden/validation_integer-use-default.go.golden index 3721d4d314..34fd59d095 100644 --- a/codegen/testdata/golden/validation_integer-use-default.go.golden +++ b/codegen/testdata/golden/validation_integer-use-default.go.golden @@ -14,8 +14,6 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { if *target.ExclusiveInteger >= 100 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } diff --git a/codegen/testdata/golden/validation_string-pointer.go.golden b/codegen/testdata/golden/validation_string-pointer.go.golden index 51b19890fd..8127794309 100644 --- a/codegen/testdata/golden/validation_string-pointer.go.golden +++ b/codegen/testdata/golden/validation_string-pointer.go.golden @@ -4,13 +4,9 @@ func Validate() (err error) { } if target.RequiredString != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.required_string", *target.RequiredString, "^[A-z].*[a-z]$")) - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 1, true)) } - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 10, false)) } diff --git a/codegen/transformer.go b/codegen/transformer.go index 9fef332d4d..454273fa72 100644 --- a/codegen/transformer.go +++ b/codegen/transformer.go @@ -25,13 +25,14 @@ type ( // Package returns the qualifier used to reference att from the current // generated file, or the empty string for a same-package declaration. Package(att *expr.AttributeExpr) string - // Enter returns the resolver that owns att and declarations nested in it. + // Enter returns the resolver for the package containing att and declarations + // nested in it. Enter(att *expr.AttributeExpr) Attributor // IsSumType reports whether unions use Goa's generated sum-type layout. IsSumType() bool - // ValidatorName returns the package-level validation function for att and - // the selected result-type view. - ValidatorName(att *expr.AttributeExpr, view string) string + // ValidatorCall returns the complete call that validates target as att. + // path is the generated expression used as the start of nested error paths. + ValidatorCall(att *expr.AttributeExpr, view, target, path string) string } // AttributeContext contains properties which impacts the code generating @@ -57,6 +58,9 @@ type ( // use pointers when Pointer is true. Service types leave this false because // the empty union discriminator represents omission after decoding. UnionPointer bool + // ArrayElementPointer keeps primitive array elements as pointers when + // generated validation must distinguish null from the primitive zero value. + ArrayElementPointer bool } // AttributeScope contains the scope of an attribute. It implements the @@ -77,37 +81,39 @@ type ( // Hooks are optional generator specific extension points // consulted by the transform engine. Nil selects the engine // defaults. - Hooks *TransformHooks - helpers map[TransformHelperID]TransformHelper - calls *transformCallCursor + Hooks *TransformHooks + helpers map[TransformHelperID]TransformHelper + calls *transformCallCursor + collectionDepth int + unionDepth int } - // TransformHelperID identifies one recursive helper selected by a transform - // plan. Its representation is deliberately private: generators may compare - // IDs or use them as map keys but cannot reconstruct them from generated - // names. + // TransformHelperID selects one recursive function in a TransformPlan. Its + // fields are private so callers cannot rebuild it from a generated name. TransformHelperID struct { plan *TransformPlan index int } - // TransformHelper describes one recursive source-to-target operation - // retained by a transform plan. Render uses its ID and declaration for both - // calls and definitions. + // TransformHelper describes one generated function that converts a nested or + // recursive value. The same chosen function name is used at every call and at + // its definition. TransformHelper struct { - // ID is the opaque identity owned by the transform plan. + // ID selects this function in its TransformPlan. ID TransformHelperID - // Source is the exact source attribute selected during planning. + // Source describes the source attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. Source *expr.AttributeExpr - // Target is the exact target attribute selected during planning. + // Target describes the target attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. Target *expr.AttributeExpr // Required reports whether nil is rejected by the helper operation. Required bool // Occurrence is the one-based position of this helper operation in the // transform plan's stable traversal. Occurrence int - // Declaration is the canonical package-level function bound before render. - // Render rejects an unbound helper. + // Declaration holds the package-level function name chosen before source + // is written. Render returns an error when it is missing. Declaration *NameDeclaration } @@ -129,13 +135,15 @@ type ( // } // TransformFunctionData struct { - // ID is the retained helper identity used to render this definition. + // ID selects the recursive function rendered by this value. ID TransformHelperID - // Declaration is the canonical package declaration for retained transforms. - // It is nil for the separate one-pass transform API. + // Declaration is the package-level function chosen before writing code. + // It is nil when GoTransformWithAttrs created this value while writing + // code. Declaration *NameDeclaration - // Name is the generated helper name for staged legacy callers. It is empty - // when Declaration owns the final name. + // Name is the final helper name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. Name string // ParamTypeRef is the generated Go reference to the helper parameter type. ParamTypeRef string @@ -145,39 +153,63 @@ type ( Code string } - // TransformPlan retains the exact source-target operations and recursive - // helpers selected for one Go transformation. Generators build the plan - // before package names freeze and render it afterward with contexts that - // resolve the final declarations. + // TransformPlan owns copied source and target expressions plus every + // recursive function needed to convert between them. Create a plan, inspect + // the detached helper descriptions from Helpers and declare their names, bind + // those declarations and the completed type resolvers, then call Render. + // A helper ID remains bound to this plan, but changing a description returned + // by Helpers cannot change the private expressions Render uses. Render caches + // each exact argument set, so repeated calls return the first generated code. TransformPlan struct { - source *expr.AttributeExpr - target *expr.AttributeExpr - sourceCtx *AttributeContext - targetCtx *AttributeContext - helpers []TransformHelper - operations []*transformOperation + source *expr.AttributeExpr + target *expr.AttributeExpr + sourceBaseline *expr.AttributeExpr + targetBaseline *expr.AttributeExpr + sourceOriginals map[*expr.AttributeExpr]*expr.AttributeExpr + targetOriginals map[*expr.AttributeExpr]*expr.AttributeExpr + prefix string + hooks *TransformHooks + sourceCtx *AttributeContext + targetCtx *AttributeContext + helpers []TransformHelper + operations []*transformOperation + renders map[transformRenderRequest]transformRenderResult } - // transformPair identifies one recursive source-target operation by its - // expression declarations rather than a provisional helper spelling. + // transformRenderRequest identifies one Render invocation. Repeating the + // same invocation returns its first result instead of invoking hooks again. + transformRenderRequest struct { + sourceVar string + targetVar string + newVar bool + } + + // transformRenderResult is the private immutable cache for one Render call. + transformRenderResult struct { + code string + helpers []*TransformFunctionData + err error + } + + // transformPair holds the exact copied source and target types whose fields + // are currently being visited. transformPair struct { source expr.DataType target expr.DataType } - // transformOperation retains the ordered helper calls made while rendering - // the top-level transform or one helper body. + // transformOperation stores the recursive calls made by the top-level + // conversion or one function body, in call order. transformOperation struct { calls []transformCall } - // transformCall binds one ordered call edge to the helper that renders its - // conversion. + // transformCall selects the recursive function used by one call. transformCall struct { helper TransformHelperID } - // transformCallCursor tracks the retained calls consumed by one render. + // transformCallCursor counts the planned calls used by one render. transformCallCursor struct { calls []transformCall next int @@ -199,6 +231,14 @@ func NewAttributeScope(scope *NameScope) *AttributeScope { return newAttributeScope(scope, "") } +// EnterCollection returns the loop variable for the current array and a copy +// used to render values nested inside that array. +func (a *TransformAttrs) EnterCollection() (string, *TransformAttrs) { + child := *a + child.collectionDepth++ + return string(rune('i' + a.collectionDepth)), &child +} + // IsCompatible returns an error if a and b are not both objects, both arrays, // both maps, both unions or one union and one object. actx and bctx are used // to build the error message if any. @@ -237,13 +277,19 @@ func IsCompatible(a, b expr.DataType, actx, bctx string) error { return nil } -// AppendHelpers takes care of only appending helper functions from newH that -// are not already in oldH. +// AppendHelpers appends functions from newH that oldH does not already contain. +// Planned functions are the same when they use the same package declaration. +// Older functions without a declaration are the same when their names match. +// It panics when one declaration or released name has different parameter, +// result, or body text because one Go function cannot implement both values. func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData { for _, h := range newH { found := false for _, h2 := range oldH { if sameTransformHelper(h, h2) { + if !transformFunctionDefinitionsEqual(h, h2) { + panic(fmt.Sprintf("transform helper %q has different definitions", h.Name)) + } found = true break } @@ -255,11 +301,15 @@ func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData return oldH } -// sameTransformHelper compares canonical declarations for catalog-backed -// helpers and generated names for staged legacy helpers. +// sameTransformHelper compares the chosen package declarations when both +// helpers have one. Values created while writing code have no declaration and +// keep using their generated names. func sameTransformHelper(left, right *TransformFunctionData) bool { + if left.Declaration != nil && right.Declaration != nil { + return left.Declaration == right.Declaration + } if left.Declaration != nil || right.Declaration != nil { - return left.ID == right.ID + return false } return left.Name == right.Name } @@ -337,6 +387,12 @@ func (a *AttributeContext) IsUnionPointer(required bool) bool { return a.UnionPointer && (!required || a.Pointer) } +// IsArrayElementPointer reports whether primitive elements in array use +// pointers so generated validation can reject null before conversion. +func (a *AttributeContext) IsArrayElementPointer(array *expr.Array) bool { + return arrayElementIsPointer(array, a.ArrayElementPointer) +} + // Pkg returns the package name of the given type. func (a *AttributeContext) Pkg(att *expr.AttributeExpr) string { return a.Scope.Package(att) @@ -353,11 +409,12 @@ func (a *AttributeContext) Enter(att *expr.AttributeExpr) *AttributeContext { // Dup creates a shallow copy of the AttributeContext. func (a *AttributeContext) Dup() *AttributeContext { return &AttributeContext{ - Pointer: a.Pointer, - IgnoreRequired: a.IgnoreRequired, - UseDefault: a.UseDefault, - Scope: a.Scope, - UnionPointer: a.UnionPointer, + Pointer: a.Pointer, + IgnoreRequired: a.IgnoreRequired, + UseDefault: a.UseDefault, + Scope: a.Scope, + UnionPointer: a.UnionPointer, + ArrayElementPointer: a.ArrayElementPointer, } } @@ -402,10 +459,11 @@ func (a *AttributeScope) Package(att *expr.AttributeExpr) string { return a.pkg } -// ValidatorName returns the deterministic validator convention used by -// generators whose names are already isolated in a private transport scope. -func (a *AttributeScope) ValidatorName(att *expr.AttributeExpr, view string) string { - return "Validate" + a.Name(att, "", false, true) + Goify(view, true) +// ValidatorCall returns a call to the validation function selected from the +// generated type and view names. +func (a *AttributeScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) } // Enter returns a scope whose default qualifier follows att's explicit type diff --git a/codegen/transformer_test.go b/codegen/transformer_test.go index 1a40acf303..7d8f24122b 100644 --- a/codegen/transformer_test.go +++ b/codegen/transformer_test.go @@ -1,11 +1,81 @@ +// This file verifies shared transform rules that are independent of a +// transport generator. package codegen import ( "testing" + "github.com/stretchr/testify/assert" + "goa.design/goa/v3/expr" ) +func TestAppendHelpersUsesDeclarationIdentity(t *testing.T) { + firstPlan := &TransformPlan{} + secondPlan := &TransformPlan{} + shared := NewExactName(NameFunction, "sharedHelper") + separate := NewExactName(NameFunction, "separateHelper") + old := []*TransformFunctionData{ + {ID: TransformHelperID{plan: firstPlan}, Declaration: shared, Name: "sharedHelper"}, + {ID: TransformHelperID{plan: firstPlan, index: 1}, Declaration: separate, Name: "separateHelper"}, + {Name: "legacyOne"}, + } + added := []*TransformFunctionData{ + {ID: TransformHelperID{plan: secondPlan}, Declaration: shared, Name: "sharedHelper"}, + {Name: "sharedHelper"}, + {Name: "legacyOne"}, + {Name: "legacyTwo"}, + } + + got := AppendHelpers(old, added) + + if assert.Len(t, got, 5) { + assert.Same(t, shared, got[0].Declaration) + assert.Same(t, separate, got[1].Declaration) + assert.Nil(t, got[2].Declaration) + assert.Nil(t, got[3].Declaration) + assert.Nil(t, got[4].Declaration) + assert.Equal(t, "sharedHelper", got[3].Name) + assert.Equal(t, "legacyTwo", got[4].Name) + } +} + +func TestAppendHelpersRejectsConflictingLegacyDefinitions(t *testing.T) { + tests := map[string]*TransformFunctionData{ + "parameter type": { + Name: "transformValue", + ParamTypeRef: "OtherSource", + ResultTypeRef: "Target", + Code: "return value", + }, + "result type": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "OtherTarget", + Code: "return value", + }, + "body": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return other", + }, + } + for name, added := range tests { + t.Run(name, func(t *testing.T) { + old := []*TransformFunctionData{{ + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return value", + }} + assert.PanicsWithValue(t, "transform helper \"transformValue\" has different definitions", func() { + AppendHelpers(old, []*TransformFunctionData{added}) + }) + }) + } +} + func TestIsPrimitivePointer(t *testing.T) { newObj := func(fieldName string, fieldType expr.DataType, req bool) *expr.AttributeExpr { attr := &expr.AttributeExpr{ diff --git a/codegen/types.go b/codegen/types.go index 3e2e8dbb79..38f7bdd904 100644 --- a/codegen/types.go +++ b/codegen/types.go @@ -43,11 +43,19 @@ func GoNativeTypeName(t expr.DataType) string { // IsNilable reports whether the Go type generated for t can be nil. func IsNilable(t expr.DataType) bool { + underlying := unalias(t) return expr.IsObject(t) || expr.IsArray(t) || expr.IsMap(t) || - t.Kind() == expr.BytesKind || - t.Kind() == expr.AnyKind + underlying.Kind() == expr.BytesKind || + underlying.Kind() == expr.AnyKind +} + +// arrayElementIsPointer reports whether validation needs a pointer to tell a +// null element apart from the element type's zero value. +func arrayElementIsPointer(array *expr.Array, enabled bool) bool { + return enabled && array.NonNullableElems && expr.IsPrimitive(array.ElemType.Type) && + !IsNilable(array.ElemType.Type) } // goFieldIsPointer reports whether a field in a generated Goa service struct diff --git a/codegen/union.go b/codegen/union.go index e1a87d9eb8..e9800c5b73 100644 --- a/codegen/union.go +++ b/codegen/union.go @@ -1,5 +1,5 @@ -// This file defines the emitted Go and JSON identity used to name and emit Goa -// unions consistently. It is separate from expression-type compatibility. +// This file builds a repeatable key from every detail that changes a generated +// union's Go or JSON definition. package codegen import ( @@ -15,10 +15,10 @@ type ( UnionTypeID string ) -// NewUnionTypeID returns the generated-definition identity for union. The -// identity includes the effective JSON envelope keys and details that change -// generated Go branch types, including package locations, field type metadata, -// and nilability. +// NewUnionTypeID returns a repeatable key for union's generated Go and JSON +// definitions. The key includes the effective JSON envelope keys and every +// detail that changes a generated Go branch type: package location, field type +// metadata, and whether the value may be nil. func NewUnionTypeID(union *expr.Union) UnionTypeID { var key strings.Builder writeUnionTypeID( @@ -31,7 +31,8 @@ func NewUnionTypeID(union *expr.Union) UnionTypeID { return UnionTypeID(key.String()) } -// Hash returns the exact identity used by a generated package's name scope. +// Hash returns the repeatable key used to look up this union's Go name in a +// generated package. func (id UnionTypeID) Hash() string { return string(id) } @@ -56,7 +57,8 @@ func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr } } -// writeUnionAttributeID appends the generated Go identity of an attribute. +// writeUnionAttributeID appends every attribute detail that changes generated +// Go code. func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { writeUnionIDPart(key, strconv.FormatBool(IsNilable(att.Type))) if metaType, ok := att.Meta["struct:field:type"]; ok { diff --git a/codegen/validation.go b/codegen/validation.go index a94722d73d..1915925ab7 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -5,8 +5,8 @@ package codegen import ( "bytes" - "errors" "fmt" + "strconv" "strings" "text/template" @@ -36,12 +36,22 @@ type ( // Target is the generated union value being checked. Target string // Context identifies the union in validation errors. - Context string + Context validationPath // Protobuf is true when each selected branch is stored in its own generated // protobuf struct. Protobuf bool // Cases lists every branch accepted by the union. Cases []unionValidationCase + // Goa is the generated import name of Goa's error package. + Goa string + } + + // validationPath stores an error path while Goa writes validation source. + // variable is true when root names a parameter in the generated function. + validationPath struct { + root string + suffix string + variable bool } ) @@ -62,9 +72,10 @@ var ( func init() { fm := template.FuncMap{ - "slice": toSlice, - "oneof": oneof, - "constant": constant, + "slice": toSlice, + "oneof": oneof, + "constant": constant, + "validationPath": renderValidationPath, "isUnion": func(att *expr.AttributeExpr) bool { if att == nil { return false @@ -101,7 +112,7 @@ func init() { // // See ValidationCode for a description of the arguments. func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias bool, target, attName string) string { - return recurseValidationCode(att, put, attCtx, req, alias, false, target, attName, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, false, target, literalValidationPath(attName), nil).String() } // ValidationCode produces Go code that runs the validations defined in the @@ -125,10 +136,24 @@ func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx // // context is used to produce helpful messages in case of error. func ValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string) string { - return recurseValidationCode(att, put, attCtx, req, alias, view, target, target, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, view, target, literalValidationPath(target), nil).String() +} + +// ValidationCodeWithPathParameter produces validation code whose error paths +// begin with the string held by pathParameter. target and pathParameter are Go +// expressions. +func ValidationCodeWithPathParameter(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, pathParameter string) string { + return recurseValidationCode(att, put, attCtx, req, alias, view, target, parameterValidationPath(pathParameter), nil).String() } -func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, context string, seen map[expr.UserType]*bytes.Buffer) *bytes.Buffer { +func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer) *bytes.Buffer { + return renderValidationCode(att, put, attCtx, req, alias, view, target, context, seen, true) +} + +// renderValidationCode writes one validation tree. localGuards reports whether +// local rule templates must check a pointer before reading it. Nested fields +// disable those checks when validateAttribute wraps the whole field once. +func renderValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer, localGuards bool) *bytes.Buffer { if seen == nil { seen = make(map[expr.UserType]*bytes.Buffer) } @@ -159,7 +184,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Write validations on attribute if any. - validation := validationCode(att, attCtx, req, alias, target, context) + validation := validationCode(att, attCtx, req, alias, target, context, localGuards) if validation != "" { buf.WriteString(validation) first = false @@ -173,7 +198,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } for _, nat := range *(expr.AsObject(att.Type)) { tgt := fmt.Sprintf("%s.%s", target, attCtx.Scope.Field(nat.Attribute, nat.Name, true)) - ctx := fmt.Sprintf("%s.%s", context, nat.Name) + ctx := context.child("." + nat.Name) val := validateAttribute(attCtx, nat.Attribute, put, tgt, ctx, att.IsRequired(nat.Name), view, seen) if val != "" { newline() @@ -184,19 +209,21 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A arr := expr.AsArray(att.Type) elem := arr.ElemType ctx := attCtx - if ctx.Pointer && expr.IsPrimitive(elem.Type) { - // Array elements of primitive type are never pointers + if expr.IsPrimitive(elem.Type) { ctx = attCtx.Dup() - ctx.Pointer = false + ctx.Pointer = attCtx.IsArrayElementPointer(arr) } - val := validateAttribute(ctx, elem, put, "e", context+"[*]", true, view, seen) - if val != "" || arr.NonNullableElems { + val := validateAttribute(ctx, elem, put, "e", context.child("[*]"), true, view, seen) + nonNullableElems := arr.NonNullableElems && + (IsNilable(elem.Type) || attCtx.IsArrayElementPointer(arr)) + if val != "" || nonNullableElems { newline() data := map[string]any{ "target": target, "validation": val, - "nonNullableElems": arr.NonNullableElems, + "checkNilElements": nonNullableElems, "context": context, + "goa": "goa", } if err := arrayValT.Execute(buf, data); err != nil { panic(err) // bug @@ -206,11 +233,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A m := expr.AsMap(att.Type) ctx := attCtx.Dup() ctx.Pointer = false - keyVal := validateAttribute(ctx, m.KeyType, put, "k", context+".key", true, view, seen) + keyVal := validateAttribute(ctx, m.KeyType, put, "k", context.child(".key"), true, view, seen) if keyVal != "" { keyVal = "\n" + keyVal } - valueVal := validateAttribute(ctx, m.ElemType, put, "v", context+"[key]", true, view, seen) + valueVal := validateAttribute(ctx, m.ElemType, put, "v", context.child("[key]"), true, view, seen) if valueVal != "" { valueVal = "\n" + valueVal } @@ -233,7 +260,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // only keep pointer semantics when both layers use pointers. unionCtx := attCtx.Dup() unionCtx.Pointer = unionCtx.Pointer && expr.IsObject(v.Attribute.Type) - val := validateAttribute(unionCtx, v.Attribute, put, "actual", context+".value", true, view, seen) + val := validateAttribute(unionCtx, v.Attribute, put, "actual", context.child(".value"), true, view, seen) if val == "" { continue } @@ -248,6 +275,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A data := map[string]any{ "target": target, "cases": cases, + "goa": "goa", } if err := unionSumValT.Execute(buf, data); err != nil { panic(err) // bug @@ -264,7 +292,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // Union values in views are never pointers - they are concrete typed values unionCtx := attCtx.Dup() unionCtx.Pointer = false - val := validateAttribute(unionCtx, vatt, put, "v", context+".value", true, view, seen) + val := validateAttribute(unionCtx, vatt, put, "v", context.child(".value"), true, view, seen) if val != "" { cases = append(cases, unionValidationCase{ Type: attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt)), @@ -273,7 +301,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } } else { fieldName := attCtx.Scope.Field(vatt, v.Name, true) - val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context+".value", true, view, seen) + val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context.child(".value"), true, view, seen) parent := &expr.AttributeExpr{Type: put} tref := attCtx.Scope.Ref(parent, attCtx.Pkg(parent)) cases = append(cases, unionValidationCase{ @@ -292,6 +320,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A Context: context, Protobuf: !view, Cases: cases, + Goa: "goa", } if err := unionValT.Execute(buf, data); err != nil { panic(err) // bug @@ -310,37 +339,21 @@ func protobufUnionPayloadRequiresPresence(att *expr.AttributeExpr) bool { return !expr.IsPrimitive(att.Type) || kind == expr.BytesKind || kind == expr.AnyKind } -func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { +func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target string, context validationPath, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { ut, isUT := att.Type.(expr.UserType) if !isUT { - code := recurseValidationCode(att, put, ctx, req, false, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, false, view, target, context, seen, !guard).String() if code == "" { return "" } if expr.IsArray(att.Type) || expr.IsMap(att.Type) { return code } - if expr.IsUnion(att.Type) { - if ctx.Scope.IsSumType() { - if !ctx.IsUnionPointer(req) { - return code - } - } else if req { - return code - } - cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } - return fmt.Sprintf("%s%s\n}", cond, code) - } - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } // Alias user types: validate underlying attribute with alias flag so that @@ -351,32 +364,44 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // validating alias user types against their underlying base. Passing // the original attribute with alias=true ensures validations operate // on the correct value type without dropping field defaults. - code := recurseValidationCode(att, put, ctx, req, true, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, true, view, target, context, seen, !guard).String() if code == "" { return "" } - // For optional pointer fields, wrap validation code in nil check - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } if !hasValidations(ctx, ut) { return "" } var buf bytes.Buffer - name := ctx.Scope.ValidatorName(att, "") - data := map[string]any{"name": name, "target": target} + call := ctx.Scope.ValidatorCall(att, "", target, renderValidationPath(context)) + data := map[string]any{"call": call, "goa": "goa"} if err := userValT.Execute(&buf, data); err != nil { panic(err) // bug } return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buf.String()) } +// validationAttributeNeedsNilGuard reports whether a nested value may be nil +// in the generated Go layout and must be checked before any validation uses it. +func validationAttributeNeedsNilGuard(att *expr.AttributeExpr, ctx *AttributeContext, required bool) bool { + if expr.IsArray(att.Type) || expr.IsMap(att.Type) { + return false + } + if expr.IsUnion(att.Type) { + if ctx.Scope.IsSumType() { + return ctx.IsUnionPointer(required) + } + return !required + } + return ctx.Pointer || !required && (att.DefaultValue == nil || !ctx.UseDefault) +} + // validationCode produces Go code that runs the validations that effectively // apply to the given attribute - see expr.EffectiveValidation - if any // against the content of the variable named target. The generated code @@ -397,7 +422,7 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // target is the variable name against which the validation code is generated // // context is used to produce helpful messages in case of error. -func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target, context string) string { +func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target string, context validationPath, localGuards bool) string { validation := expr.EffectiveValidation(att) if validation == nil { return "" @@ -422,10 +447,12 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia data := map[string]any{ "attribute": att, "attCtx": attCtx, - "isPointer": isPointer, + "isPointer": isPointer && localGuards, "context": context, "target": target, "targetVal": tval, + "goa": "goa", + "utf8": "utf8", "string": kind == expr.StringKind, "array": expr.IsArray(att.Type), "map": expr.IsMap(att.Type), @@ -517,29 +544,48 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia return strings.Join(res, "\n") } -// hasValidations returns true if a UserType contains validations. It is a -// pure predicate: it never mutates the design expression tree. +// literalValidationPath folds a complete error path into a quoted Go string +// while Goa is generating source. +func literalValidationPath(root string) validationPath { + return validationPath{root: root} +} + +// parameterValidationPath writes an error path relative to the string held by +// a generated validator parameter. +func parameterValidationPath(parameter string) validationPath { + return validationPath{root: parameter, variable: true} +} + +// child returns the context used for a field or collection value below c. +func (p validationPath) child(prefix string) validationPath { + p.suffix += prefix + return p +} + +// renderValidationPath returns the Go expression passed to a generated +// validation error. +func renderValidationPath(path validationPath) string { + if !path.variable { + return strconv.Quote(path.root + path.suffix) + } + if path.suffix == "" { + return path.root + } + return path.root + " + " + strconv.Quote(path.suffix) +} + +// hasValidations reports whether validating ut can write any code with the Go +// layout described by attCtx. func hasValidations(attCtx *AttributeContext, ut expr.UserType) bool { - // We need to check empirically whether there are validations to be - // generated. We can't call recurseValidationCode() to avoid infinite - // recursions, but we can use validationCode() for the local (non-recursive) - // attribute-level checks — it is the source of truth for whether a given - // attribute produces any validation output, including any skips (e.g. - // format checks on struct:field:type attributes). For nested user types - // and required-field checks we keep the structural walk. - res := false - done := errors.New("done") - Walk(ut.Attribute(), func(a *expr.AttributeExpr) error { // nolint: errcheck - // validationCode computes the validation that effectively applies - // to a - including user type alias chain validations - and returns - // the empty string when there is nothing to validate. - if validationCode(a, attCtx, true, false, "x", "x") != "" { - res = true - return done - } - return nil - }) - return res + policy := GoLayoutPolicy{ + Pointer: attCtx.Pointer, + IgnoreRequired: attCtx.IgnoreRequired, + UseDefault: attCtx.UseDefault, + UnionPointer: attCtx.UnionPointer, + ArrayElementPointer: attCtx.ArrayElementPointer, + SumType: attCtx.Scope.IsSumType(), + } + return NeedsValidation(ut.Attribute(), policy) } // There is a case where there is validation but no actual validation code: if diff --git a/codegen/validation_plan.go b/codegen/validation_plan.go index 3a51f36444..4f13906bb9 100644 --- a/codegen/validation_plan.go +++ b/codegen/validation_plan.go @@ -1,11 +1,12 @@ -// This file retains service and view validation operations before generated -// package names freeze. Linked validation rendering consumes only copied rules, -// symbolic Go layouts, and exact validator declarations. +// This file records every validation check and generated function call before +// Goa chooses Go names. It later writes those checks using the stored field +// shapes, rules, and chosen function names. package codegen import ( "bytes" "fmt" + "path" "strings" "text/template" @@ -13,21 +14,22 @@ import ( ) type ( - // ValidatorBindingRequest identifies one nested user-type validation call. - // Attribute is available only while planning; Layout supplies its already - // bound Go owner and declaration identity. + // ValidatorBindingRequest describes one validation call for a nested user + // type. Attribute is available only while planning. Layout supplies its + // generated package and chosen declaration. ValidatorBindingRequest struct { - // Attribute is the exact nested user-type occurrence. + // Attribute is the nested user type whose validation call is being prepared. Attribute *expr.AttributeExpr - // Layout is the exact symbolic Go layout for Attribute. + // Layout is the planned Go layout for Attribute. Layout *GoTypePlan - // View is the selected nested validator view. Service and projected-view - // validation use the default view, represented by the empty string. + // View selects which result fields the nested validator checks. Service + // types and view-specific result copies use the default view, represented by + // the empty string. View string } - // ValidatorDeclarationBinder returns the exact package-level validator - // declaration selected before generation freeze. + // ValidatorDeclarationBinder returns the package-level validation function + // chosen before Goa starts writing files. ValidatorDeclarationBinder func(ValidatorBindingRequest) (*NameDeclaration, error) // ValidationPlanOptions configures one root validation operation. @@ -40,24 +42,24 @@ type ( Bind ValidatorDeclarationBinder } - // ValidationPlan is an immutable symbolic service/view validation program. - // Expression pointers are retained only for occurrence identity; all rules, - // paths, requiredness, layouts, and validator calls are copied during - // NewValidationPlan. + // ValidationPlan stores the copied checks for one service or view value, + // including calls to validators for nested fields. It keeps expression + // pointers only to recognize the attribute supplied by the caller. ValidationPlan struct { layout *GoTypePlan root *validationPlanNode declarations []*NameDeclaration } - // LinkedValidationPlan renders a ValidationPlan after generated declaration - // names and import aliases freeze. + // LinkedValidationPlan renders a ValidationPlan after Goa has chosen all + // generated function names and package aliases. LinkedValidationPlan struct { plan *ValidationPlan layout LinkedGoType } - // validationPlanNode is one retained recursive validation operation. + // validationPlanNode stores checks for one value and for its fields, + // collection entries, or union branches. validationPlanNode struct { occurrence *expr.AttributeExpr layout *GoTypePlan @@ -70,7 +72,7 @@ type ( union *validationUnionPlan } - // validationRulePlan retains local effective validation values in template + // validationRulePlan stores local effective validation values in template // execution order. validationRulePlan struct { values []any @@ -91,58 +93,59 @@ type ( mapValue bool } - // validationRequiredPlan retains one generated required-field check. + // validationRequiredPlan stores one generated required-field check. validationRequiredPlan struct { name string fieldName string unionKind bool } - // validationFieldPlan retains one object child and its context path segment. + // validationFieldPlan stores one object child and its context path segment. validationFieldPlan struct { name string node *validationPlanNode } - // validationArrayPlan retains one element operation and presence policy. + // validationArrayPlan stores one element operation and presence policy. validationArrayPlan struct { element *validationPlanNode - nonNullableElems bool + checkNilElements bool } - // validationMapPlan retains map key and value validation operations. + // validationMapPlan stores map key and value validation operations. validationMapPlan struct { key *validationPlanNode value *validationPlanNode } - // validationUnionPlan retains generated sum-type branch operations. + // validationUnionPlan stores generated sum-type branch operations. validationUnionPlan struct { cases []validationUnionCasePlan } - // validationUnionCasePlan retains one sum-type accessor and branch program. + // validationUnionCasePlan stores one sum-type accessor and branch program. validationUnionCasePlan struct { typeTag string fieldName string node *validationPlanNode } - // validatorCallPlan retains one exact nested validator declaration. + // validatorCallPlan stores one exact nested validator declaration. validatorCallPlan struct { declaration *NameDeclaration } - // validationPlanner owns all expression reads during validation planning. + // validationPlanner performs all expression reads while validation checks and + // function calls are copied into a plan. validationPlanner struct { bind ValidatorDeclarationBinder declarations []*NameDeclaration } ) -// NewValidationPlan selects every service/view validation operation for -// attribute before generated package names freeze. layout must be the exact -// sum-type Go plan built for attribute with the desired service or view policy. +// NewValidationPlan records every check needed for attribute before Goa chooses +// the final generated names. layout must describe the same attribute and the +// requested service or view representation. func NewValidationPlan(attribute *expr.AttributeExpr, layout *GoTypePlan, options ValidationPlanOptions) (*ValidationPlan, error) { if attribute == nil { return nil, fmt.Errorf("plan validation: attribute must not be nil") @@ -168,14 +171,54 @@ func NewValidationPlan(attribute *expr.AttributeExpr, layout *GoTypePlan, option }, nil } +// NeedsValidation reports whether Goa would generate at least one validation +// check for attribute with the given Go field layout. +func NeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy) bool { + return attributeNeedsValidation(attribute, policy, make(map[expr.UserType]struct{})) +} + // ValidatorDeclarations returns the exact nested validator declarations in // stable call order. Repeated calls deliberately repeat the same pointer. func (p *ValidationPlan) ValidatorDeclarations() []*NameDeclaration { return append([]*NameDeclaration(nil), p.declarations...) } -// Link binds p to its exact linked Go layout after declaration and import -// aliases freeze. +// ImportPreferences returns each package needed by the stored validation +// checks. Goa and standard library packages keep the names used by the +// templates. A package containing another generated validator includes the +// name Goa should try first. +func (p *ValidationPlan) ImportPreferences() []GoTypeImport { + seen := make(map[string]struct{}) + var imports []GoTypeImport + add := func(goImport GoTypeImport) { + if _, exists := seen[goImport.Path]; exists { + return + } + seen[goImport.Path] = struct{}{} + imports = append(imports, goImport) + } + if p.root.usesUTF8() { + add(GoTypeImport{Path: "unicode/utf8"}) + } + if !p.root.empty() { + goa := GoaImport("") + add(GoTypeImport{Name: goa.Name, Path: goa.Path}) + } + for _, declaration := range p.declarations { + owner := declaration.packagePath() + if owner == p.layout.Owner() { + continue + } + add(GoTypeImport{ + Name: strings.ToLower(Goify(path.Base(owner), false)), + Path: owner, + }) + } + return imports +} + +// Link joins p with the Go types and package aliases that Goa chose for the +// generated file. func (p *ValidationPlan) Link(layout LinkedGoType) (LinkedValidationPlan, error) { if layout.plan != p.layout { return LinkedValidationPlan{}, fmt.Errorf("link validation: linked Go type does not belong to this validation plan") @@ -189,25 +232,20 @@ func (p LinkedValidationPlan) Render(target, context string) string { return p.renderNode(p.plan.root, target, context) } -// Imports returns path-unique external validator imports with their frozen -// qualifiers. Imports already supplied by the linked Go layout are not +// Imports returns each external validation package once, using its final alias. +// Imports already supplied by the linked Go layout are not // repeated unless validation calls require them too. func (p LinkedValidationPlan) Imports() []GoTypeImport { - seen := make(map[string]struct{}) - var imports []GoTypeImport - for _, declaration := range p.plan.declarations { - owner := declaration.packagePath() - if owner == p.layout.outputPath { - continue - } - if _, exists := seen[owner]; exists { - continue + preferences := p.plan.ImportPreferences() + if len(preferences) == 0 { + return nil + } + imports := make([]GoTypeImport, len(preferences)) + for index, preference := range preferences { + imports[index] = GoTypeImport{ + Name: p.layout.qualify(preference.Path), + Path: preference.Path, } - seen[owner] = struct{}{} - imports = append(imports, GoTypeImport{ - Name: p.layout.qualify(owner), - Path: owner, - }) } return imports } @@ -286,23 +324,25 @@ func (p *validationPlanner) plan(attribute *expr.AttributeExpr, layout *GoTypePl } case expr.IsArray(attribute.Type): array := expr.AsArray(attribute.Type) - childPolicy := policy - if childPolicy.Pointer && expr.IsPrimitive(array.ElemType.Type) { - childPolicy.Pointer = false - } childLayout := layout.Elem() if childLayout == nil { return nil, fmt.Errorf("plan validation for %s: array layout has no element", path) } + childPolicy := policy + if expr.IsPrimitive(array.ElemType.Type) { + childPolicy.Pointer = childLayout.definitionPointer + } childLayout = childLayout.withPolicy(childPolicy) child, err := p.plan(array.ElemType, childLayout, true, expr.IsAlias(array.ElemType.Type), true, path+"[*]") if err != nil { return nil, err } - if !child.empty() || array.NonNullableElems { + checkNilElements := array.NonNullableElems && + (childLayout.definitionPointer || IsNilable(array.ElemType.Type)) + if !child.empty() || checkNilElements { node.array = &validationArrayPlan{ element: child, - nonNullableElems: array.NonNullableElems, + checkNilElements: checkNilElements, } } case expr.IsMap(attribute.Type): @@ -444,7 +484,8 @@ func generatedRequiredValidationNames(attribute *expr.AttributeExpr, validation return names } -// validationNeedsNilGuard retains validateAttribute's wrapper decision. +// validationNeedsNilGuard reports whether generated checks must first verify +// that the value is not nil. func validationNeedsNilGuard(attribute *expr.AttributeExpr, required bool, policy GoLayoutPolicy) bool { if expr.IsArray(attribute.Type) || expr.IsMap(attribute.Type) { return false @@ -455,19 +496,21 @@ func validationNeedsNilGuard(attribute *expr.AttributeExpr, required bool, polic return policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) } -// userTypeNeedsValidation mirrors the existing nested-validator predicate -// without allocating names or retaining expression-backed render decisions. +// userTypeNeedsValidation reports whether a user-defined type or any value +// inside it needs a generated check. seen stops recursive types. func userTypeNeedsValidation(userType expr.UserType, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { origin := userType.Origin() if _, exists := seen[origin]; exists { return false } seen[origin] = struct{}{} - return attributeNeedsValidation(userType.Attribute(), true, expr.IsAlias(userType), policy, seen) + defer delete(seen, origin) + return attributeNeedsValidation(userType.Attribute(), policy, seen) } -// attributeNeedsValidation reports whether planning the attribute can emit code. -func attributeNeedsValidation(attribute *expr.AttributeExpr, required, alias bool, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { +// attributeNeedsValidation reports whether Goa would generate a check for the +// attribute or a value inside it. +func attributeNeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { validation := expr.EffectiveValidation(attribute) if validation != nil { if len(validation.Values) > 0 || validation.Pattern != "" || @@ -494,22 +537,23 @@ func attributeNeedsValidation(attribute *expr.AttributeExpr, required, alias boo } continue } - if attributeNeedsValidation(field.Attribute, attribute.IsRequired(field.Name), expr.IsAlias(field.Attribute.Type), policy, seen) { + if attributeNeedsValidation(field.Attribute, policy, seen) { return true } } case expr.IsArray(attribute.Type): array := expr.AsArray(attribute.Type) - if array.NonNullableElems { + if array.NonNullableElems && + (IsNilable(array.ElemType.Type) || arrayElementIsPointer(array, policy.ArrayElementPointer)) { return true } - return attributeNeedsValidation(array.ElemType, true, expr.IsAlias(array.ElemType.Type), policy, seen) + return attributeNeedsValidation(array.ElemType, policy, seen) case expr.IsMap(attribute.Type): mapping := expr.AsMap(attribute.Type) mapPolicy := policy mapPolicy.Pointer = false - return attributeNeedsValidation(mapping.KeyType, true, expr.IsAlias(mapping.KeyType.Type), mapPolicy, seen) || - attributeNeedsValidation(mapping.ElemType, true, expr.IsAlias(mapping.ElemType.Type), mapPolicy, seen) + return attributeNeedsValidation(mapping.KeyType, mapPolicy, seen) || + attributeNeedsValidation(mapping.ElemType, mapPolicy, seen) case expr.IsUnion(attribute.Type): for _, branch := range expr.AsUnion(attribute.Type).Values { branchPolicy := policy @@ -520,7 +564,7 @@ func attributeNeedsValidation(attribute *expr.AttributeExpr, required, alias boo } continue } - if attributeNeedsValidation(branch.Attribute, true, expr.IsAlias(branch.Attribute.Type), branchPolicy, seen) { + if attributeNeedsValidation(branch.Attribute, branchPolicy, seen) { return true } } @@ -528,18 +572,22 @@ func attributeNeedsValidation(attribute *expr.AttributeExpr, required, alias boo return false } -// renderNode renders retained operations without reading expression contents. +// renderNode writes the Go checks for node and its children without reading the +// original design expression. func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, context string) string { if node.call != nil { name := p.validatorName(node.call.declaration) var buffer bytes.Buffer - if err := userValT.Execute(&buffer, map[string]any{"name": name, "target": target}); err != nil { + if err := userValT.Execute(&buffer, map[string]any{ + "call": fmt.Sprintf("%s(%s)", name, target), + "goa": p.goaPackage(), + }); err != nil { panic(err) } return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buffer.String()) } var sections []string - if local := renderValidationRules(node.rules, target, context); local != "" { + if local := p.renderValidationRules(node.rules, target, context, !node.guard); local != "" { sections = append(sections, local) } for _, field := range node.fields { @@ -558,8 +606,9 @@ func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, conte if err := arrayValT.Execute(&buffer, map[string]any{ "target": target, "validation": validation, - "nonNullableElems": node.array.nonNullableElems, - "context": context, + "checkNilElements": node.array.checkNilElements, + "context": literalValidationPath(context), + "goa": p.goaPackage(), }); err != nil { panic(err) } @@ -602,16 +651,14 @@ func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, conte code := strings.Join(sections, "\n") if node.guard && code != "" { condition := fmt.Sprintf("if %s != nil {\n", target) - if !strings.HasPrefix(code, condition) { - code = condition + code + "\n}" - } + code = condition + code + "\n}" } return code } -// renderValidationRules renders copied local rules through the canonical -// validation templates. -func renderValidationRules(rules validationRulePlan, target, context string) string { +// renderValidationRules renders copied local rules through the shared +// validation templates using the package names assigned to the linked file. +func (p LinkedValidationPlan) renderValidationRules(rules validationRulePlan, target, context string, localGuards bool) string { targetValue := target if rules.dereference { targetValue = "*" + targetValue @@ -619,11 +666,17 @@ func renderValidationRules(rules validationRulePlan, target, context string) str if rules.aliasCast != "" { targetValue = fmt.Sprintf("%s(%s)", rules.aliasCast, targetValue) } + utf8Package := "" + if rules.stringValue && (rules.minLength != nil || rules.maxLength != nil) { + utf8Package = p.utf8Package() + } data := map[string]any{ - "isPointer": rules.pointer, - "context": context, + "isPointer": rules.pointer && localGuards, + "context": literalValidationPath(context), "target": target, "targetVal": targetValue, + "goa": p.goaPackage(), + "utf8": utf8Package, "string": rules.stringValue, "array": rules.arrayValue, "map": rules.mapValue, @@ -676,20 +729,20 @@ func renderValidationRules(rules validationRulePlan, target, context string) str for _, required := range rules.required { if required.unionKind { rendered = append(rendered, fmt.Sprintf( - "if %s.%s.Kind() == \"\" {\n err = goa.MergeErrors(err, goa.MissingFieldError(%q, %q))\n}", - target, required.fieldName, required.name, context, + "if %s.%s.Kind() == \"\" {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, )) continue } rendered = append(rendered, fmt.Sprintf( - "if %s.%s == nil {\n err = goa.MergeErrors(err, goa.MissingFieldError(%q, %q))\n}", - target, required.fieldName, required.name, context, + "if %s.%s == nil {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, )) } return strings.Join(rendered, "\n") } -// appendValidationTemplate executes one canonical local validation template. +// appendValidationTemplate executes one shared local validation template. func appendValidationTemplate(rendered []string, validationTemplate *template.Template, data map[string]any) []string { var buffer bytes.Buffer if err := validationTemplate.Execute(&buffer, data); err != nil { @@ -711,13 +764,49 @@ func (p LinkedValidationPlan) validatorName(declaration *NameDeclaration) string return p.layout.qualify(owner) + "." + name } +// goaPackage returns the final name of Goa's generated-error package. +func (p LinkedValidationPlan) goaPackage() string { + return p.layout.qualify(GoaImport("").Path) +} + +// utf8Package returns the final name of the standard UTF-8 package. +func (p LinkedValidationPlan) utf8Package() string { + return p.layout.qualify("unicode/utf8") +} + // empty reports whether node emits any validation code. func (n *validationPlanNode) empty() bool { return n.call == nil && n.rules.empty() && len(n.fields) == 0 && n.array == nil && n.mapValue == nil && n.union == nil } -// empty reports whether no local rule was retained. +// usesUTF8 reports whether this validation tree counts runes in a string. +func (n *validationPlanNode) usesUTF8() bool { + if n.rules.stringValue && (n.rules.minLength != nil || n.rules.maxLength != nil) { + return true + } + for _, field := range n.fields { + if field.node.usesUTF8() { + return true + } + } + if n.array != nil && n.array.element.usesUTF8() { + return true + } + if n.mapValue != nil && (n.mapValue.key.usesUTF8() || n.mapValue.value.usesUTF8()) { + return true + } + if n.union != nil { + for _, unionCase := range n.union.cases { + if unionCase.node.usesUTF8() { + return true + } + } + } + return false +} + +// empty reports whether these rules would write no Go checks. func (p validationRulePlan) empty() bool { return p.values == nil && p.format == "" && p.pattern == "" && p.exclusiveMinimum == nil && p.minimum == nil && @@ -725,8 +814,8 @@ func (p validationRulePlan) empty() bool { p.minLength == nil && p.maxLength == nil && len(p.required) == 0 } -// withPolicy returns an occurrence-identical immutable plan view with a -// validation-specific effective policy. It does not modify the shared layout. +// withPolicy copies the prepared type and changes only the rules used to write +// its Go value. It does not modify the original type description. func (p *GoTypePlan) withPolicy(policy GoLayoutPolicy) *GoTypePlan { clone := *p clone.policy = policy diff --git a/codegen/validation_plan_test.go b/codegen/validation_plan_test.go index ca1d6266af..9367cd25e9 100644 --- a/codegen/validation_plan_test.go +++ b/codegen/validation_plan_test.go @@ -1,9 +1,10 @@ -// This file verifies that symbolic validation planning preserves service and -// view validation output without reading expressions after package freeze. +// This file verifies that validation planning preserves service and view +// output without reading expressions after package names are fixed. package codegen import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/require" @@ -11,7 +12,7 @@ import ( "goa.design/goa/v3/expr" ) -// TestValidationPlanPreservesRulesPathsAndRequiredness compares retained rule +// TestValidationPlanPreservesRulesPathsAndRequiredness compares copied rule // rendering with the existing service/view validation generator. func TestValidationPlanPreservesRulesPathsAndRequiredness(t *testing.T) { minimum := 2.0 @@ -60,6 +61,179 @@ func TestValidationPlanPreservesRulesPathsAndRequiredness(t *testing.T) { linked, err := plan.Link(layout.Link("generated.local/gen/service", validationPlanTestQualifier)) require.NoError(t, err) require.Equal(t, want, linked.Render("target", "target")) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) +} + +// TestValidationPlanImportsOnlyUsedRuntimePackages checks that standalone +// validation users receive the packages named directly by rendered checks. +func TestValidationPlanImportsOnlyUsedRuntimePackages(t *testing.T) { + for _, test := range []struct { + name string + validation *expr.ValidationExpr + wantPreferences []GoTypeImport + wantImports []GoTypeImport + }{ + { + name: "no checks", + validation: nil, + }, + { + name: "pattern", + validation: &expr.ValidationExpr{Pattern: "^[a-z]+$"}, + wantPreferences: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + { + name: "string length", + validation: func() *expr.ValidationExpr { + minimum := 2 + return &expr.ValidationExpr{MinLength: &minimum} + }(), + wantPreferences: []GoTypeImport{ + {Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String, Validation: test.validation} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + require.Equal(t, test.wantPreferences, plan.ImportPreferences()) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, test.wantImports, linked.Imports()) + }) + } +} + +// TestValidationPlanImportPreferencesIncludeExternalValidators checks that +// planning includes only packages containing validation functions that the +// generated checks call. +func TestValidationPlanImportPreferencesIncludeExternalValidators(t *testing.T) { + const ( + owner = "generated.local/gen/service" + childOwner = "generated.local/gen/shared" + ) + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, childOwner, child) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generation.Package(childOwner).DeclareName(validator)) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: child}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: child}}, + }} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: childOwner, Type: childDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(ValidatorBindingRequest) (*NameDeclaration, error) { + return validator, nil + }, + }) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + {Name: "shared", Path: childOwner}, + }, plan.ImportPreferences()) +} + +// TestValidationPlanUsesFinalRuntimeImportNames proves rendered checks and +// reported imports use the same collision-safe package names. +func TestValidationPlanUsesFinalRuntimeImportNames(t *testing.T) { + minimum := 2 + attribute := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), func(importPath string) string { + switch importPath { + case "goa.design/goa/v3/pkg": + return "goa2" + case "unicode/utf8": + return "utf82" + default: + t.Fatalf("unexpected validation import %q", importPath) + return "" + } + })) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "utf82", Path: "unicode/utf8"}, + {Name: "goa2", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) + code := linked.Render("target", "target") + require.Contains(t, code, "utf82.RuneCountInString") + require.Contains(t, code, "goa2.MergeErrors") +} + +// TestValidationPlanSharesOptionalFieldGuard verifies that copied validation +// rules use the one nil check selected for their containing field. +func TestValidationPlanSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + + code := linked.Render("target", "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") } // TestValidationPlanCopiesEnumValues verifies that accepted mutable enum @@ -105,6 +279,153 @@ func TestValidationPlanCopiesEnumValues(t *testing.T) { }, plan.root.rules.values) } +// TestNeedsValidation reports whether the validation renderer can write code +// for local rules, nested rules, and values with no rules. +func TestNeedsValidation(t *testing.T) { + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + tests := []struct { + name string + attribute *expr.AttributeExpr + want bool + }{ + { + name: "local rule", + attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }, + want: true, + }, + { + name: "nested rule", + attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }}, + want: true, + }, + { + name: "no rules", + attribute: &expr.AttributeExpr{Type: &expr.Object{{Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}}}, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, NeedsValidation(test.attribute, policy)) + }) + } +} + +// TestValidationPlanChecksOnlyRepresentableNullElements verifies that a null +// check follows the generated element type instead of the raw DSL flag. +func TestValidationPlanChecksOnlyRepresentableNullElements(t *testing.T) { + array := &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: "[a-z]+"}, + }, + NonNullableElems: true, + } + attribute := &expr.AttributeExpr{Type: array} + tests := []struct { + name string + jsonBody bool + wantCheck bool + }{ + {name: "service values"}, + {name: "JSON input pointers", jsonBody: true, wantCheck: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + code := linked.Render("target", "target") + require.Equal(t, test.wantCheck, strings.Contains(code, "e == nil")) + if test.jsonBody { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", *e, \"[a-z]+\")") + } else { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", e, \"[a-z]+\")") + } + require.True(t, NeedsValidation(attribute, policy)) + }) + } + + objectArray := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: &expr.Object{}}, + NonNullableElems: true, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(objectArray, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(objectArray, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Contains(t, linked.Render("target", "target"), "e == nil") + require.True(t, NeedsValidation(objectArray, policy)) +} + +// TestNeedsValidationChecksEverySiblingCopy verifies that one unconstrained +// copy of a type does not hide rules on another copy of the same type. +func TestNeedsValidationChecksEverySiblingCopy(t *testing.T) { + minLength := 2 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + unvalidated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + validated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + expr.AsObject(validated.Type.(expr.UserType).Attribute().Type).Attribute("value").Validation = + &expr.ValidationExpr{MinLength: &minLength} + + tests := []struct { + name string + fields *expr.Object + }{ + { + name: "unvalidated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: unvalidated}, + {Name: "second", Attribute: validated}, + }, + }, + { + name: "validated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: validated}, + {Name: "second", Attribute: unvalidated}, + }, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: test.fields} + require.True(t, NeedsValidation(attribute, policy)) + }) + } +} + // TestValidationPlanPreservesContainersUnionsAndValidatorBindings verifies all // recursive service/view shapes retain exact nested validator declarations. func TestValidationPlanPreservesContainersUnionsAndValidatorBindings(t *testing.T) { @@ -195,7 +516,10 @@ func TestValidationPlanPreservesContainersUnionsAndValidatorBindings(t *testing. linked, err := plan.Link(layout.Link(owner, validationPlanTestQualifier)) require.NoError(t, err) require.Equal(t, want, linked.Render("target", "target")) - require.Empty(t, linked.Imports()) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) } // TestValidationPlanRejectsUnboundNestedValidator verifies planning never @@ -232,6 +556,10 @@ func validationPlanTestQualifier(importPath string) string { switch importPath { case "generated.local/gen/service": return "service" + case "goa.design/goa/v3/pkg": + return "goa" + case "unicode/utf8": + return "utf8" default: panic(fmt.Sprintf("unexpected validation import %q", importPath)) } diff --git a/codegen/validation_protobuf_union_test.go b/codegen/validation_protobuf_union_test.go index 98d6a0ed5a..02757a810c 100644 --- a/codegen/validation_protobuf_union_test.go +++ b/codegen/validation_protobuf_union_test.go @@ -3,6 +3,7 @@ package codegen import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -68,8 +69,9 @@ func (*protobufUnionTestScope) IsSumType() bool { return false } -func (s *protobufUnionTestScope) ValidatorName(att *expr.AttributeExpr, view string) string { - return "Validate" + s.Name(att, "", false, false) + Goify(view, true) +func (s *protobufUnionTestScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + s.Name(att, "", false, false) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) } func (s *protobufUnionTestScope) Scope() *NameScope { diff --git a/codegen/validation_test.go b/codegen/validation_test.go index f592b29061..77c1cc44ea 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -168,8 +168,8 @@ func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { } ctx := NewAttributeContext(false, false, false, "", NewNameScope()) seen := make(map[expr.UserType]*bytes.Buffer) - firstCode := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", "first", seen).String() - secondCode := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", "second", seen).String() + firstCode := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", literalValidationPath("first"), seen).String() + secondCode := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", literalValidationPath("second"), seen).String() require.Contains(t, firstCode, "first.Code") require.Contains(t, firstCode, "InvalidLengthError") @@ -178,6 +178,94 @@ func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { require.Len(t, seen, 2) } +// TestValidationPathsAreSpecializedBeforeRendering verifies that fixed roots +// become string literals while reusable validators receive only their caller's +// path as a runtime value. +func TestValidationPathsAreSpecializedBeforeRendering(t *testing.T) { + minLength := 1 + pattern := "^[a-z]+$" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "nested", + Attribute: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + }, + { + Name: "items", + Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + { + Name: "values", + Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + }} + context := NewAttributeContext(true, false, false, "", NewNameScope()) + + direct := ValidationCode(attribute, nil, context, true, false, false, "body") + direct = FormatTestCode(t, "package foo\nfunc validate() (err error) {\n"+direct+"\nreturn\n}") + require.Contains(t, direct, `"body.nested.value"`) + require.Contains(t, direct, `"body.items[*]"`) + require.Contains(t, direct, `"body.values[key]"`) + require.NotContains(t, direct, "path+") + + nested := ValidationCodeWithPathParameter(attribute, nil, context, true, false, false, "body", "path") + nested = FormatTestCode(t, "package foo\nfunc validate(path string) (err error) {\n"+nested+"\nreturn\n}") + require.Contains(t, nested, `path+".nested.value"`) + require.Contains(t, nested, `path+".items[*]"`) + require.Contains(t, nested, `path+".values[key]"`) + require.NotContains(t, nested, "fmt.Sprintf") +} + +// TestValidationCodeSharesOptionalFieldGuard verifies that the direct +// validation renderer checks one optional primitive once before all its rules. +func TestValidationCodeSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code := ValidationCode(attribute, nil, context, true, false, false, "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") + + root := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + } + rootCode := ValidationCode(root, nil, context, false, false, false, "target") + require.Equal(t, 2, strings.Count(rootCode, "if target != nil")) + require.Contains(t, rootCode, "*target") +} + // TestMultipleAliasTypesInSameStruct tests that multiple fields with the same // alias type can be validated independently. Previously, the recursion guard // would incorrectly block validation of the second field. @@ -291,7 +379,10 @@ func TestValidationCodeUsesBothExclusiveBounds(t *testing.T) { require.NoError(t, err) plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) require.NoError(t, err) - linked, err := plan.Link(layout.Link("generated.local/gen/service", nil)) + linked, err := plan.Link(layout.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "goa.design/goa/v3/pkg", importPath) + return "goa" + })) require.NoError(t, err) require.Equal(t, legacy, linked.Render("target", "target")) } diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md index 548fdfff6a..0dddaa1abf 100644 --- a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -590,7 +590,7 @@ All commands must pass. - Produces: retained OpenAPI and example plans - Produces: one core command plan with no render-time root or generation reconstruction -- [ ] **Step 1: Add selective-command and plan-identity REDs** +- [x] **Step 1: Add selective-command and plan-identity REDs** For `gen`, `example`, and focused test commands, assert each selected subsystem is planned once, each renderer receives the exact retained pointer, unselected @@ -598,7 +598,7 @@ subsystems allocate nothing, and the prepared root remains unchanged after the plan boundary. Cover OpenAPI-only semantic example IDs separately from Go declaration identity. -- [ ] **Step 2: Retain OpenAPI and example analysis** +- [x] **Step 2: Retain OpenAPI and example analysis** Build typed OpenAPI plans from prepared expressions and typed example plans from exact service/transport plans. The example plan owns its server @@ -610,7 +610,7 @@ build cannot mutate it. Use the typed example identities established in Task 6. Collect every example and CLI package-level constructor, variable, and helper through the owning package catalog. -- [ ] **Step 3: Make the core plan the only command execution model** +- [x] **Step 3: Make the core plan the only command execution model** Have command factories construct one private-field `generator.Plan` containing the exact selected subsystem plans. Core render dispatch reads those fields; @@ -618,7 +618,7 @@ it does not call `NewPlan`, `NewServicesData`, `Generation.Roots`, or accept a second generated module path. Remove all remaining generator adapters and callback-shaped lifecycle tests. -- [ ] **Step 4: Prove purity, selection, repeated runs, and compilation** +- [x] **Step 4: Prove purity, selection, repeated runs, and compilation** Run each command twice and concurrently with different roots. Assert byte- identical output per input, no cross-run state, no late declarations, and no @@ -628,7 +628,7 @@ design appears in the other and the first returned result remains unchanged after the second build. Run both concurrency tests with the race detector. Compile full HTTP/gRPC/JSON-RPC examples and validate both OpenAPI versions. -- [ ] **Step 5: Verify and commit Task 10** +- [x] **Step 5: Verify and commit Task 10** Run: diff --git a/dsl/attribute.go b/dsl/attribute.go index 0b8b21787b..9a7dcfea62 100644 --- a/dsl/attribute.go +++ b/dsl/attribute.go @@ -138,7 +138,7 @@ func Attribute(name string, args ...any) { var attr *expr.AttributeExpr { if ref := parent.Find(name); ref != nil { - attr = expr.DupAtt(ref) + attr = expr.DupAttForDSL(ref) } dataType, description, fn := parseAttributeArgs(attr, args...) @@ -170,7 +170,7 @@ func Attribute(name string, args ...any) { } union := parent.Type.(*expr.Union) if _, ok := attr.Type.(expr.UserType); !ok { - att := expr.DupAtt(attr) + att := expr.DupAttForDSL(attr) attr.Type = &expr.UserTypeExpr{AttributeExpr: att, TypeName: union.TypeName + expr.Title(name)} } union.Values = append(union.Values, &expr.NamedAttributeExpr{Name: name, Attribute: attr}) diff --git a/dsl/http.go b/dsl/http.go index 40fd5c90f1..14ac5df967 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -783,6 +783,8 @@ func MapParams(args ...any) { // MIME multipart encoding as defined in RFC 2046. // // MultipartRequest must appear in a HTTP endpoint expression. +// At least one payload value must remain in the request body after path, +// query, header, and cookie mappings are applied. // // goa generates a custom encoder that writes the payload for requests made to // HTTP endpoints that use MultipartRequest. The generated encoder accept a @@ -790,11 +792,11 @@ func MapParams(args ...any) { // multipart content. The user provided function accepts a multipart writer // and a reference to the payload and is responsible for encoding the payload. // goa also generates a custom decoder that reads back the multipart content -// into the payload struct. The generated decoder also accepts a user provided -// function that takes a multipart reader and a reference to the payload struct -// as parameter. The user provided decoder is responsible for decoding the -// multipart content into the payload. The example command generates a default -// implementation for the user decoder and encoder. +// into the generated HTTP request body. The generated decoder accepts a user +// provided function that takes a multipart reader and a reference to that body +// as parameters. Goa validates the decoded body before it builds the service +// payload. The example command generates a default implementation for the user +// decoder and encoder. func MultipartRequest() { e, ok := eval.Current().(*expr.HTTPEndpointExpr) if !ok { @@ -979,7 +981,7 @@ func Body(args ...any) { eval.ReportError("%s type does not have an attribute named %#v", kind, a) return } - attr = expr.DupAtt(attr) + attr = expr.DupAttForDSL(attr) attr.AddMeta("origin:attribute", a) if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok && expr.IsArray(rt.Type) { // If the attribute type is a result type collection add the type to the diff --git a/dsl/jsonrpc.go b/dsl/jsonrpc.go index c6d7240cb1..1bad1cc60f 100644 --- a/dsl/jsonrpc.go +++ b/dsl/jsonrpc.go @@ -27,7 +27,7 @@ const ( // JSONRPC configures a service to use JSON-RPC 2.0 transport. // The generated code handles JSON-RPC protocol details: request parsing, method dispatch, // response formatting, and batch processing. All service JSON-RPC methods share -// a single HTTP endpoint and must use the same transport (HTTP, WebSocket or SSE). +// a single HTTP POST endpoint. Methods may stream results over Server-Sent Events. // // JSONRPC can be used at three levels: // @@ -61,32 +61,15 @@ const ( // notifications), and marshal the responses into a single array of JSON-RPC // response objects in the HTTP response body. // -// WebSocket: -// -// For WebSocket transport, methods that use StreamingPayload() and/or StreamingResult() -// enable bidirectional streaming: each payload or result element is sent as a separate, -// complete JSON-RPC message over the WebSocket connection. When using WebSockets, all -// methods must use StreamingPayload() for their payload (if any) and StreamingResult() -// for their result (if any), because a single WebSocket connection is shared by all -// methods of a service and client. Non-streaming methods are not supported over WebSockets. -// -// WebSocket methods can have three patterns: -// - StreamingPayload() only: Client-to-server notifications (no response) -// - StreamingResult() only: Server-to-client notifications (no request ID, sent without client request) -// - Both StreamingPayload() and StreamingResult(): Bidirectional request/response streaming -// -// Server-side notifications (methods with StreamingResult() but no StreamingPayload()) are -// sent from the server to the client without an associated request ID, as they are not -// responses to client requests but rather server-initiated messages. -// // Server-Sent Events: // -// For Server-Sent Events (SSE), enable SSE by calling the ServerSentEvents() function -// within the JSONRPC expression. In this mode, each element of the result is sent as a -// separate JSON-RPC response within its own SSE event. The SSE id field is mapped to -// the result's ID attribute. Because all methods for a given service and client -// share the same HTTP endpoint, every method must use both StreamingResult() and -// ServerSentEvents() to ensure correct streaming behavior. +// A JSON-RPC method may stream results by defining StreamingResult() and calling +// ServerSentEvents() in its method-level JSONRPC expression. The client sends one +// JSON-RPC request. Each streamed value is sent as a complete JSON-RPC message in +// a separate SSE event. The SSE id field may be mapped to a result attribute. +// JSON-RPC does not support StreamingPayload(), bidirectional streaming, or one +// method that defines both Result() and StreamingResult(). Use +// separate methods when clients need both a stream and a final resource. // // Using JSON-RPC with Other Transports: // @@ -94,15 +77,6 @@ const ( // For example, a method can have both standard HTTP or gRPC endpoints in addition // to a JSON-RPC endpoint. // -// Important WebSocket Limitation: -// -// A service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. -// This is because JSON-RPC WebSocket uses a single underlying WebSocket connection -// for all methods in the service, with method dispatch happening at the protocol level -// through JSON-RPC message routing. In contrast, pure HTTP WebSocket creates individual -// connections per streaming endpoint. These two approaches are fundamentally incompatible -// and cannot coexist in the same service. -// // Error Codes: // // Use the predefined constants for standard JSON-RPC errors: @@ -112,7 +86,7 @@ const ( // - RPCInvalidParams (-32602): Invalid method parameters // - RPCInternalError (-32603): Internal JSON-RPC error (default for unmapped errors) // -// Example - Complete service with request/notification handling and streaming: +// Example - Service with request and notification handling: // // Service("calc", func() { // Error("timeout", ErrTimeout, "Request timed out") // Define an error that all service methods can return @@ -144,44 +118,6 @@ const ( // }) // }) // -// Example - WebSocket streaming service: -// -// Service("chat", func() { -// JSONRPC(func() { -// GET("/ws") // Use GET for WebSocket endpoint -// }) -// Method("send", func() { -// StreamingPayload(func() { -// Attribute("message", String, "Message to send") -// }) -// JSONRPC(func() { -// // Client-to-server notification (no response) -// }) -// }) -// Method("notify", func() { -// StreamingResult(func() { -// Attribute("event", String, "Server notification") -// Attribute("data", Any, "Notification data") -// }) -// JSONRPC(func() { -// // Server-to-client notification (no request ID, server-initiated) -// }) -// }) -// Method("echo", func() { -// StreamingPayload(func() { -// ID("req_id", String, "Request ID") -// Attribute("message", String, "Message to echo") -// }) -// StreamingResult(func() { -// ID("req_id", String, "Request ID") -// Attribute("echo", String, "Echoed message") -// }) -// JSONRPC(func() { -// // Bidirectional request/response streaming -// }) -// }) -// }) -// // Example - SSE streaming service: // // Service("updater", func() { @@ -198,7 +134,7 @@ const ( // Attribute("data", Data, "Event data") // }) // JSONRPC(func() { -// ServerSentEvents(func() { // Use SSE instead of WebSocket +// ServerSentEvents(func() { // Stream results as server-sent events // SSERequestID("last_event_id") // Map SSE Last-Event-ID header to payload "last_event_id" attribute // SSEEventID("id") // Use "id" result attribute as SSE event ID // }) diff --git a/dsl/meta.go b/dsl/meta.go index 750f1b56ad..c20adae516 100644 --- a/dsl/meta.go +++ b/dsl/meta.go @@ -157,10 +157,13 @@ const DefaultProtoc = expr.DefaultProtoc // that command. The given command will have additional arguments appended and // is expected to behave similar to protoc. // -// Can be used to specify custom options or alternate implementations. The -// default command can be specified using DefaultProtoc. +// Goa always uses protoc-gen-go v1.36.12 and protoc-gen-go-grpc v1.6.2. It +// finds and checks both programs before code generation starts, then passes +// their absolute paths to the chosen compiler. A protoc:cmd value may add +// other compiler options, but it cannot use --plugin to replace either of +// these programs. The default compiler can be specified using DefaultProtoc. // -// // Use Go run to run a drop-in replacement for protoc. +// // Use Go run to run another compiler that accepts protoc arguments. // var _ = API("myapi", func() { // Meta("protoc:cmd", "go", "run", "github.com/duckbrain/goprotoc") // }) diff --git a/dsl/payload.go b/dsl/payload.go index 2e96fd8a37..8fc5abc3f4 100644 --- a/dsl/payload.go +++ b/dsl/payload.go @@ -88,9 +88,9 @@ func Payload(val any, args ...any) { // // The arguments to a StreamingPayload DSL is same as the Payload DSL. // -// StreamingPayload requires a transport that supports client-to-server streaming -// such as gRPC or WebSockets. When using HTTP or JSON-RPC transports, methods -// with StreamingPayload must use WebSockets (via GET endpoints). +// StreamingPayload requires a transport that supports client-to-server +// streaming. gRPC supports it directly. Ordinary HTTP methods use a WebSocket +// through a GET endpoint. JSON-RPC methods do not support StreamingPayload. // For gRPC methods that define both Payload and StreamingPayload, the ordinary // method payload is sent once as the initial typed stream frame and the // StreamingPayload values are sent as subsequent stream item frames. @@ -175,7 +175,7 @@ func methodDSL(m *expr.MethodExpr, suffix string, p any, args ...any) *expr.Attr // Do not duplicate type if it is not customized return &expr.AttributeExpr{Type: actual} } - dupped := expr.Dup(actual) + dupped := expr.DupForDSL(actual) att = &expr.AttributeExpr{Type: dupped} if f, ok := args[len(args)-1].(func()); ok { numreqs := 0 diff --git a/dsl/result_type.go b/dsl/result_type.go index bb82848587..a796df713f 100644 --- a/dsl/result_type.go +++ b/dsl/result_type.go @@ -529,7 +529,7 @@ func buildView(name string, mt *expr.ResultTypeExpr, at *expr.AttributeExpr) (*e n := nat.Name cat := nat.Attribute if existing := mt.Find(n); existing != nil { - dup := expr.DupAtt(existing) + dup := expr.DupAttForDSL(existing) if v, ok := cat.Meta.Last(expr.ViewMetaKey); ok { dup.AddMeta("view", v) } diff --git a/expr/attached_service.go b/expr/attached_service.go new file mode 100644 index 0000000000..d0a16390c9 --- /dev/null +++ b/expr/attached_service.go @@ -0,0 +1,239 @@ +// This file checks and finishes services that generators add after the design +// DSL has run. +package expr + +import ( + "fmt" + "slices" + + "goa.design/goa/v3/eval" +) + +// EvaluateAttachedServices prepares, checks, and finishes services that a +// generator added to r. The services and types must already belong to r. No +// service is finished unless every added expression is valid. +func (r *RootExpr) EvaluateAttachedServices(services []*ServiceExpr, types ...UserType) error { + sets, err := r.attachedServiceExpressions(services, types) + if err != nil { + return err + } + prepareExpressions(sets) + if err := validateExpressions(r, sets); err != nil { + return err + } + finalizeExpressions(sets) + return nil +} + +// attachedServiceExpressions verifies that each service and type belongs to r +// and that every endpoint points to a method on its service. It returns the +// expressions in the order that Prepare, Validate, and Finalize must run. +func (r *RootExpr) attachedServiceExpressions( + services []*ServiceExpr, + types []UserType, +) ([]eval.ExpressionSet, error) { + selected := make(map[*ServiceExpr]struct{}, len(services)) + methods := make(eval.ExpressionSet, 0) + for _, service := range services { + if !slices.Contains(r.Services, service) { + return nil, fmt.Errorf("service %q is not part of this design", service.Name) + } + if r.Service(service.Name) != service { + return nil, fmt.Errorf("service name %q is already used in this design", service.Name) + } + if _, ok := selected[service]; ok { + return nil, fmt.Errorf("service %q was provided more than once", service.Name) + } + selected[service] = struct{}{} + for _, method := range service.Methods { + if method.Service != service { + return nil, fmt.Errorf("method %q belongs to a different service", method.Name) + } + methods = append(methods, method) + } + } + + typeExpressions := make(eval.ExpressionSet, len(types)) + for index, userType := range types { + if !slices.Contains(r.Types, userType) { + return nil, fmt.Errorf("type %q is not part of this design", userType.Name()) + } + typeExpressions[index] = userType.Attribute() + } + + var ( + httpServices, httpEndpoints, httpFileServers eval.ExpressionSet + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers eval.ExpressionSet + grpcServices, grpcEndpoints eval.ExpressionSet + err error + ) + if r.API.HTTP != nil { + httpServices, httpEndpoints, httpFileServers, err = collectHTTPExpressions( + r.API.HTTP.Services, + r.API.HTTP, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.JSONRPC != nil { + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers, err = collectHTTPExpressions( + r.API.JSONRPC.Services, + &r.API.JSONRPC.HTTPExpr, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.GRPC != nil { + grpcServices, grpcEndpoints, err = collectGRPCExpressions(r.API.GRPC.Services, selected) + if err != nil { + return nil, err + } + } + + for service := range selected { + service.design = r + } + return []eval.ExpressionSet{ + typeExpressions, + eval.ToExpressionSet(services), + methods, + httpServices, + httpEndpoints, + httpFileServers, + jsonrpcServices, + jsonrpcEndpoints, + jsonrpcFileServers, + grpcServices, + grpcEndpoints, + }, nil +} + +// collectHTTPExpressions returns the selected HTTP services, endpoints, and +// file servers. It rejects a child that points to another service. +func collectHTTPExpressions( + transports []*HTTPServiceExpr, + httpRoot *HTTPExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + fileServers := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + if transport.Root != httpRoot { + return nil, nil, nil, fmt.Errorf("HTTP service %q uses a different design", transport.Name()) + } + services = append(services, transport) + for _, endpoint := range transport.HTTPEndpoints { + if endpoint.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q belongs to a different HTTP service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + for _, fileServer := range transport.FileServers { + if fileServer.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP file server %q belongs to a different HTTP service", + fileServer.FilePath, + ) + } + fileServers = append(fileServers, fileServer) + } + } + return services, endpoints, fileServers, nil +} + +// collectGRPCExpressions returns the selected gRPC services and endpoints and +// rejects any endpoint that points outside its service. +func collectGRPCExpressions( + transports []*GRPCServiceExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + services = append(services, transport) + for _, endpoint := range transport.GRPCEndpoints { + if endpoint.Service != transport { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q belongs to a different gRPC service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + } + return services, endpoints, nil +} + +// prepareExpressions calls Prepare on each added expression in Goa's required +// order. +func prepareExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if preparer, ok := expression.(eval.Preparer); ok { + preparer.Prepare() + } + } + } +} + +// validateExpressions checks the complete design and each added expression. It +// returns all errors together. +func validateExpressions(root *RootExpr, sets []eval.ExpressionSet) error { + errors := new(eval.ValidationErrors) + if err := root.Validate(); err != nil { + errors.AddError(root, err) + } + for _, set := range sets { + for _, expression := range set { + if validator, ok := expression.(eval.Validator); ok { + if err := validator.Validate(); err != nil { + errors.AddError(expression, err) + } + } + } + } + if len(errors.Errors) > 0 { + return errors + } + return nil +} + +// finalizeExpressions calls Finalize on each added expression after every +// check succeeds. +func finalizeExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if finalizer, ok := expression.(eval.Finalizer); ok { + finalizer.Finalize() + } + } + } +} diff --git a/expr/attached_service_test.go b/expr/attached_service_test.go new file mode 100644 index 0000000000..6abeaccf36 --- /dev/null +++ b/expr/attached_service_test.go @@ -0,0 +1,224 @@ +// This file checks services that generators add after the design DSL has +// finished running. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvaluateAttachedServicesUsesOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + other := newRootExprForTest("other_header") + Root = other + + service, _, endpoint := attachTestService(owner, "generated") + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + + headers := AsObject(endpoint.Headers.Type) + require.NotNil(t, headers.Attribute("owner_header")) + require.Nil(t, headers.Attribute("other_header")) +} + +func TestEvaluateAttachedServicesDoesNotReadPackageRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service, _, endpoint := attachTestService(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.NotNil(t, AsObject(endpoint.Headers.Type).Attribute("owner_header")) +} + +func TestEvaluateAttachedGRPCServiceUsesOwningRootForAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedGRPCServiceIgnoresPackageRootAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + other := newRootExprForTest("other_header") + other.Errors = append(other.Errors, &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + Name: "failed", + }) + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedServiceFinishesFileServerWithOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServiceIgnoresPackageRootForFileServer(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + other := newRootExprForTest("other_header") + other.API.HTTP.Path = "/other" + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServicesChecksAllBeforeFinishing(t *testing.T) { + root := newRootExprForTest("owner_header") + first, firstTransport, _ := attachTestService(root, "first") + second, _, _ := attachTestService(root, "second") + second.Methods[0].Payload.Validation = &ValidationExpr{Required: []string{"missing"}} + + err := root.EvaluateAttachedServices([]*ServiceExpr{first, second}) + + require.Error(t, err) + require.Empty(t, firstTransport.Paths) +} + +func TestEvaluateAttachedServicesRunsDifferentRootsTogether(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + firstRoot := newRootExprForTest("first_header") + firstService, _, firstEndpoint := attachTestService(firstRoot, "first") + secondRoot := newRootExprForTest("second_header") + secondService, _, secondEndpoint := attachTestService(secondRoot, "second") + Root = nil + + errors := make(chan error, 2) + go func() { + errors <- firstRoot.EvaluateAttachedServices([]*ServiceExpr{firstService}) + }() + go func() { + errors <- secondRoot.EvaluateAttachedServices([]*ServiceExpr{secondService}) + }() + require.NoError(t, <-errors) + require.NoError(t, <-errors) + + require.NotNil(t, AsObject(firstEndpoint.Headers.Type).Attribute("first_header")) + require.Nil(t, AsObject(firstEndpoint.Headers.Type).Attribute("second_header")) + require.NotNil(t, AsObject(secondEndpoint.Headers.Type).Attribute("second_header")) + require.Nil(t, AsObject(secondEndpoint.Headers.Type).Attribute("first_header")) +} + +// newRootExprForTest returns a design with one API header. +func newRootExprForTest(header string) *RootExpr { + api := NewAPIExpr("test", func() {}) + obj := &Object{} + obj.Set(header, &AttributeExpr{Type: String}) + api.HTTP.Headers = NewMappedAttributeExpr(&AttributeExpr{Type: obj}) + return &RootExpr{API: api} +} + +// attachTestService adds one HTTP service whose payload contains the API +// header used by the test. +func attachTestService(root *RootExpr, name string) (*ServiceExpr, *HTTPServiceExpr, *HTTPEndpointExpr) { + payload := &Object{} + for _, header := range *AsObject(root.API.HTTP.Headers.Type) { + payload.Set(header.Name, header.Attribute) + } + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: payload}, + Result: &AttributeExpr{Type: Empty}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.HTTP.ServiceFor(service, root.API.HTTP) + endpoint := transport.EndpointFor(method) + endpoint.Routes = []*RouteExpr{{ + Method: "POST", + Path: "/run", + Endpoint: endpoint, + }} + return service, transport, endpoint +} + +// attachTestGRPCServiceWithAPIError adds one gRPC method that uses an error +// response defined for the complete API. +func attachTestGRPCServiceWithAPIError(root *RootExpr, name string) *ServiceExpr { + apiError := &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + } + root.Errors = append(root.Errors, apiError) + response := &GRPCResponseExpr{ + StatusCode: 13, + Parent: root.API.GRPC, + } + response.Prepare() + root.API.GRPC.Errors = append(root.API.GRPC.Errors, &GRPCErrorExpr{ + Name: "failed", + Response: response, + }) + + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + Errors: []*ErrorExpr{{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + }}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.GRPC.ServiceFor(service) + transport.EndpointFor(method.Name, method) + return service +} diff --git a/expr/attribute.go b/expr/attribute.go index 3a67e9b9c1..6f50575c00 100644 --- a/expr/attribute.go +++ b/expr/attribute.go @@ -1,3 +1,6 @@ +// This file defines Goa attributes and their validation rules. It also lets +// transport and generated-type copies point back to the exact attribute +// written in the evaluated design. package expr import ( @@ -32,9 +35,12 @@ type ( DefaultValue any // UserExample set in DSL or computed in Finalize UserExamples []*ExampleExpr - // finalized is true if the attribute has been finalized - only - // applies if attribute type is an object + // finalized reports whether bases and references have already been + // applied to this attribute. finalized bool + // authored points to the first attribute copied from the evaluated + // design. It is nil while this value is that original attribute. + authored *AttributeExpr } // ExampleExpr represents an example. @@ -109,6 +115,15 @@ type ( CookieSameSiteValue string ) +// AuthoredAttribute returns the first attribute from which a was copied. It +// returns a when a was written directly in the evaluated design. +func (a *AttributeExpr) AuthoredAttribute() *AttributeExpr { + if a.authored != nil { + return a.authored + } + return a +} + const ( // FormatDate describes RFC3339 date values. FormatDate ValidationFormat = "date" @@ -160,9 +175,6 @@ const ( CookieSameSiteDefault CookieSameSiteValue = "default" ) -// validated keeps track of validated attributes to handle cyclical definitions. -var validated = make(map[*AttributeExpr]bool) - // TaggedAttribute returns the name of the child attribute of a with the given // tag if a is an object. func TaggedAttribute(a *AttributeExpr, tag string) string { @@ -237,10 +249,20 @@ func (a *AttributeExpr) EvalName() string { // to be used in error messages. The parent definition context is automatically // added to error messages. func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.ValidationErrors { - if validated[a] { + return a.validate(ctx, parent, make(map[*AttributeExpr]struct{})) +} + +// validate checks attributes reached from a. The map stops a type that refers +// to itself from being checked forever. +func (a *AttributeExpr) validate( + ctx string, + parent eval.Expression, + visited map[*AttributeExpr]struct{}, +) *eval.ValidationErrors { + if _, ok := visited[a]; ok { return nil } - validated[a] = true + visited[a] = struct{}{} verr := new(eval.ValidationErrors) if a.Type == nil { verr.Add(parent, "attribute type is nil") @@ -269,14 +291,14 @@ func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.Valid for _, nat := range *o { verr.Merge(a.validatePkgPath(pkgPath, nat.Attribute.Type)) ctx = fmt.Sprintf("field %s", nat.Name) - verr.Merge(nat.Attribute.Validate(ctx, parent)) + verr.Merge(nat.Attribute.validate(ctx, parent, visited)) } } else if ar := AsArray(a.Type); ar != nil { elemType := ar.ElemType - verr.Merge(elemType.Validate(ctx, a)) + verr.Merge(elemType.validate(ctx, a, visited)) } else if u := AsUnion(a.Type); u != nil { for _, ut := range u.Values { - verr.Merge(ut.Attribute.Validate(ctx, parent)) + verr.Merge(ut.Attribute.validate(ctx, parent, visited)) } } diff --git a/expr/attribute_test.go b/expr/attribute_test.go index b8451687de..320998cb76 100644 --- a/expr/attribute_test.go +++ b/expr/attribute_test.go @@ -1129,3 +1129,79 @@ func TestAttributeExprValidationValidate(t *testing.T) { } } } + +func TestAttributeExprValidateChecksEachCall(t *testing.T) { + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + attribute := &AttributeExpr{ + Type: &Object{}, + Validation: &ValidationExpr{Required: []string{"missing"}}, + } + + first := attribute.Validate("payload", parent) + second := attribute.Validate("payload", parent) + + if first == nil { + t.Error("first check returned no errors, expected 1") + } else if len(first.Errors) != 1 { + t.Errorf("first check returned %d errors, expected 1", len(first.Errors)) + } + if second == nil { + t.Error("second check returned no errors, expected 1") + } else if len(second.Errors) != 1 { + t.Errorf("second check returned %d errors, expected 1", len(second.Errors)) + } +} + +func TestAttributeExprValidateChecksSharedTypeOncePerCall(t *testing.T) { + minLength, maxLength := 2, 1 + shared := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "value", + Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{ + MinLength: &minLength, + MaxLength: &maxLength, + }, + }, + }, + }}, + TypeName: "Shared", + } + attribute := &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "first", + Attribute: &AttributeExpr{Type: shared}, + }, + &NamedAttributeExpr{ + Name: "second", + Attribute: &AttributeExpr{Type: shared}, + }, + }} + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + + checks := []*eval.ValidationErrors{ + attribute.Validate("payload", parent), + attribute.Validate("payload", parent), + } + for i, result := range checks { + if result == nil { + t.Errorf("check %d returned no errors, expected 1", i+1) + continue + } + if len(result.Errors) != 1 { + t.Errorf("check %d returned %d errors, expected 1", i+1, len(result.Errors)) + continue + } + if got, want := result.Errors[0].Error(), "field value - min length is greater than max length"; got != want { + t.Errorf("check %d returned %q, expected %q", i+1, got, want) + } + } +} diff --git a/expr/dup.go b/expr/dup.go index 77b5c4f2e4..ac361470d8 100644 --- a/expr/dup.go +++ b/expr/dup.go @@ -1,5 +1,5 @@ -// This file copies design data types while preserving declaration provenance, -// so compiler-created graphs can still resolve their original generated names. +// This file copies design types while preserving which original declaration +// each copied type came from. package expr import ( @@ -11,25 +11,38 @@ func Dup(d DataType) DataType { return newDupper().DupType(d) } -// DupAtt creates a copy of the given attribute. +// DupForDSL creates a copy of the given data type and registers copied result +// types so Goa evaluates their DSL. +func DupForDSL(dataType DataType) DataType { + dupper := newDSLDupper() + result := dupper.DupType(dataType) + dupper.registerResultTypes() + return result +} + +// DupAtt creates a copy of the given attribute without changing the design. func DupAtt(att *AttributeExpr) *AttributeExpr { - dupper := newDupper() - duppedBases := make([]DataType, len(att.Bases)) - for i, b := range att.Bases { - duppedBases[i] = dupper.DupType(b) - } - res := dupper.DupAttribute(att) - res.Bases = duppedBases - return res + return newDupper().DupAtt(att) +} + +// DupAttForDSL creates a copy of the given attribute and registers copied +// result types so Goa evaluates their DSL. +func DupAttForDSL(att *AttributeExpr) *AttributeExpr { + dupper := newDSLDupper() + result := dupper.DupAtt(att) + dupper.registerResultTypes() + return result } // dupper implements recursive and cycle safe copy of data types. type dupper struct { - uts map[UserType]UserType - ats map[*AttributeExpr]struct{} + uts map[UserType]UserType + ats map[*AttributeExpr]struct{} + registerTypes bool + resultTypeCopies []*ResultTypeExpr } -// newDupper returns a new initialized dupper. +// newDupper returns a copier that does not change the evaluated design. func newDupper() *dupper { return &dupper{ uts: make(map[UserType]UserType), @@ -37,6 +50,26 @@ func newDupper() *dupper { } } +// newDSLDupper returns a copier that records generated result types whose DSL +// must run before evaluation is complete. +func newDSLDupper() *dupper { + dupper := newDupper() + dupper.registerTypes = true + return dupper +} + +// DupAtt creates a copy of att and its base attributes with one shared type +// map so repeated and recursive types remain shared in the copy. +func (d *dupper) DupAtt(att *AttributeExpr) *AttributeExpr { + duppedBases := make([]DataType, len(att.Bases)) + for i, b := range att.Bases { + duppedBases[i] = d.DupType(b) + } + res := d.DupAttribute(att) + res.Bases = duppedBases + return res +} + // DupAttribute creates a copy of the given attribute. func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { if _, ok := d.ats[att]; ok { @@ -61,6 +94,7 @@ func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { DSLFunc: att.DSLFunc, UserExamples: att.UserExamples, finalized: att.finalized, + authored: att.AuthoredAttribute(), } d.ats[&dup] = struct{}{} return &dup @@ -112,12 +146,11 @@ func (d *dupper) DupType(t DataType) DataType { dupAtt := d.DupAttribute(actual.Attribute()) dp.SetAttribute(dupAtt) - // Make sure that if we are dupping a generated type we also put - // the dup in the generated type list so that it gets properly - // eval'd. - if rt, ok := dp.(*ResultTypeExpr); ok { + // DSL copies must be evaluated because their DSL may define views + // used by the attribute that contains the copy. + if rt, ok := dp.(*ResultTypeExpr); d.registerTypes && ok { if GeneratedResultType(rt.Identifier) != nil { - GeneratedResultTypes.Append(rt) + d.resultTypeCopies = append(d.resultTypeCopies, rt) } } @@ -125,3 +158,11 @@ func (d *dupper) DupType(t DataType) DataType { } panic("unknown type " + fmt.Sprintf("%T", t)) } + +// registerResultTypes adds every generated result type found in a DSL copy +// after the complete graph has been copied. +func (d *dupper) registerResultTypes() { + for _, resultType := range d.resultTypeCopies { + GeneratedResultTypes.Append(resultType) + } +} diff --git a/expr/dup_test.go b/expr/dup_test.go index b091f058e7..752b3adbeb 100644 --- a/expr/dup_test.go +++ b/expr/dup_test.go @@ -22,6 +22,36 @@ func TestDupPreservesNonNullableArrayElements(t *testing.T) { assert.True(t, duplicate.NonNullableElems) } +// TestDupGeneratedResultTypes verifies that ordinary copies do not change the +// evaluated design and DSL copies register the result types whose DSL must run. +func TestDupGeneratedResultTypes(t *testing.T) { + ResetDSL(t) + + resultType := NewResultTypeExpr("Item", "application/vnd.item", func() {}) + GeneratedResultTypes.Append(resultType) + attribute := &AttributeExpr{Type: &Array{ElemType: &AttributeExpr{Type: resultType}}} + + duplicate := DupAtt(attribute) + require.Len(t, *GeneratedResultTypes, 1) + require.NotSame(t, resultType, duplicate.Type.(*Array).ElemType.Type) + + dslDuplicate := DupAttForDSL(attribute) + require.Len(t, *GeneratedResultTypes, 2) + require.Same(t, dslDuplicate.Type.(*Array).ElemType.Type, (*GeneratedResultTypes)[1]) +} + +func TestIsErrorResultRecognizesCopies(t *testing.T) { + duplicate := DupAtt(&AttributeExpr{Type: ErrorResult}).Type + + require.True(t, IsErrorResult(ErrorResult)) + require.True(t, IsErrorResult(duplicate)) + require.False(t, IsErrorResult(&UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "error", + })) + require.False(t, IsErrorResult(String)) +} + func TestDupKeepsRootTypeAndSameNameUnionAliasDistinct(t *testing.T) { rootType := &UserTypeExpr{ AttributeExpr: &AttributeExpr{Type: String}, @@ -53,3 +83,30 @@ func TestDupKeepsRootTypeAndSameNameUnionAliasDistinct(t *testing.T) { require.Equal(t, String, rootCopy.Attribute().Type) require.Equal(t, Boolean, aliasCopy.Attribute().Type) } + +// TestDupAttributeKeepsAuthoredAttribute verifies that every transport copy +// can find the exact attribute written in the design, including after more +// than one copy. +func TestDupAttributeKeepsAuthoredAttribute(t *testing.T) { + originalChild := &AttributeExpr{Type: String} + original := &AttributeExpr{Type: &Object{ + {Name: "child", Attribute: originalChild}, + }} + + first := DupAtt(original) + second := DupAtt(first) + + require.Same(t, original, first.AuthoredAttribute()) + require.Same(t, original, second.AuthoredAttribute()) + require.Same(t, originalChild, first.Type.(*Object).Attribute("child").AuthoredAttribute()) + require.Same(t, originalChild, second.Type.(*Object).Attribute("child").AuthoredAttribute()) +} + +func TestDupSchemeKeepsAuthoredScheme(t *testing.T) { + authored := &SchemeExpr{SchemeName: "key"} + first := DupScheme(authored) + second := DupScheme(first) + + require.Same(t, authored, first.AuthoredScheme()) + require.Same(t, authored, second.AuthoredScheme()) +} diff --git a/expr/error_contract.go b/expr/error_contract.go index a6852fe7ee..ee6218c66a 100644 --- a/expr/error_contract.go +++ b/expr/error_contract.go @@ -1,7 +1,6 @@ -// This file defines the transport-independent error contract used when an -// endpoint inherits HTTP or gRPC response policy from its service or API. -// Transport policy may select status codes and wire fields, but the method's -// effective error remains the concrete service value encoded on every path. +// This file builds the error definition that a method inherits from its +// service or API. HTTP and gRPC settings may change how the error is sent, but +// they do not change the service error value. package expr import ( @@ -17,10 +16,8 @@ type ( second *AttributeExpr } - // effectiveErrorCopier owns a detached graph while inherited error - // attributes are materialized for comparison. Both maps are keyed by the - // source node so recursive declarations and inheritance edges reconnect to - // their copied counterparts. + // effectiveErrorCopier copies an inherited error without changing the + // evaluated design. The maps reconnect recursive types to their copies. effectiveErrorCopier struct { attributes map[*AttributeExpr]*AttributeExpr userTypes map[UserType]UserType @@ -43,6 +40,30 @@ func equivalentErrorAttributes(first, second *AttributeExpr) bool { return equivalentErrorAttributeNodes(first, second, make(map[attributePair]struct{})) } +// differingErrorQualifierSettings lists error settings that would change the +// generated service error returned to callers. +func differingErrorQualifierSettings(first, second *AttributeExpr) []string { + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) + qualifiers := []struct { + name string + key string + }{ + {name: "temporary", key: "goa:error:temporary"}, + {name: "timeout", key: "goa:error:timeout"}, + {name: "fault", key: "goa:error:fault"}, + } + var different []string + for _, qualifier := range qualifiers { + _, firstSet := first.Meta[qualifier.key] + _, secondSet := second.Meta[qualifier.key] + if firstSet != secondSet { + different = append(different, qualifier.name) + } + } + return different +} + // effectiveErrorAttribute returns a detached copy with References and Bases // applied by AttributeExpr.Finalize. Validation can therefore compare the // value contracts code generation will see without mutating evaluated design. diff --git a/expr/example.go b/expr/example.go index 3ef10bf35a..e10675ba5d 100644 --- a/expr/example.go +++ b/expr/example.go @@ -1,5 +1,6 @@ -// This file generates JSON-compatible examples from evaluated attributes using -// streams anchored to exact semantic design owners. +// This file generates JSON-compatible examples from evaluated attributes. Each +// service, method, type, and field uses its own repeatable sequence so changes +// elsewhere do not change its example values. package expr import ( diff --git a/expr/example_identity.go b/expr/example_identity.go index 097b68d30b..82ca6eb27d 100644 --- a/expr/example_identity.go +++ b/expr/example_identity.go @@ -1,5 +1,5 @@ -// This file defines stable, typed identities for the example values emitted -// from evaluated design expressions. +// This file builds repeatable keys for example values from evaluated service, +// method, type, field, and transport names. package expr import ( @@ -8,8 +8,9 @@ import ( ) type ( - // ExampleIdentity identifies one semantic example stream. Its representation - // is opaque so callers cannot manufacture identities by joining names. + // ExampleIdentity selects a repeatable sequence of generated example values. + // Equal values select the same sequence. Its fields are private so callers + // must use the constructors below. ExampleIdentity struct { seed string } @@ -44,7 +45,7 @@ const ( unionMemberExampleKind ) -// UserTypeExampleIdentity returns the example identity owned by typ. +// UserTypeExampleIdentity returns the example key for typ. func UserTypeExampleIdentity(typ UserType) ExampleIdentity { if identity, ok := GeneratedUserTypeExampleIdentity(typ); ok { return identity @@ -52,8 +53,9 @@ func UserTypeExampleIdentity(typ UserType) ExampleIdentity { return newExampleIdentity(userTypeExampleKind, []byte(typ.ID())) } -// GeneratedUserTypeExampleIdentity returns the exact semantic owner retained -// by a synthesized user type. The second result is false for authored types. +// GeneratedUserTypeExampleIdentity returns the example key stored on a user +// type created by Goa. The second result is false for types written in the +// design. func GeneratedUserTypeExampleIdentity(typ UserType) (ExampleIdentity, bool) { var identity ExampleIdentity switch generated := typ.(type) { @@ -65,32 +67,29 @@ func GeneratedUserTypeExampleIdentity(typ UserType) (ExampleIdentity, bool) { return identity, identity.seed != "" } -// MethodPayloadExampleIdentity returns the payload example identity owned by -// method. +// MethodPayloadExampleIdentity returns the example key for method's payload. func MethodPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(methodPayloadExampleKind, method) } -// MethodResultExampleIdentity returns the result example identity owned by -// method. +// MethodResultExampleIdentity returns the example key for method's result. func MethodResultExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(methodResultExampleKind, method) } -// MethodStreamingPayloadExampleIdentity returns the streaming payload example -// identity owned by method. +// MethodStreamingPayloadExampleIdentity returns the example key for method's +// streaming payload. func MethodStreamingPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(methodStreamingPayloadExampleKind, method) } -// MethodStreamingResultExampleIdentity returns the streaming result example -// identity owned by method. +// MethodStreamingResultExampleIdentity returns the example key for method's +// streaming result. func MethodStreamingResultExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(methodStreamingResultExampleKind, method) } -// MethodErrorExampleIdentity returns the example identity owned by err in -// method. +// MethodErrorExampleIdentity returns the example key for err in method. func MethodErrorExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { return newExampleIdentity( methodErrorExampleKind, @@ -100,8 +99,8 @@ func MethodErrorExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdent ) } -// RequestBodyExampleIdentity returns the request body example identity owned -// by endpoint. HTTP and JSON-RPC mappings receive distinct identities. +// RequestBodyExampleIdentity returns the example key for endpoint's request +// body. HTTP and JSON-RPC endpoints receive different keys. func RequestBodyExampleIdentity(endpoint *HTTPEndpointExpr) ExampleIdentity { kind := httpRequestBodyExampleKind if endpoint.IsJSONRPC() { @@ -114,10 +113,9 @@ func RequestBodyExampleIdentity(endpoint *HTTPEndpointExpr) ExampleIdentity { ) } -// ResponseBodyExampleIdentity returns the successful response body example -// identity owned by response in endpoint. HTTP and JSON-RPC mappings receive -// distinct identities. Endpoint validation makes each successful status code -// unique. +// ResponseBodyExampleIdentity returns the example key for a successful response +// body. HTTP and JSON-RPC endpoints receive different keys, and each successful +// status code receives its own key. func ResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPResponseExpr) ExampleIdentity { kind := httpResponseBodyExampleKind if endpoint.IsJSONRPC() { @@ -131,10 +129,9 @@ func ResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPRespo ) } -// ErrorResponseBodyExampleIdentity returns the error response body example -// identity owned by response in endpoint. HTTP and JSON-RPC mappings receive -// distinct identities. Error names distinguish errors that intentionally -// share an HTTP status. +// ErrorResponseBodyExampleIdentity returns the example key for an error response +// body. HTTP and JSON-RPC endpoints receive different keys. The error name keeps +// two errors with the same HTTP status separate. func ErrorResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPErrorExpr) ExampleIdentity { kind := httpErrorResponseBodyExampleKind if endpoint.IsJSONRPC() { @@ -149,32 +146,32 @@ func ErrorResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTP ) } -// GRPCRequestMessageExampleIdentity returns the gRPC request message example -// identity owned by method. +// GRPCRequestMessageExampleIdentity returns the example key for method's gRPC +// request message. func GRPCRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(grpcRequestMessageExampleKind, method) } -// GRPCResponseMessageExampleIdentity returns the gRPC response message example -// identity owned by method. +// GRPCResponseMessageExampleIdentity returns the example key for method's gRPC +// response message. func GRPCResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(grpcResponseMessageExampleKind, method) } -// GRPCStreamingRequestMessageExampleIdentity returns the gRPC streaming -// request message example identity owned by method. +// GRPCStreamingRequestMessageExampleIdentity returns the example key for +// method's streaming gRPC request message. func GRPCStreamingRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(grpcStreamingRequestMessageExampleKind, method) } -// GRPCStreamingResponseMessageExampleIdentity returns the gRPC streaming -// response message example identity owned by method. +// GRPCStreamingResponseMessageExampleIdentity returns the example key for +// method's streaming gRPC response message. func GRPCStreamingResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { return methodExampleIdentity(grpcStreamingResponseMessageExampleKind, method) } -// GRPCErrorMessageExampleIdentity returns the gRPC error message example -// identity owned by err in method. +// GRPCErrorMessageExampleIdentity returns the example key for err's gRPC +// message in method. func GRPCErrorMessageExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { return newExampleIdentity( grpcErrorMessageExampleKind, @@ -184,8 +181,8 @@ func GRPCErrorMessageExampleIdentity(method *MethodExpr, err *ErrorExpr) Example ) } -// GRPCArrayWrapperExampleIdentity returns the stable gRPC wrapper identity for -// an authored array alias shared across message fields. +// GRPCArrayWrapperExampleIdentity returns the example key for the gRPC message +// that wraps an array type written in the design. func GRPCArrayWrapperExampleIdentity(typ UserType) ExampleIdentity { if !IsArray(typ) { panic("gRPC array wrapper identity requires an array user type") @@ -193,8 +190,8 @@ func GRPCArrayWrapperExampleIdentity(typ UserType) ExampleIdentity { return newExampleIdentity(grpcArrayWrapperExampleKind, []byte(typ.Origin().ID())) } -// GRPCMapWrapperExampleIdentity returns the stable gRPC wrapper identity for -// an authored map alias shared across message fields. +// GRPCMapWrapperExampleIdentity returns the example key for the gRPC message +// that wraps a map type written in the design. func GRPCMapWrapperExampleIdentity(typ UserType) ExampleIdentity { if !IsMap(typ) { panic("gRPC map wrapper identity requires a map user type") @@ -202,56 +199,54 @@ func GRPCMapWrapperExampleIdentity(typ UserType) ExampleIdentity { return newExampleIdentity(grpcMapWrapperExampleKind, []byte(typ.Origin().ID())) } -// Seed returns the complete stable seed material custom randomizer factories -// use to create the stream for this identity. +// Seed returns the complete encoded key passed to custom randomizer factories. func (i ExampleIdentity) Seed() string { return base64.RawURLEncoding.EncodeToString([]byte(i.seed)) } -// Member returns the identity of the named object member below i. +// Member returns the example key for name within i. func (i ExampleIdentity) Member(name string) ExampleIdentity { return i.append(memberExampleKind, []byte(name)) } -// ArrayElement returns the identity of the indexed array element below i. +// ArrayElement returns the example key for index within the array at i. func (i ExampleIdentity) ArrayElement(index int) ExampleIdentity { return i.append(arrayElementExampleKind, exampleIdentityInt(index)) } -// MapKey returns the identity of the indexed map key below i. +// MapKey returns the example key for key index within the map at i. func (i ExampleIdentity) MapKey(index int) ExampleIdentity { return i.append(mapKeyExampleKind, exampleIdentityInt(index)) } -// MapValue returns the identity of the indexed map value below i. +// MapValue returns the example key for value index within the map at i. func (i ExampleIdentity) MapValue(index int) ExampleIdentity { return i.append(mapValueExampleKind, exampleIdentityInt(index)) } -// UnionMember returns the identity of the named union member below i. +// UnionMember returns the example key for name within the union at i. func (i ExampleIdentity) UnionMember(name string) ExampleIdentity { return i.append(unionMemberExampleKind, []byte(name)) } -// newExampleIdentity serializes one typed segment with independently framed -// components so punctuation and component boundaries cannot collide. +// A new example key encodes a value kind and each component's byte length so +// different component lists cannot produce the same key. func newExampleIdentity(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { return ExampleIdentity{seed: string(appendExampleIdentitySegment(nil, kind, components...))} } -// methodExampleIdentity derives a method-owned identity from the evaluated -// service and method names rather than accepting caller-supplied components. +// Method example keys are built from the evaluated service and method names. func methodExampleIdentity(kind exampleIdentityKind, method *MethodExpr) ExampleIdentity { return newExampleIdentity(kind, []byte(method.Service.Name), []byte(method.Name)) } -// exampleIdentityInt returns a stable fixed-width encoding of value. +// Integers in example keys are written as eight bytes in big-endian order. func exampleIdentityInt(value int) []byte { return binary.BigEndian.AppendUint64(nil, uint64(value)) } -// appendExampleIdentitySegment writes the segment kind, component count, and -// byte length of each component before its data. +// Each added part writes its kind, number of components, and every component's +// byte length before the component bytes. func appendExampleIdentitySegment(seed []byte, kind exampleIdentityKind, components ...[]byte) []byte { seed = append(seed, byte(kind)) seed = binary.BigEndian.AppendUint64(seed, uint64(len(components))) @@ -262,8 +257,7 @@ func appendExampleIdentitySegment(seed []byte, kind exampleIdentityKind, compone return seed } -// append adds one structural segment without exposing the serialized form to -// callers. +// append adds one member, array, map, or union part to the current key. func (i ExampleIdentity) append(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { if i.seed == "" { panic("example identity must have a semantic owner before structural descent") diff --git a/expr/grpc_endpoint.go b/expr/grpc_endpoint.go index c6bf7bd410..2e621361b4 100644 --- a/expr/grpc_endpoint.go +++ b/expr/grpc_endpoint.go @@ -120,7 +120,7 @@ func (e *GRPCEndpointExpr) Prepare() { continue } // Lookup undefined GRPC errors in API. - for _, v := range Root.API.GRPC.Errors { + for _, v := range e.MethodExpr.Service.design.API.GRPC.Errors { if me.Name == v.Name { e.GRPCErrors = append(e.GRPCErrors, v.Dup()) } @@ -140,7 +140,7 @@ func (e *GRPCEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.GRPC.Errors { + for _, ae := range e.MethodExpr.Service.design.API.GRPC.Errors { if se.Name == ae.Name { e.GRPCErrors = append(e.GRPCErrors, ae.Dup()) break @@ -173,6 +173,9 @@ func (e *GRPCEndpointExpr) Validate() error { if e.Name() == "" { verr.Add(e, "Endpoint name cannot be empty") } + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "gRPC method %q cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream", e.MethodExpr.Name) + } verr.Merge(e.validateStreamCompat()) seenUnions := make(map[*Union]struct{}) @@ -249,7 +252,7 @@ func (e *GRPCEndpointExpr) Validate() error { // their fields must define field numbers, mirroring the payload and // result checks above. Default ErrorResult errors travel in the gRPC // status and need no tags. - if ee := e.MethodExpr.Error(er.Name); ee != nil && ee.Type != ErrorResult && IsObject(ee.Type) { + if ee := e.MethodExpr.Error(er.Name); ee != nil && !IsErrorResult(ee.Type) && IsObject(ee.Type) { verr.Merge(validateRPCTags(AsObject(ee.Type), e)) } } @@ -262,20 +265,21 @@ func (e *GRPCEndpointExpr) Validate() error { func (e *GRPCEndpointExpr) validateErrorMappings() *eval.ValidationErrors { verr := new(eval.ValidationErrors) for _, mapping := range e.GRPCErrors { - mapped, owner := mapping.mappedError() + mapped, owner := mapping.mappedError(e.MethodExpr.Service.design) method := e.MethodExpr.Error(mapping.Name) if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { continue } verr.Add( mapping.Response, - `gRPC error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute (type, validations, defaults, and struct metadata)`, + `gRPC error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, mapping.Name, owner, mapped.Type.Name(), e.MethodExpr.Name, e.MethodExpr.Service.Name, method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), ) } return verr @@ -631,7 +635,7 @@ func (e *GRPCEndpointExpr) streamCompatValue() (string, bool) { if v, ok := e.Service.ServiceExpr.Meta.Last(streamCompatMetaKey); ok { return v, true } - if v, ok := Root.API.Meta.Last(streamCompatMetaKey); ok { + if v, ok := e.MethodExpr.Service.design.API.Meta.Last(streamCompatMetaKey); ok { return v, true } return "", false diff --git a/expr/grpc_endpoint_test.go b/expr/grpc_endpoint_test.go index 357f691e5b..cee9bfd6d3 100644 --- a/expr/grpc_endpoint_test.go +++ b/expr/grpc_endpoint_test.go @@ -22,6 +22,22 @@ func TestGRPCEndpointValidation(t *testing.T) { DSL: testdata.GRPCEndpointWithAnyType, Errors: []string{}, // Any type is now supported in gRPC }, + "endpoint-with-mixed-results": { + DSL: testdata.GRPCEndpointWithMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-matching-mixed-results": { + DSL: testdata.GRPCEndpointWithMatchingMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-streaming-result": { + DSL: testdata.GRPCEndpointWithStreamingResult, + Errors: []string{}, + }, "endpoint-with-untagged-fields": { DSL: testdata.GRPCEndpointWithUntaggedFields, Errors: []string{`service "Service" gRPC endpoint "Method": attribute "req_not_field" does not have "rpc:tag" defined in the meta, use "Field" to define the attribute of a type used in a gRPC method diff --git a/expr/grpc_error.go b/expr/grpc_error.go index 4a2989f3f3..becda5e3d0 100644 --- a/expr/grpc_error.go +++ b/expr/grpc_error.go @@ -38,7 +38,7 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: - if Root.Error(e.Name) == nil { + if p.Error(e.Name) == nil { verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) } } @@ -51,16 +51,18 @@ func (e *GRPCErrorExpr) Finalize(a *GRPCEndpointExpr) { e.Response.Finalize(a, e.AttributeExpr) } -// mappedError returns the error declaration that owns this reusable gRPC -// response policy before the policy is applied to an endpoint method. -func (e *GRPCErrorExpr) mappedError() (*ErrorExpr, string) { +// mappedError returns the error declaration described by this reusable gRPC +// response before it is copied to an endpoint. +func (e *GRPCErrorExpr) mappedError(root *RootExpr) (*ErrorExpr, string) { switch parent := e.Response.Parent.(type) { case *GRPCEndpointExpr: return parent.MethodExpr.Error(e.Name), "method" case *GRPCServiceExpr: return parent.Error(e.Name), "service" - case *GRPCExpr, *RootExpr: - return Root.Error(e.Name), "API" + case *GRPCExpr: + return root.Error(e.Name), "API" + case *RootExpr: + return parent.Error(e.Name), "API" } return nil, "" } diff --git a/expr/grpc_service.go b/expr/grpc_service.go index 2fffe92eba..23d01f91e6 100644 --- a/expr/grpc_service.go +++ b/expr/grpc_service.go @@ -62,12 +62,7 @@ func (svc *GRPCServiceExpr) EndpointFor(name string, m *MethodExpr) *GRPCEndpoin // Error returns the error with the given name. func (svc *GRPCServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } - } - return Root.Error(name) + return svc.ServiceExpr.Error(name) } // GRPCError returns the service gRPC error with given name if any. @@ -102,7 +97,7 @@ func (svc *GRPCServiceExpr) Validate() error { for _, er := range svc.GRPCErrors { verr.Merge(er.Validate()) } - for _, er := range Root.API.GRPC.Errors { + for _, er := range svc.ServiceExpr.design.API.GRPC.Errors { // This may result in the same error being validated multiple // times however service is the top level expression being // walked and errors cannot be walked until all expressions have diff --git a/expr/http_authored_attribute_test.go b/expr/http_authored_attribute_test.go new file mode 100644 index 0000000000..7782b4a5f4 --- /dev/null +++ b/expr/http_authored_attribute_test.go @@ -0,0 +1,71 @@ +// This file verifies that copied HTTP request and response fields still point +// to the service fields that supplied their descriptions and examples. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPBodyAttributesKeepAuthoredAttribute(t *testing.T) { + payloadChild := &AttributeExpr{Type: String} + payload := &AttributeExpr{Type: &Object{{Name: "message", Attribute: payloadChild}}} + resultChild := &AttributeExpr{Type: String} + result := &AttributeExpr{Type: &Object{{Name: "message", Attribute: resultChild}}} + serviceMethod := &ServiceExpr{Name: "messages"} + serviceMethod.design = &RootExpr{API: NewAPIExpr("messages", nil)} + method := &MethodExpr{ + Name: "show", + Service: serviceMethod, + Payload: payload, + Result: result, + } + service := &HTTPServiceExpr{ServiceExpr: serviceMethod} + endpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: service, + Params: NewEmptyMappedAttributeExpr(), + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + response := &HTTPResponseExpr{ + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + + requestBody := httpRequestBody(endpoint) + responseBody := buildHTTPResponseBody("show", result, response, MethodResultExampleIdentity(method)) + + require.Same(t, payload, requestBody.AuthoredAttribute()) + require.Same(t, result, responseBody.AuthoredAttribute()) + require.Same(t, payloadChild, AsObject(requestBody.Type).Attribute("message").AuthoredAttribute()) + require.Same(t, resultChild, AsObject(responseBody.Type).Attribute("message").AuthoredAttribute()) +} + +func TestHTTPStreamingBodyKeepsAuthoredAttribute(t *testing.T) { + streaming := &AttributeExpr{Type: &Object{{Name: "message", Attribute: &AttributeExpr{Type: String}}}} + method := &MethodExpr{ + Name: "watch", + Service: &ServiceExpr{Name: "events"}, + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + StreamingPayload: streaming, + Stream: ClientStreamKind, + } + endpoint := &HTTPEndpointExpr{MethodExpr: method} + + body := httpStreamingBody(endpoint) + + require.Same(t, streaming, body.AuthoredAttribute()) +} + +func TestHTTPPlaceholderKeepsAuthoredAttribute(t *testing.T) { + authored := &AttributeExpr{Type: String, Description: "description"} + placeholder := &AttributeExpr{Type: String} + + initAttrFromDesign(placeholder, authored) + + require.Same(t, authored, placeholder.AuthoredAttribute()) + require.Equal(t, "description", placeholder.Description) +} diff --git a/expr/http_body_types.go b/expr/http_body_types.go index 1bf50486a1..7cf50898e3 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -26,8 +26,8 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { requirements = e.MethodExpr.Requirements case len(e.Service.ServiceExpr.Requirements) > 0: requirements = e.Service.ServiceExpr.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(e.MethodExpr.Service.design.API.Requirements) > 0: + requirements = e.MethodExpr.Service.design.API.Requirements } if len(requirements) == 0 { return nil @@ -142,6 +142,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { Type: ut, Validation: att.Validation, UserExamples: att.UserExamples, + authored: payload.AuthoredAttribute(), } } @@ -175,6 +176,7 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { Type: ut, Validation: dupped.Validation, UserExamples: att.UserExamples, + authored: att.AuthoredAttribute(), } } @@ -257,12 +259,14 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE // 5. Build computed user type bodyAtt := body.Attribute() - if bodyAtt.Description == "" { - if t, ok := attr.Type.(UserType); ok { - bodyAtt.Description = t.Attribute().Description - } - } - if bodyAtt.Description == "" { + if t, ok := attr.Type.(UserType); ok { + // The generated body type describes the named Goa type after fields used + // by headers and cookies have been removed. Keep the type description + // separate from text that explains one method response. + typeAtt := t.Attribute() + bodyAtt.Description = typeAtt.Description + bodyAtt.authored = typeAtt.AuthoredAttribute() + } else if bodyAtt.Description == "" { bodyAtt.Description = attr.Description } userType := NewGeneratedUserType(name, bodyAtt, identity) @@ -280,6 +284,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), } } views := make([]*ViewExpr, len(rt.Views)) @@ -307,11 +312,12 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), } } // generatedUserType preserves result-type behavior while giving a computed -// transport type a fresh declaration origin and exact example owner. +// transport type its own original declaration and repeatable example sequence. func generatedUserType(typ UserType, identity ExampleIdentity) UserType { generated := NewGeneratedUserType(typ.Name(), typ.Attribute(), identity) if result, ok := typ.(*ResultTypeExpr); ok { diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index 0856abf4f1..7795deb164 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -138,10 +138,10 @@ func (e *HTTPEndpointExpr) UsesSSE() bool { return e.SSE != nil && (e.MethodExpr.IsResultStreaming() || e.MethodExpr.HasMixedResults()) } -// UsesWebSocket returns true if the endpoint streams payloads or results over a -// WebSocket connection. +// UsesWebSocket returns true if an ordinary HTTP endpoint streams payloads or +// results over a WebSocket connection. func (e *HTTPEndpointExpr) UsesWebSocket() bool { - return e.MethodExpr.IsStreaming() && e.SSE == nil + return !e.IsJSONRPC() && e.MethodExpr.IsStreaming() && e.SSE == nil } // HasAbsoluteRoutes returns true if all the endpoint routes are absolute. @@ -230,15 +230,15 @@ func (e *HTTPEndpointExpr) Prepare() { // Inherit headers, cookies and params from parent service and API headers := NewEmptyMappedAttributeExpr() - headers.Merge(Root.API.HTTP.Headers) + headers.Merge(e.MethodExpr.Service.design.API.HTTP.Headers) headers.Merge(e.Service.Headers) cookies := NewEmptyMappedAttributeExpr() - cookies.Merge(Root.API.HTTP.Cookies) + cookies.Merge(e.MethodExpr.Service.design.API.HTTP.Cookies) cookies.Merge(e.Service.Cookies) params := NewEmptyMappedAttributeExpr() - params.Merge(Root.API.HTTP.Params) + params.Merge(e.MethodExpr.Service.design.API.HTTP.Params) params.Merge(e.Service.Params) if p := e.Service.Parent(); p != nil { @@ -310,8 +310,8 @@ func (e *HTTPEndpointExpr) Prepare() { if e.MethodExpr.Stream == ServerStreamKind && e.SSE == nil { if e.Service.SSE != nil { e.SSE = e.Service.SSE - } else if Root.API.HTTP.SSE != nil { - e.SSE = Root.API.HTTP.SSE + } else if e.MethodExpr.Service.design.API.HTTP.SSE != nil { + e.SSE = e.MethodExpr.Service.design.API.HTTP.SSE } } @@ -357,7 +357,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.HTTP.Errors { + for _, ae := range e.MethodExpr.Service.design.API.HTTP.Errors { if se.Name == ae.Name { e.HTTPErrors = append(e.HTTPErrors, ae.Dup()) break @@ -366,8 +366,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } - // Make sure JSON-RPC HTTP verb is set to GET if the endpoint is a - // WebSocket endpoint + // WebSocket endpoints use GET for the HTTP upgrade request. if e.UsesWebSocket() && len(e.Routes) > 0 { e.Routes[0].Method = "GET" } @@ -391,7 +390,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipRequestBodyEncodeDecode is not compatible with gRPC or WebSocket if e.SkipRequestBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint cannot use SkipRequestBodyEncodeDecode and define a gRPC transport.") } @@ -406,7 +405,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipResponseBodyEncodeDecode is not compatible with gRPC or WebSocket. if e.SkipResponseBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint response cannot use SkipResponseBodyEncodeDecode and define a gRPC transport.") } @@ -424,6 +423,23 @@ func (e *HTTPEndpointExpr) Validate() error { } } + // A WebSocket client learns the result view from the connection handshake. + // Receiving a streamed payload starts that connection before the service can + // choose a result view, so the design must select the view in advance. + if e.UsesWebSocket() && e.MethodExpr.IsPayloadStreaming() && !e.MethodExpr.HasMixedResults() { + if result, ok := e.MethodExpr.Result.Type.(*ResultTypeExpr); ok { + viewCount := len(result.Views) + if result.View(DefaultView) == nil { + viewCount++ + } + _, selectedByMethod := e.MethodExpr.Result.Meta.Last(ViewMetaKey) + _, selectedByType := result.Meta.Last(ViewMetaKey) + if viewCount > 1 && !selectedByMethod && !selectedByType { + verr.Add(e, "Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.") + } + } + } + // Validate streaming endpoints for SSE compatibility if e.MethodExpr.Stream == ServerStreamKind { if e.SSE != nil { @@ -438,9 +454,9 @@ func (e *HTTPEndpointExpr) Validate() error { // Validate mixed results configuration if e.MethodExpr.HasMixedResults() { - // Mixed results (different Result and StreamingResult types) requires SSE + // A separate streaming result requires SSE. if e.SSE == nil { - verr.Add(e, "Methods with both Result and StreamingResult defined with different types must use ServerSentEvents()") + verr.Add(e, "Methods with both Result and StreamingResult must use ServerSentEvents()") } // Cannot have bidirectional streaming with mixed results if e.MethodExpr.IsPayloadStreaming() { @@ -464,10 +480,17 @@ func (e *HTTPEndpointExpr) Validate() error { // JSON-RPC validation if e.IsJSONRPC() { - // JSON-RPC WebSocket endpoints with server streaming cannot have both Payload and StreamingPayload - if e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type != Empty { - verr.Add(e, "JSON-RPC WebSocket server streaming method %q cannot define both Payload and StreamingPayload. Use Payload for the request data", e.MethodExpr.Name) + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "JSON-RPC method %q cannot define both Result and StreamingResult because its client stream cannot return a separate final result", e.MethodExpr.Name) + } + switch e.MethodExpr.Stream { + case ClientStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use client streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case BidirectionalStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use bidirectional streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case ServerStreamKind: + if e.SSE == nil { + verr.Add(e, "JSON-RPC method %q with a streaming result must use ServerSentEvents()", e.MethodExpr.Name) } } @@ -742,18 +765,16 @@ func (e *HTTPEndpointExpr) Validate() error { } body := httpRequestBody(e) + if e.MultipartRequest && body.Type == Empty { + verr.Add(e, "MultipartRequest requires a request body.") + } if e.SkipRequestBodyEncodeDecode && body.Type != Empty { verr.Add(e, "HTTP endpoint request body must be empty when using SkipRequestBodyEncodeDecode but not all method payload attributes are mapped to headers and params. Make sure to define Headers and Params as needed.") } - // For streaming endpoints, check if request body is allowed + // WebSocket upgrade requests cannot carry a request body. if e.MethodExpr.IsStreaming() && body.Type != Empty { - // SSE endpoints can have request bodies, but WebSocket endpoints cannot - // Refer WebSocket protocol - https://tools.ietf.org/html/rfc6455 - // Exception: JSON-RPC WebSocket endpoints can have payloads as they are sent - // as JSON-RPC messages after the WebSocket connection is established - _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"] - if e.UsesWebSocket() && !isJSONRPC { + if e.UsesWebSocket() { verr.Add(e, "HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params.") } } @@ -773,18 +794,28 @@ func (e *HTTPEndpointExpr) validateErrorMappings() *eval.ValidationErrors { } verr.Add( mapping.Response, - `HTTP error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute (type, validations, defaults, and struct metadata)`, + `HTTP error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, mapping.Name, owner, mapped.Type.Name(), e.MethodExpr.Name, e.MethodExpr.Service.Name, method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), ) } return verr } +// errorAttributeDifference names qualifier settings when they are the reason +// two reusable error definitions disagree. +func errorAttributeDifference(first, second *AttributeExpr) string { + if settings := differingErrorQualifierSettings(first, second); len(settings) > 0 { + return "the " + strings.Join(settings, ", ") + " setting differs" + } + return "their type, validations, defaults, or metadata differ" +} + // Finalize is run post DSL execution. It merges response definitions, creates // implicit endpoint parameters and initializes querystring parameters. It also // flattens the error responses and makes sure the error types are all user @@ -849,17 +880,6 @@ func (e *HTTPEndpointExpr) Finalize() { e.StreamingBody.Finalize() } - // For JSON-RPC, WebSocket handling is managed at the server level. - // Each endpoint is treated as a standard HTTP endpoint; the server is responsible - // for upgrading the connection, decoding incoming JSON-RPC requests, and dispatching - // them to the appropriate endpoint handlers. - if e.IsJSONRPC() { - if e.MethodExpr.IsPayloadStreaming() { - e.MethodExpr.Payload = e.MethodExpr.StreamingPayload - e.Body = e.StreamingBody - } - } - // Initialize responses parent, headers and body for _, r := range e.Responses { r.Finalize(e, e.MethodExpr.Result) @@ -1171,6 +1191,7 @@ func initAttrFromDesign(att, patt *AttributeExpr) { if patt == nil || patt.Type == Empty { return } + att.authored = patt.AuthoredAttribute() att.Type = patt.Type if att.Description == "" { att.Description = patt.Description diff --git a/expr/http_endpoint_test.go b/expr/http_endpoint_test.go index b3931f8aa7..bc2c860b4e 100644 --- a/expr/http_endpoint_test.go +++ b/expr/http_endpoint_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" @@ -176,6 +177,10 @@ service "Service" HTTP endpoint "Method": HTTP endpoint response body must be em DSL: testdata.EndpointPayloadMissingRequired, Error: `service "Service" HTTP endpoint "Method": The following HTTP request body attribute is required but the corresponding method payload attribute is not: nonreq. Use 'Required' to make the attribute required in the method payload as well.`, }, + "endpoint-multipart-without-body": { + DSL: testdata.EndpointMultipartWithoutBody, + Error: `service "Service" HTTP endpoint "Method": MultipartRequest requires a request body.`, + }, "streaming-endpoint-has-request-body": { DSL: testdata.StreamingEndpointRequestBody, Error: `service "Service" HTTP endpoint "MethodA": HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params. @@ -210,6 +215,143 @@ service "Service" HTTP endpoint "MethodC": HTTP endpoint request body must be em } } +func TestHTTPWebSocketViewedResultValidation(t *testing.T) { + tests := []struct { + name string + collection bool + collectionView string + method func(*expr.ResultTypeExpr) + err string + }{ + { + name: "client stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "bidirectional stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with caller-selected collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "bidirectional stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with fixed collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with view fixed by collection type", + collection: true, + collectionView: "tiny", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + }, + { + name: "server stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingResult(result) + }, + }, + { + name: "client stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + }, + }, + { + name: "bidirectional stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + design := httpWebSocketViewedResultDSL(test.collection, test.collectionView, test.method) + if test.err == "" { + expr.RunDSL(t, design) + return + } + err := expr.RunInvalidDSL(t, design) + if got := stripValidationLocations(err.Error()); got != test.err { + t.Errorf("got %q, expected %q", got, test.err) + } + }) + } +} + +// httpWebSocketViewedResultDSL defines a result with two response shapes and +// lets each test choose how the method streams it. +func httpWebSocketViewedResultDSL(collection bool, collectionView string, method func(*expr.ResultTypeExpr)) func() { + return func() { + result := dsl.ResultType("application/vnd.websocket-view", func() { + dsl.Attribute("name", dsl.String) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + if collection { + if collectionView == "" { + result = dsl.CollectionOf(result) + } else { + result = dsl.CollectionOf(result, func() { + dsl.View(collectionView) + }) + } + } + dsl.Service("Service", func() { + dsl.Method("Method", func() { + method(result) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} + func TestHTTPEndpointParentRequired(t *testing.T) { root := expr.RunDSL(t, testdata.EndpointHasParent) svc := root.Service("Child") diff --git a/expr/http_error.go b/expr/http_error.go index d7e6ed4121..94113ed6ae 100644 --- a/expr/http_error.go +++ b/expr/http_error.go @@ -39,7 +39,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: - if Root.Error(e.Name) == nil { + if p.Error(e.Name) == nil { verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) } } @@ -51,7 +51,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { case *HTTPServiceExpr: ee = p.Error(e.Name) case *RootExpr: - ee = Root.Error(e.Name) + ee = p.Error(e.Name) } // validate headers @@ -121,7 +121,7 @@ func (e *HTTPErrorExpr) mappedError() (*ErrorExpr, string) { case *HTTPServiceExpr: return parent.Error(e.Name), "service" case *RootExpr: - return Root.Error(e.Name), "API" + return parent.Error(e.Name), "API" } return nil, "" } diff --git a/expr/http_file_server.go b/expr/http_file_server.go index 9ed969a98c..2d6d0fb3b1 100644 --- a/expr/http_file_server.go +++ b/expr/http_file_server.go @@ -57,7 +57,7 @@ func (f *HTTPFileServerExpr) Finalize() { if isAbs { p = current } else { - p = path.Join(Root.API.HTTP.Path, sp, current) + p = path.Join(f.Service.Root.Path, sp, current) } // Make sure request path starts with a "/" so codegen can rely on it. if !strings.HasPrefix(p, "/") { diff --git a/expr/http_response.go b/expr/http_response.go index a6bb093beb..684f69d6ba 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -348,7 +348,7 @@ func (r *HTTPResponseExpr) Dup() *HTTPResponseExpr { // attributes are mapped to special goa headers in the form of // "Goa-Attribute(-)". func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { - if svcAtt.Type != ErrorResult { + if !IsErrorResult(svcAtt.Type) { return } diff --git a/expr/http_service.go b/expr/http_service.go index 1bd9ba63c7..6ff108ddb5 100644 --- a/expr/http_service.go +++ b/expr/http_service.go @@ -66,12 +66,7 @@ func (svc *HTTPServiceExpr) Description() string { // Error returns the error with the given name. func (svc *HTTPServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } - } - return Root.Error(name) + return svc.ServiceExpr.Error(name) } // Endpoint returns the service endpoint with the given name or nil if there @@ -280,57 +275,13 @@ func (svc *HTTPServiceExpr) validateErrors(verr *eval.ValidationErrors) { } } -// validateTransports validates transport compatibility and JSON-RPC constraints +// validateTransports validates JSON-RPC route constraints. func (svc *HTTPServiceExpr) validateTransports(verr *eval.ValidationErrors) { - var ( - hasPureHTTPWebSocket bool - hasJSONRPCWebSocket bool - ) - - // Analyze endpoints - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - if e.UsesWebSocket() { - hasJSONRPCWebSocket = true - } - } else if e.UsesWebSocket() { - hasPureHTTPWebSocket = true - } - } - - // Validate JSON-RPC and pure HTTP WebSocket mixing - if hasJSONRPCWebSocket && hasPureHTTPWebSocket { - verr.Add(svc, "Service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. JSON-RPC uses a single WebSocket connection for all methods, while pure HTTP WebSocket creates individual connections per endpoint.") - } - - // Validate JSON-RPC WebSocket constraints - if hasJSONRPCWebSocket { - svc.validateJSONRPCWebSocketConstraints(verr) - } - - // Validate JSON-RPC transport consistency if svc.ServiceExpr.Meta != nil && svc.ServiceExpr.Meta["jsonrpc:service"] != nil { - svc.validateJSONRPCTransportConsistency(verr) svc.validateJSONRPCRoutes(verr) } } -// validateJSONRPCWebSocketConstraints validates constraints for JSON-RPC WebSocket endpoints -func (svc *HTTPServiceExpr) validateJSONRPCWebSocketConstraints(verr *eval.ValidationErrors) { - for _, e := range svc.HTTPEndpoints { - name := e.MethodExpr.Name - if !e.Headers.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have header mappings", name) - } - if !e.Cookies.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have cookie mappings", name) - } - if !e.Params.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have parameter mappings", name) - } - } -} - // Finalize initializes the path if no path is set in design. func (svc *HTTPServiceExpr) Finalize() { if len(svc.Paths) == 0 { @@ -367,18 +318,8 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { path = svc.Paths[0] } - method := "POST" // default - - // If using WebSocket, force GET - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && e.UsesWebSocket() { - method = "GET" // WebSocket requires GET - break - } - } - route = &RouteExpr{ - Method: method, + Method: "POST", Path: path, } } @@ -395,46 +336,13 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { } } -// validateJSONRPCTransportConsistency validates JSON-RPC transport combinations. -// WebSocket cannot be mixed with other transports, but HTTP and SSE can coexist. -func (svc *HTTPServiceExpr) validateJSONRPCTransportConsistency(verr *eval.ValidationErrors) { - var hasWebSocket, hasSSE, hasRegular bool - - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - switch { - case e.UsesWebSocket(): - hasWebSocket = true - case e.UsesSSE(): - hasSSE = true - default: - hasRegular = true - } - } - } - - // WebSocket cannot be mixed with any other transport - if hasWebSocket && (hasSSE || hasRegular) { - verr.Add(svc, "JSON-RPC service %q cannot mix WebSocket with other transports (SSE or regular HTTP). WebSocket requires a single persistent connection for all methods.", svc.Name()) - } - // HTTP and SSE can be mixed - they both use POST requests and can share the same endpoint -} - -// validateJSONRPCRoutes validates that JSON-RPC routes use the correct HTTP method. +// validateJSONRPCRoutes checks that every JSON-RPC route uses POST. func (svc *HTTPServiceExpr) validateJSONRPCRoutes(verr *eval.ValidationErrors) { for _, e := range svc.HTTPEndpoints { if e.IsJSONRPC() { for _, r := range e.Routes { - // WebSocket requires GET - if e.UsesWebSocket() { - if r.Method != "GET" { - verr.Add(r, "JSON-RPC WebSocket endpoint must use GET method, got %q", r.Method) - } - } else { - // Regular JSON-RPC and SSE require POST - if r.Method != "POST" { - verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) - } + if r.Method != "POST" { + verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) } } } diff --git a/expr/http_service_test.go b/expr/http_service_test.go deleted file mode 100644 index 1fa5729c2d..0000000000 --- a/expr/http_service_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package expr_test - -import ( - "strings" - "testing" - - . "goa.design/goa/v3/dsl" - "goa.design/goa/v3/expr" -) - -func TestHTTPServiceValidate(t *testing.T) { - cases := []struct { - Name string - DSL func() - Error string - ContainsError string - }{ - {"valid jsonrpc websocket", validJSONRPCWebSocketDSL, "", ""}, - {"jsonrpc websocket with headers", jsonrpcWebSocketWithHeadersDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - {"jsonrpc websocket with cookies", jsonrpcWebSocketWithCookiesDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have cookie mappings`}, - {"jsonrpc websocket with params", jsonrpcWebSocketWithParamsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have parameter mappings`}, - {"jsonrpc websocket with all mappings", jsonrpcWebSocketWithAllMappingsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - if tc.Error == "" && tc.ContainsError == "" { - expr.RunDSL(t, tc.DSL) - } else { - err := expr.RunInvalidDSL(t, tc.DSL) - if tc.Error != "" { - if err.Error() != tc.Error { - t.Errorf("got error %q, expected %q", err.Error(), tc.Error) - } - } else if tc.ContainsError != "" { - if !strings.Contains(err.Error(), tc.ContainsError) { - t.Errorf("error %q does not contain expected substring %q", err.Error(), tc.ContainsError) - } - } - } - }) - } -} - -// Test DSL functions - -var validJSONRPCWebSocketDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() {}) - }) - }) -} - -var jsonrpcWebSocketWithHeadersDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithCookiesDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Cookie("session", String) - }) - }) - }) -} - -var jsonrpcWebSocketWithParamsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Params(func() { - Param("id", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithAllMappingsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - Cookie("session", String) - Params(func() { - Param("id", String) - }) - }) - }) - }) -} diff --git a/expr/interceptor.go b/expr/interceptor.go index 1451c967b9..5a0f483b94 100644 --- a/expr/interceptor.go +++ b/expr/interceptor.go @@ -119,12 +119,12 @@ func (i *InterceptorExpr) validate(m *MethodExpr) *eval.ValidationErrors { if !m.IsResultStreaming() { verr.Add(m, "interceptor %q cannot be applied because the method result is not streaming", i.Name) } else { - if !IsObject(m.Result.Type) { + if !IsObject(m.StreamingResult.Type) { verr.Add(m, "interceptor %q cannot be applied because the method result is not an object", i.Name) } else { - result := DupAtt(m.Result) - if m.Result.Bases != nil { - for _, base := range m.Result.Bases { + result := DupAtt(m.StreamingResult) + if m.StreamingResult.Bases != nil { + for _, base := range m.StreamingResult.Bases { if ut, ok := base.(UserType); ok { result.Merge(ut.Attribute()) } diff --git a/expr/interceptor_test.go b/expr/interceptor_test.go index 08e7b2f6cc..37ea6a83d3 100644 --- a/expr/interceptor_test.go +++ b/expr/interceptor_test.go @@ -70,6 +70,14 @@ func TestInterceptorExpr_Validate(t *testing.T) { m.StreamingPayload = &AttributeExpr{Type: ut} }), }, + "streaming-result-distinct-from-result": { + intercept: makeInterceptor(t, withReadStreamingResult(t, namedAttr(t, "event"))), + method: makeMethod(t, func(m *MethodExpr) { + m.Stream = ServerStreamKind + m.Result = &AttributeExpr{Type: &Object{namedAttr(t, "summary")}} + m.StreamingResult = &AttributeExpr{Type: &Object{namedAttr(t, "event")}} + }), + }, "invalid-payload-not-object": { intercept: makeInterceptor(t, withReadPayload(t, namedAttr(t, "foo"))), method: makeMethod(t, func(m *MethodExpr) { diff --git a/expr/jsonrpc_stream_contract_test.go b/expr/jsonrpc_stream_contract_test.go index b5b51eded4..bf7e83332a 100644 --- a/expr/jsonrpc_stream_contract_test.go +++ b/expr/jsonrpc_stream_contract_test.go @@ -1,5 +1,4 @@ -// This file checks that JSON-RPC keeps the number and direction of values -// declared by each service method. +// This file checks which Goa stream shapes JSON-RPC can represent. package expr_test import ( @@ -11,35 +10,113 @@ import ( "goa.design/goa/v3/expr" ) -// TestJSONRPCWebSocketFinalizePreservesStreamKinds checks that a method which -// sends many results keeps its one initial request. It must not be changed into -// a method which also receives many requests. -func TestJSONRPCWebSocketFinalizePreservesStreamKinds(t *testing.T) { - root := expr.RunDSL(t, func() { - dsl.Service("socket", func() { - dsl.JSONRPC(func() { - dsl.Path("/stream") - }) - dsl.Method("server", func() { +// TestJSONRPCStreamContract checks the request and response stream shapes that +// JSON-RPC can carry over HTTP and server-sent events. +func TestJSONRPCStreamContract(t *testing.T) { + valid := []struct { + name string + method func() + }{ + { + name: "one request and one response", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }, + }, + { + name: "server stream over server sent events", + method: func() { dsl.Payload(dsl.String) dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + }, + } + for _, test := range valid { + t.Run(test.name, func(t *testing.T) { + expr.RunDSL(t, jsonRPCStreamDSL(test.method)) + }) + } + + invalid := []struct { + name string + method func() + wantErr string + }{ + { + name: "client stream", + method: func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) dsl.JSONRPC(func() {}) - }) - dsl.Method("bidi", func() { + }, + wantErr: `JSON-RPC method "stream" cannot use client streaming because one JSON-RPC request contains one params value`, + }, + { + name: "bidirectional stream", + method: func() { dsl.StreamingPayload(dsl.String) dsl.StreamingResult(dsl.String) dsl.JSONRPC(func() {}) - }) + }, + wantErr: `JSON-RPC method "stream" cannot use bidirectional streaming because one JSON-RPC request contains one params value`, + }, + { + name: "server stream without server sent events", + method: func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" with a streaming result must use ServerSentEvents()`, + }, + { + name: "synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.Int) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + { + name: "matching synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCStreamDSL(test.method)) + require.Contains(t, err.Error(), test.wantErr) }) - }) - - service := root.Service("socket") - server := service.Method("server") - require.Equal(t, expr.ServerStreamKind, server.Stream) - require.Equal(t, expr.String, server.Payload.Type) - require.Equal(t, expr.Empty, server.StreamingPayload.Type) + } +} - bidi := service.Method("bidi") - require.Equal(t, expr.BidirectionalStreamKind, bidi.Stream) - require.Equal(t, expr.String, bidi.StreamingPayload.Type) +// jsonRPCStreamDSL exposes one method through the shared JSON-RPC HTTP route. +func jsonRPCStreamDSL(method func()) func() { + return func() { + dsl.Service("streamer", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("stream", func() { + method() + }) + }) + } } diff --git a/expr/jsonrpc_validation_test.go b/expr/jsonrpc_validation_test.go deleted file mode 100644 index 77201bf1eb..0000000000 --- a/expr/jsonrpc_validation_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package expr_test - -import ( - "errors" - "testing" - - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" -) - -func TestJSONRPCTransportConsistency(t *testing.T) { - cases := []struct { - Name string - Setup func() *expr.HTTPServiceExpr - WantErr bool - ErrorMsg string - }{ - { - Name: "valid HTTP and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // Regular HTTP method - m1 := &expr.MethodExpr{ - Name: "GetUser", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "WatchUsers", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: false, - }, - { - Name: "invalid WebSocket and HTTP mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // Regular HTTP method - m2 := &expr.MethodExpr{ - Name: "Get", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - { - Name: "invalid WebSocket and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "Watch", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - } - - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - svc := c.Setup() - err := svc.Validate() - - if c.WantErr { - if err == nil { - t.Errorf("expected error containing %q but got none", c.ErrorMsg) - } else if !containsStr(err.Error(), c.ErrorMsg) { - t.Errorf("expected error containing %q but got %q", c.ErrorMsg, err.Error()) - } - } else { - if err != nil { - // Check if it's a ValidationErrors with no actual errors - var verr *eval.ValidationErrors - if errors.As(err, &verr) && len(verr.Errors) == 0 { - // Empty validation errors, ignore - } else { - t.Logf("Error type: %T", err) - t.Errorf("unexpected error: %v", err) - } - } - } - }) - } -} - -func containsStr(s, substr string) bool { - if len(s) < len(substr) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/expr/method.go b/expr/method.go index 85697ebbf1..83ed3d8435 100644 --- a/expr/method.go +++ b/expr/method.go @@ -51,9 +51,8 @@ type ( // StreamingPayload is the payload sent across the stream. StreamingPayload *AttributeExpr // StreamingResult is the result sent across the stream when using SSE. - // When both Result and StreamingResult are defined with different types, - // the method supports content negotiation between standard HTTP responses - // (using Result) and SSE streams (using StreamingResult). + // When Result and StreamingResult are both defined, the method supports + // normal HTTP responses using Result and SSE streams using StreamingResult. StreamingResult *AttributeExpr } ) @@ -151,8 +150,8 @@ func (m *MethodExpr) validateRequirements() *eval.ValidationErrors { requirements = m.Requirements case len(m.Service.Requirements) > 0: requirements = m.Service.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(m.Service.design.API.Requirements) > 0: + requirements = m.Service.design.API.Requirements } var ( hasBasicAuth bool @@ -278,11 +277,19 @@ func (m *MethodExpr) validateErrors() *eval.ValidationErrors { // validateInterceptors validates the method interceptors. func (m *MethodExpr) validateInterceptors() *eval.ValidationErrors { verr := new(eval.ValidationErrors) - m.ClientInterceptors = mergeInterceptors(m.ClientInterceptors, m.Service.ClientInterceptors, Root.API.ClientInterceptors) + m.ClientInterceptors = mergeInterceptors( + m.ClientInterceptors, + m.Service.ClientInterceptors, + m.Service.design.API.ClientInterceptors, + ) for _, i := range m.ClientInterceptors { verr.Merge(i.validate(m)) } - m.ServerInterceptors = mergeInterceptors(m.ServerInterceptors, m.Service.ServerInterceptors, Root.API.ServerInterceptors) + m.ServerInterceptors = mergeInterceptors( + m.ServerInterceptors, + m.Service.ServerInterceptors, + m.Service.design.API.ServerInterceptors, + ) for _, i := range m.ServerInterceptors { verr.Merge(i.validate(m)) } @@ -417,8 +424,8 @@ func (m *MethodExpr) Finalize() { if len(m.Requirements) == 0 { if len(m.Service.Requirements) > 0 { m.Requirements = copyReqs(m.Service.Requirements) - } else if len(Root.API.Requirements) > 0 { - m.Requirements = copyReqs(Root.API.Requirements) + } else if len(m.Service.design.API.Requirements) > 0 { + m.Requirements = copyReqs(m.Service.design.API.Requirements) } } } @@ -438,8 +445,8 @@ func (m *MethodExpr) IsResultStreaming() bool { return m.Stream == ServerStreamKind || m.Stream == BidirectionalStreamKind } -// HasMixedResults returns true if the method has both Result and StreamingResult -// defined with different types, indicating support for content negotiation. +// HasMixedResults returns true if the method defines Result and StreamingResult +// separately so HTTP clients can choose a normal response or an SSE stream. func (m *MethodExpr) HasMixedResults() bool { return m.Result != nil && m.StreamingResult != nil && m.Result != m.StreamingResult } diff --git a/expr/method_test.go b/expr/method_test.go index 213e574ea0..1207c3b91b 100644 --- a/expr/method_test.go +++ b/expr/method_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -80,11 +81,6 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { }, } - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { scheme := &expr.SchemeExpr{ @@ -96,7 +92,10 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } req := &expr.SecurityExpr{Schemes: []*expr.SchemeExpr{scheme}} api, service := tc.setup(req) - expr.Root = &expr.RootExpr{API: api} + designAPI := expr.NewAPIExpr("test", func() {}) + designAPI.Requirements = api.Requirements + design := &expr.RootExpr{API: designAPI, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Method", @@ -114,27 +113,22 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } func TestMethodExprFinalizePreservesNoSecurityMarker(t *testing.T) { - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - - expr.Root = &expr.RootExpr{ - API: &expr.APIExpr{ - Requirements: []*expr.SecurityExpr{{ - Schemes: []*expr.SchemeExpr{{ - Kind: expr.JWTKind, - SchemeName: "jwt", - }}, - }}, - }, - } + service := &expr.ServiceExpr{Name: "Service"} + api := expr.NewAPIExpr("test", func() {}) + api.Requirements = []*expr.SecurityExpr{{ + Schemes: []*expr.SchemeExpr{{ + Kind: expr.JWTKind, + SchemeName: "jwt", + }}, + }} + design := &expr.RootExpr{API: api, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Health", Requirements: []*expr.SecurityExpr{{ Schemes: []*expr.SchemeExpr{{Kind: expr.NoKind}}, }}, - Service: &expr.ServiceExpr{Name: "Service"}, + Service: service, } method.Finalize() @@ -180,14 +174,17 @@ func TestMethodExprError(t *testing.T) { }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBaz, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorBar, }, } + design := &expr.RootExpr{ + API: expr.NewAPIExpr("test", func() {}), + Errors: []*expr.ErrorExpr{errorBaz}, + Services: []*expr.ServiceExpr{&s}, + } + design.WalkSets(func(eval.ExpressionSet) {}) m := expr.MethodExpr{ Errors: []*expr.ErrorExpr{ errorFoo, diff --git a/expr/random.go b/expr/random.go index bf8ee59aa1..b25809540b 100644 --- a/expr/random.go +++ b/expr/random.go @@ -1,5 +1,6 @@ -// This file defines immutable example-randomizer configuration and the -// mutable value streams owned by one code generation run. +// This file creates the repeatable example values used by one code generation +// run. Each ExampleGenerator has its own sequence of values and shares only the +// map used while building recursive types. package expr import ( @@ -14,9 +15,9 @@ import ( ) type ( - // Randomizer generates values used in generated examples. Implementations - // must return the same sequence when constructed from the same configuration - // and identity. + // Randomizer produces the primitive values used in generated examples. Two + // Randomizer values created with the same settings and equal ExampleIdentity + // keys must produce the same sequence. Randomizer interface { // ArrayLength decides how long an example array will be. ArrayLength() int @@ -58,74 +59,105 @@ type ( UUID() string } - // RandomizerFactory is immutable example configuration. NewRandomizer must - // create a new mutable stream for every call. identity identifies a stable - // design location so separate runs produce identical examples without - // sharing consumed stream state. + // RandomizerFactory stores settings used to create Randomizer values. + // NewRandomizer must return a new Randomizer on every call. Its + // ExampleIdentity argument selects a repeatable sequence without sharing + // values already consumed by another call. RandomizerFactory interface { - // NewRandomizer creates an independent value stream for identity. + // NewRandomizer creates an independent value sequence for the supplied + // ExampleIdentity. NewRandomizer(identity ExampleIdentity) Randomizer } - // exampleRandomizer hides the mutable stream field while promoting its - // value methods to ExampleGenerator. + // exampleRandomizer lets ExampleGenerator expose Randomizer methods without + // exposing its stored Randomizer field. exampleRandomizer interface { Randomizer } - // ExampleGenerator generates examples from one run-owned value stream. - // Derived generators use stable design identities and share only this run's - // recursion cache, so unrelated analysis order does not change examples. - // One planning thread owns each generator; concurrent runs use distinct - // generators. + // ExampleGenerator builds examples from one value sequence. Child generators + // use separate repeatable keys for fields and collection entries, and share + // the map of values currently being built so recursive types can stop. One + // planning thread uses each generator; concurrent runs use separate values. ExampleGenerator struct { exampleRandomizer factory RandomizerFactory identity ExampleIdentity - // root points to the generator this one was derived from so that all - // derived generators share the root's seen cache. It is nil on roots. + // root points to the first generator so child generators share its map of + // values currently being built. It is nil on the first generator. root *ExampleGenerator seen map[UserType]*any } - // fakerRandomizer implements Randomizer using the faker library. - fakerRandomizer struct { + // FakerRandomizer produces repeatable example values with the faker library. + FakerRandomizer struct { + // Seed is the input used to create this value sequence. + Seed string faker *faker.Faker rand *rand.Rand } - // deterministicRandomizer returns fixed values for every requested kind. - deterministicRandomizer struct{} + // DeterministicRandomizer returns the same fixed value from every method. + DeterministicRandomizer struct{} - // fakerRandomizerFactory retains only the seed configured by the API DSL. + // fakerRandomizerFactory stores the seed configured by the API DSL. fakerRandomizerFactory struct { seed string } - // deterministicRandomizerFactory carries no mutable run state. + // deterministicRandomizerFactory needs no settings. deterministicRandomizerFactory struct{} ) -// NewExampleGenerator creates an unanchored mutable run object with an empty -// recursion cache. Call At before requesting an example value. +// NewExampleGenerator returns a generator with no selected example sequence and +// no values currently being built. Call At with an ExampleIdentity before +// requesting a value. func NewExampleGenerator(factory RandomizerFactory) *ExampleGenerator { return &ExampleGenerator{factory: factory} } -// NewFakerRandomizerFactory returns immutable configuration that creates -// independent faker streams rooted at seed. +// NewFakerRandomizerFactory returns settings that create independent faker +// value sequences from seed. func NewFakerRandomizerFactory(seed string) RandomizerFactory { return fakerRandomizerFactory{seed: seed} } -// NewDeterministicRandomizerFactory returns immutable configuration that -// creates independent streams of fixed values. +// NewDeterministicRandomizerFactory returns settings that create independent +// Randomizer values whose methods return fixed values. func NewDeterministicRandomizerFactory() RandomizerFactory { return deterministicRandomizerFactory{} } -// At returns a generator whose stream is anchored to identity. Anchored -// generators share this run's recursion cache but never consumed stream state. +// NewFakerRandomizer returns a repeatable faker value sequence created from +// seed. +func NewFakerRandomizer(seed string) Randomizer { + hasher := md5.New() + hasher.Write([]byte(seed)) + sint := int64(binary.BigEndian.Uint64(hasher.Sum(nil))) + source := rand.NewSource(sint) + ran := rand.New(source) + faker := &faker.Faker{ + Language: "end", + Dict: faker.Dict["en"], + Rand: ran, + } + + return &FakerRandomizer{ + Seed: seed, + faker: faker, + rand: ran, + } +} + +// NewDeterministicRandomizer returns a value sequence whose methods return +// fixed values. +func NewDeterministicRandomizer() Randomizer { + return &DeterministicRandomizer{} +} + +// At returns a generator whose value sequence is selected by the supplied +// ExampleIdentity. The result shares the map of values currently being built +// in this run, but gets a new Randomizer with no consumed values. func (r *ExampleGenerator) At(identity ExampleIdentity) *ExampleGenerator { root := r.store() if root.factory == nil { @@ -142,8 +174,8 @@ func (r *ExampleGenerator) At(identity ExampleIdentity) *ExampleGenerator { } } -// Member returns a generator for the named object member below the current -// anchored identity. +// Member returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the named field. func (r *ExampleGenerator) Member(name string) *ExampleGenerator { if r.factory == nil { return r @@ -151,8 +183,8 @@ func (r *ExampleGenerator) Member(name string) *ExampleGenerator { return r.structural(r.identity.Member(name)) } -// ArrayElement returns a generator for the indexed array element below the -// current anchored identity. +// ArrayElement returns a generator whose repeatable sequence is selected by +// the current ExampleIdentity plus the array index. func (r *ExampleGenerator) ArrayElement(index int) *ExampleGenerator { if r.factory == nil { return r @@ -160,8 +192,8 @@ func (r *ExampleGenerator) ArrayElement(index int) *ExampleGenerator { return r.structural(r.identity.ArrayElement(index)) } -// MapKey returns a generator for the indexed map key below the current -// anchored identity. +// MapKey returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map key index. func (r *ExampleGenerator) MapKey(index int) *ExampleGenerator { if r.factory == nil { return r @@ -169,8 +201,8 @@ func (r *ExampleGenerator) MapKey(index int) *ExampleGenerator { return r.structural(r.identity.MapKey(index)) } -// MapValue returns a generator for the indexed map value below the current -// anchored identity. +// MapValue returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map value index. func (r *ExampleGenerator) MapValue(index int) *ExampleGenerator { if r.factory == nil { return r @@ -178,8 +210,8 @@ func (r *ExampleGenerator) MapValue(index int) *ExampleGenerator { return r.structural(r.identity.MapValue(index)) } -// UnionMember returns a generator for the named union member below the current -// anchored identity. +// UnionMember returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the union branch name. func (r *ExampleGenerator) UnionMember(name string) *ExampleGenerator { if r.factory == nil { return r @@ -187,126 +219,177 @@ func (r *ExampleGenerator) UnionMember(name string) *ExampleGenerator { return r.structural(r.identity.UnionMember(name)) } -func (r *fakerRandomizer) ArrayLength() int { +// ArrayLength returns a small positive array length. +func (r *FakerRandomizer) ArrayLength() int { return r.Int()%3 + 2 } -func (r *fakerRandomizer) Int() int { + +// Int returns the next int value. +func (r *FakerRandomizer) Int() int { return r.rand.Int() } -func (r *fakerRandomizer) Int32() int32 { + +// Int32 returns the next int32 value. +func (r *FakerRandomizer) Int32() int32 { return r.rand.Int31() } -func (r *fakerRandomizer) Int64() int64 { + +// Int64 returns the next int64 value. +func (r *FakerRandomizer) Int64() int64 { return r.rand.Int63() } -func (r *fakerRandomizer) String() string { + +// String returns the next short sentence. +func (r *FakerRandomizer) String() string { return r.faker.Sentence(2, false) } -func (r *fakerRandomizer) Bool() bool { + +// Bool returns the next boolean value. +func (r *FakerRandomizer) Bool() bool { return r.rand.Int()%2 == 0 } -func (r *fakerRandomizer) Float32() float32 { + +// Float32 returns the next float32 value. +func (r *FakerRandomizer) Float32() float32 { return r.rand.Float32() } -func (r *fakerRandomizer) Float64() float64 { + +// Float64 returns the next float64 value. +func (r *FakerRandomizer) Float64() float64 { return r.rand.Float64() } -func (r *fakerRandomizer) UInt() uint { + +// UInt returns the next uint value. +func (r *FakerRandomizer) UInt() uint { return uint(r.UInt64()) } -func (r *fakerRandomizer) UInt32() uint32 { + +// UInt32 returns the next uint32 value. +func (r *FakerRandomizer) UInt32() uint32 { return r.rand.Uint32() } -func (r *fakerRandomizer) UInt64() uint64 { + +// UInt64 returns the next uint64 value. +func (r *FakerRandomizer) UInt64() uint64 { return r.rand.Uint64() } -func (r *fakerRandomizer) Email() string { + +// Email returns the next email address. +func (r *FakerRandomizer) Email() string { return r.faker.Email() } -func (r *fakerRandomizer) Hostname() string { + +// Hostname returns the next hostname. +func (r *FakerRandomizer) Hostname() string { return r.faker.DomainName() + "." + r.faker.DomainSuffix() } -func (r *fakerRandomizer) IPv4Address() net.IP { + +// IPv4Address returns the next IPv4 address. +func (r *FakerRandomizer) IPv4Address() net.IP { return r.faker.IPv4Address() } -func (r *fakerRandomizer) IPv6Address() net.IP { + +// IPv6Address returns the next IPv6 address. +func (r *FakerRandomizer) IPv6Address() net.IP { return r.faker.IPv6Address() } -func (r *fakerRandomizer) URL() string { + +// URL returns the next URL. +func (r *FakerRandomizer) URL() string { return r.faker.URL() } -func (r *fakerRandomizer) Characters(n int) string { + +// Characters returns the next string containing n characters. +func (r *FakerRandomizer) Characters(n int) string { return r.faker.Characters(n) } -func (r *fakerRandomizer) UUID() string { + +// UUID returns the next random version 4 UUID. +func (r *FakerRandomizer) UUID() string { uuid := make([]byte, 16) r.rand.Read(uuid) uuid[6] = (uuid[6] & 0x0f) | 0x40 uuid[8] = (uuid[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) } -func (r *fakerRandomizer) Name() string { + +// Name returns the next human name. +func (r *FakerRandomizer) Name() string { return r.faker.Name() } -func (deterministicRandomizer) ArrayLength() int { return 1 } -func (deterministicRandomizer) Int() int { return 1 } -func (deterministicRandomizer) Int32() int32 { return 1 } -func (deterministicRandomizer) Int64() int64 { return 1 } -func (deterministicRandomizer) String() string { return "abc123" } -func (deterministicRandomizer) Bool() bool { return false } -func (deterministicRandomizer) Float32() float32 { return 1 } -func (deterministicRandomizer) Float64() float64 { return 1 } -func (deterministicRandomizer) UInt() uint { return 1 } -func (deterministicRandomizer) UInt32() uint32 { return 1 } -func (deterministicRandomizer) UInt64() uint64 { return 1 } -func (deterministicRandomizer) Name() string { return "Alice" } -func (deterministicRandomizer) Email() string { return "alice@example.com" } -func (deterministicRandomizer) Hostname() string { return "example.com" } -func (deterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } -func (deterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } -func (deterministicRandomizer) URL() string { return "https://example.com/foo" } -func (deterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } -func (deterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } - -// NewRandomizer creates an independent faker stream for identity. -func (f fakerRandomizerFactory) NewRandomizer(identity ExampleIdentity) Randomizer { - return newFakerRandomizer(f.seed + identity.Seed()) -} +// ArrayLength returns one. +func (DeterministicRandomizer) ArrayLength() int { return 1 } -// NewRandomizer creates an independent deterministic stream. identity does -// not affect fixed deterministic values. -func (deterministicRandomizerFactory) NewRandomizer(ExampleIdentity) Randomizer { - return newDeterministicRandomizer() -} +// Int returns one. +func (DeterministicRandomizer) Int() int { return 1 } -// newFakerRandomizer creates a mutable faker stream from exact seed material. -func newFakerRandomizer(seed string) Randomizer { - hasher := md5.New() - hasher.Write([]byte(seed)) - sint := int64(binary.BigEndian.Uint64(hasher.Sum(nil))) - source := rand.NewSource(sint) - ran := rand.New(source) - faker := &faker.Faker{ - Language: "end", - Dict: faker.Dict["en"], - Rand: ran, - } +// Int32 returns one. +func (DeterministicRandomizer) Int32() int32 { return 1 } - return &fakerRandomizer{ - faker: faker, - rand: ran, - } +// Int64 returns one. +func (DeterministicRandomizer) Int64() int64 { return 1 } + +// String returns a fixed string. +func (DeterministicRandomizer) String() string { return "abc123" } + +// Bool returns false. +func (DeterministicRandomizer) Bool() bool { return false } + +// Float32 returns one. +func (DeterministicRandomizer) Float32() float32 { return 1 } + +// Float64 returns one. +func (DeterministicRandomizer) Float64() float64 { return 1 } + +// UInt returns one. +func (DeterministicRandomizer) UInt() uint { return 1 } + +// UInt32 returns one. +func (DeterministicRandomizer) UInt32() uint32 { return 1 } + +// UInt64 returns one. +func (DeterministicRandomizer) UInt64() uint64 { return 1 } + +// Name returns a fixed human name. +func (DeterministicRandomizer) Name() string { return "Alice" } + +// Email returns a fixed email address. +func (DeterministicRandomizer) Email() string { return "alice@example.com" } + +// Hostname returns a fixed hostname. +func (DeterministicRandomizer) Hostname() string { return "example.com" } + +// IPv4Address returns the unspecified IPv4 address. +func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } + +// IPv6Address returns the unspecified IPv6 address. +func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } + +// URL returns a fixed URL. +func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } + +// Characters returns n copies of "a". +func (DeterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } + +// UUID returns a fixed version 4 UUID. +func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// NewRandomizer creates an independent faker value sequence selected by the +// supplied ExampleIdentity key. +func (f fakerRandomizerFactory) NewRandomizer(identity ExampleIdentity) Randomizer { + return NewFakerRandomizer(f.seed + identity.Seed()) } -// newDeterministicRandomizer builds a stream that returns fixed values. -func newDeterministicRandomizer() Randomizer { - return &deterministicRandomizer{} +// NewRandomizer creates an independent Randomizer whose methods return fixed +// values. The supplied ExampleIdentity does not change those values. +func (deterministicRandomizerFactory) NewRandomizer(ExampleIdentity) Randomizer { + return NewDeterministicRandomizer() } -// previouslySeen returns the value already being built for typ in this run. -// Declaration origins, rather than authored string IDs, distinguish graph -// nodes while still breaking recursive cycles through copied types. +// previouslySeen returns the value already being built for typ in this run. It +// uses the original type declaration so copied types find the same in-progress +// value and recursive definitions stop. func (r *ExampleGenerator) previouslySeen(typ UserType) (*any, bool) { s := r.store() if s.seen == nil { @@ -316,8 +399,8 @@ func (r *ExampleGenerator) previouslySeen(typ UserType) (*any, bool) { return val, haveSeen } -// haveSeen records the value being built for typ so recursive descent can -// reuse it before construction finishes. +// haveSeen records the value currently being built for typ so a recursive use +// can return it before construction finishes. func (r *ExampleGenerator) haveSeen(typ UserType, val *any) { s := r.store() if s.seen == nil { @@ -327,8 +410,8 @@ func (r *ExampleGenerator) haveSeen(typ UserType, val *any) { s.seen[typ.Origin()] = val } -// store returns the generator owning the seen cache and factory: the generator -// this one was derived from, or the generator itself when it is a root. +// store returns the first generator, which stores the RandomizerFactory and the +// map of values currently being built. It returns r when r has no parent. func (r *ExampleGenerator) store() *ExampleGenerator { if r.root != nil { return r.root @@ -336,8 +419,8 @@ func (r *ExampleGenerator) store() *ExampleGenerator { return r } -// structural returns a generator drawing from the structural identity and -// sharing this generator's run-local recursion cache. +// structural returns a generator for the sequence selected by the supplied +// ExampleIdentity. It shares this run's map of values currently being built. func (r *ExampleGenerator) structural(identity ExampleIdentity) *ExampleGenerator { if r.factory == nil { return r diff --git a/expr/random_factory_test.go b/expr/random_factory_test.go index 48e675289b..212a3000a1 100644 --- a/expr/random_factory_test.go +++ b/expr/random_factory_test.go @@ -61,6 +61,21 @@ func TestRandomizerFactoriesCreateIndependentStreams(t *testing.T) { } } +// TestReleasedStandaloneRandomizers checks the released concrete randomizer +// types, seed, and repeatable values. +func TestReleasedStandaloneRandomizers(t *testing.T) { + faker := expr.NewFakerRandomizer("seed") + concrete, ok := faker.(*expr.FakerRandomizer) + require.True(t, ok) + require.Equal(t, "seed", concrete.Seed) + require.Equal(t, expr.NewFakerRandomizer("seed").String(), faker.String()) + + deterministic := expr.NewDeterministicRandomizer() + _, ok = deterministic.(*expr.DeterministicRandomizer) + require.True(t, ok) + require.Equal(t, "abc123", deterministic.String()) +} + func TestRandomizerFactoriesPreserveDerivedExampleStability(t *testing.T) { factory := expr.NewFakerRandomizerFactory("seed") identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) diff --git a/expr/result_type.go b/expr/result_type.go index dd39c28e97..70f323aca6 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -1,5 +1,5 @@ -// This file defines result types and views, including the declaration origin -// retained when code generation rebuilds projected result graphs. +// This file defines result types and views and records the original +// declaration used when a result type is copied. package expr import ( @@ -62,6 +62,7 @@ var ( Type: errorResultType, Description: "Error response result type", Validation: &ValidationExpr{Required: []string{"name", "id", "message", "temporary", "timeout", "fault"}}, + finalized: true, }, TypeName: "error", }, @@ -119,6 +120,13 @@ func NewResultTypeExpr(name, identifier string, fn func()) *ResultTypeExpr { } } +// IsErrorResult reports whether dataType is Goa's built-in service error type +// or a generator copy made from it. +func IsErrorResult(dataType DataType) bool { + userType, ok := dataType.(UserType) + return ok && userType.Origin() == ErrorResult +} + // CanonicalIdentifier returns the result type identifier sans suffix // which is what the DSL uses to store and lookup result types. func CanonicalIdentifier(identifier string) string { @@ -147,8 +155,8 @@ func (rt *ResultTypeExpr) Dup(att *AttributeExpr) UserType { } // Origin returns the earliest result type declaration from which rt was -// copied. Result types override their embedded user-type origin so the dynamic -// result-type identity is preserved. +// copied. Result types override their embedded user-type origin so later copies +// still point to the original result declaration. func (rt *ResultTypeExpr) Origin() UserType { if rt.origin != nil { return rt.origin @@ -381,8 +389,9 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType return proj, nil } -// projectedUserType preserves the exact example owner of a synthesized result -// type while authored projections retain their media-type-derived UID. +// projectedUserType makes a synthesized result type use the same repeatable +// example sequence as source. A view-specific type authored in the design keeps +// its media-type-derived UID instead. func projectedUserType(source UserType, name, uid string, attribute *AttributeExpr) *UserTypeExpr { if identity, ok := GeneratedUserTypeExampleIdentity(source); ok { return NewGeneratedUserType(name, attribute, identity) diff --git a/expr/root.go b/expr/root.go index d990c4a707..913107736c 100644 --- a/expr/root.go +++ b/expr/root.go @@ -91,6 +91,9 @@ func (r *RootExpr) WalkSets(walk eval.SetWalker) { walk(rtypes) // Services + for _, service := range r.Services { + service.design = r + } walk(eval.ToExpressionSet(r.Services)) // Methods (must be done after services) @@ -225,8 +228,8 @@ func (r *RootExpr) Validate() error { } // validateTypeMappings rejects repeated declarations that would generate the -// same method on one user type. The reflected type preserves package identity, -// so equally named external types from different packages remain distinct. +// same method on one user type. A reflected type includes its package path, so +// equally named external types from different packages remain distinct. func validateTypeMappings(direction string, mappings []*TypeMap) *eval.ValidationErrors { type mappingIdentity struct { user UserType diff --git a/expr/security.go b/expr/security.go index 6ae1c45c95..7ef6d55e4e 100644 --- a/expr/security.go +++ b/expr/security.go @@ -83,6 +83,9 @@ type ( Flows []*FlowExpr // Meta is a list of key/value pairs Meta MetaExpr + // authored points to the security scheme copied for a transport. It is + // nil while this value is the scheme declared by the design. + authored *SchemeExpr } // FlowExpr describes a specific OAuth2 flow. @@ -141,10 +144,20 @@ func DupScheme(sch *SchemeExpr) *SchemeExpr { Scopes: sch.Scopes, Flows: sch.Flows, Meta: sch.Meta, + authored: sch.AuthoredScheme(), } return &dup } +// AuthoredScheme returns the security scheme declared by the design. It +// returns s when s has not been copied for a transport. +func (s *SchemeExpr) AuthoredScheme() *SchemeExpr { + if s.authored != nil { + return s.authored + } + return s +} + // HasNoSecurity returns true if the security requirements explicitly disable // security. func HasNoSecurity(reqs []*SecurityExpr) bool { diff --git a/expr/service.go b/expr/service.go index 5b1d0e57b3..75dff39454 100644 --- a/expr/service.go +++ b/expr/service.go @@ -1,10 +1,11 @@ -// This file defines service and error expressions, including the distinct -// ownership of authored errors and compiler-wrapped inline method errors. +// This file defines services and their errors. It also distinguishes errors +// named in the design from errors created for one method. package expr import ( "errors" "fmt" + "strings" "goa.design/goa/v3/eval" ) @@ -36,6 +37,8 @@ type ( // Meta is a set of key/value pairs with semantic that is // specific to each generator. Meta MetaExpr + // design points to the root containing this service. + design *RootExpr } // ErrorExpr defines an error response. It consists of a named @@ -74,7 +77,7 @@ func (s *ServiceExpr) Error(name string) *ErrorExpr { return erro } } - return Root.Error(name) + return s.design.Error(name) } // Hash returns a unique hash value for s. @@ -93,9 +96,59 @@ func (s *ServiceExpr) Validate() error { } } } + verr.Merge(s.validateInlineMethodErrors()) return verr } +// validateInlineMethodErrors rejects two inline errors that request one public +// Go error name but define different values. +func (s *ServiceExpr) validateInlineMethodErrors() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + seen := make(map[string]*ErrorExpr) + for _, serviceError := range s.Errors { + if standardErrorUsesGeneratedConstructor(serviceError) { + seen[serviceError.Name] = serviceError + } + } + for _, method := range s.Methods { + for _, methodError := range method.Errors { + if !standardErrorUsesGeneratedConstructor(methodError) { + continue + } + if previous := seen[methodError.Name]; previous != nil { + if !equivalentErrorAttributes(previous.AttributeExpr, methodError.AttributeExpr) { + if settings := differingErrorQualifierSettings(previous.AttributeExpr, methodError.AttributeExpr); len(settings) > 0 { + verr.Add( + methodError, + "error %q cannot use one generated constructor because its %s setting differs in service %q", + methodError.Name, + strings.Join(settings, ", "), + s.Name, + ) + } else { + verr.Add( + methodError, + "inline error %q must define the same value contract in every method of service %q", + methodError.Name, + s.Name, + ) + } + } + continue + } + seen[methodError.Name] = methodError + } + } + return verr +} + +// standardErrorUsesGeneratedConstructor reports whether Goa generates the +// shared Make function whose behavior repeated declarations could change. +func standardErrorUsesGeneratedConstructor(errorExpression *ErrorExpr) bool { + userType, authored := errorExpression.Type.(UserType) + return !authored || IsErrorResult(userType) +} + // Finalize finalizes all the service methods and errors. func (s *ServiceExpr) Finalize() { for _, e := range s.Errors { @@ -136,7 +189,7 @@ func (e *ErrorExpr) Finalize() { att := e.AttributeExpr switch dt := att.Type.(type) { case UserType: - if dt != ErrorResult { + if !IsErrorResult(dt) { // If this type contains an attribute with "struct:error:name" meta // then no need to do anything. if IsObject(dt) { @@ -161,12 +214,36 @@ func (e *ErrorExpr) Finalize() { } } -// finalizeMethodType wraps an inline method error with the exact method-error -// owner used by service and transport example generation. +// finalizeMethodType wraps an inline method error and assigns the repeatable +// example key used by service and transport generators for that method error. func (e *ErrorExpr) finalizeMethodType(method *MethodExpr) { - e.AttributeExpr = &AttributeExpr{Type: NewGeneratedUserType( + e.AttributeExpr = &AttributeExpr{Type: newGeneratedUserType( e.Name, e.AttributeExpr, MethodErrorExampleIdentity(method, e), + previousInlineMethodErrorOrigin(method, e.Name), )} } + +// previousInlineMethodErrorOrigin returns the declaration already created for +// the same inline error by an earlier method in this service. +func previousInlineMethodErrorOrigin(method *MethodExpr, name string) UserType { + for _, previousMethod := range method.Service.Methods { + if previousMethod == method { + return nil + } + for _, previousError := range previousMethod.Errors { + if previousError.Name != name { + continue + } + userType, ok := previousError.Type.(UserType) + if !ok { + continue + } + if _, generated := GeneratedUserTypeExampleIdentity(userType); generated { + return userType.Origin() + } + } + } + return nil +} diff --git a/expr/service_test.go b/expr/service_test.go index 6cf39903aa..0eee205bca 100644 --- a/expr/service_test.go +++ b/expr/service_test.go @@ -4,7 +4,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -45,6 +48,115 @@ func TestServiceExprMethod(t *testing.T) { } } +func TestEquivalentInlineMethodErrorsShareOrigin(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("secured", func() { + for _, method := range []string{"read", "write"} { + dsl.Method(method, func() { + dsl.Error("invalid_scopes", dsl.String) + }) + } + }) + }) + service := root.Service("secured") + first := service.Method("read").Error("invalid_scopes").Type.(expr.UserType) + second := service.Method("write").Error("invalid_scopes").Type.(expr.UserType) + + require.NotSame(t, first, second) + require.Same(t, first.Origin(), second.Origin()) + firstIdentity, ok := expr.GeneratedUserTypeExampleIdentity(first) + require.True(t, ok) + secondIdentity, ok := expr.GeneratedUserTypeExampleIdentity(second) + require.True(t, ok) + require.NotEqual(t, firstIdentity, secondIdentity) +} + +func TestIncompatibleInlineMethodErrorsAreRejected(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("secured", func() { + dsl.Method("read", func() { + dsl.Error("invalid_scopes", dsl.String) + }) + dsl.Method("write", func() { + dsl.Error("invalid_scopes", dsl.Int) + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, `inline error "invalid_scopes" must define the same value contract in every method of service "secured"`) +} + +func TestRepeatedStandardErrorsMustUseSameQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: dsl.Temporary}, + {name: "timeout", apply: func() { dsl.Timeout() }}, + {name: "fault", apply: dsl.Fault}, + } + for _, qualifier := range qualifiers { + t.Run("service and method "+qualifier.name, func(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy") + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + + t.Run("two methods "+qualifier.name, func(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("jobs", func() { + dsl.Method("start", func() { + dsl.Error("busy", qualifier.apply) + }) + dsl.Method("resume", func() { + dsl.Error("busy") + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + + t.Run("matching "+qualifier.name, func(t *testing.T) { + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy", qualifier.apply) + }) + }) + }) + }) + } +} + +func TestRepeatedAuthoredErrorTypesDoNotShareGeneratedConstructors(t *testing.T) { + custom := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + }) + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", custom, dsl.Temporary) + dsl.Method("run", func() { + dsl.Error("busy", custom) + }) + }) + }) +} + func TestServiceExprError(t *testing.T) { var ( errorFoo = &expr.ErrorExpr{ @@ -72,14 +184,17 @@ func TestServiceExprError(t *testing.T) { }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBar, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorFoo, }, } + design := &expr.RootExpr{ + API: expr.NewAPIExpr("test", func() {}), + Errors: []*expr.ErrorExpr{errorBar}, + Services: []*expr.ServiceExpr{&s}, + } + design.WalkSets(func(eval.ExpressionSet) {}) for k, tc := range cases { t.Run(k, func(t *testing.T) { if actual := s.Error(tc.name); actual != tc.expected { diff --git a/expr/streaming_response_mapping_test.go b/expr/streaming_response_mapping_test.go index e26f85c9f1..9aaf07e45d 100644 --- a/expr/streaming_response_mapping_test.go +++ b/expr/streaming_response_mapping_test.go @@ -12,8 +12,8 @@ import ( "goa.design/goa/v3/expr" ) -// TestStreamingSuccessResponseRejectsHeadersAndCookies checks both HTTP and -// JSON-RPC methods over SSE and WebSocket connections. +// TestStreamingSuccessResponseRejectsHeadersAndCookies checks HTTP methods over +// SSE and WebSocket connections and JSON-RPC methods over SSE. func TestStreamingSuccessResponseRejectsHeadersAndCookies(t *testing.T) { transports := []struct { name string @@ -22,7 +22,6 @@ func TestStreamingSuccessResponseRejectsHeadersAndCookies(t *testing.T) { {name: "HTTP server-sent events", dsl: httpStreamingResponseMappingDSL(true)}, {name: "HTTP WebSocket", dsl: httpStreamingResponseMappingDSL(false)}, {name: "JSON-RPC server-sent events", dsl: jsonRPCStreamingResponseMappingDSL(true)}, - {name: "JSON-RPC WebSocket", dsl: jsonRPCStreamingResponseMappingDSL(false)}, } mappings := []struct { name string diff --git a/expr/testdata/endpoint_dsls.go b/expr/testdata/endpoint_dsls.go index 0e16481496..162956e30f 100644 --- a/expr/testdata/endpoint_dsls.go +++ b/expr/testdata/endpoint_dsls.go @@ -462,6 +462,20 @@ var EndpointPayloadMissingRequired = func() { }) } +var EndpointMultipartWithoutBody = func() { + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("id", String) + }) + HTTP(func() { + POST("/{id}") + MultipartRequest() + }) + }) + }) +} + var StreamingEndpointRequestBody = func() { var PT = Type("Payload", func() { Attribute("foo", String) @@ -627,6 +641,35 @@ var GRPCEndpointWithAnyType = func() { }) } +var GRPCEndpointWithMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(Int) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithMatchingMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithStreamingResult = func() { + Service("Service", func() { + Method("Method", func() { + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + var GRPCEndpointWithUntaggedFields = func() { var Req = Type("Req", func() { Attribute("req_not_field", String) diff --git a/expr/testdata/mixed_jsonrpc_transports.go b/expr/testdata/mixed_jsonrpc_transports.go deleted file mode 100644 index 3cf13ed25c..0000000000 --- a/expr/testdata/mixed_jsonrpc_transports.go +++ /dev/null @@ -1,151 +0,0 @@ -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// MixedJSONRPCTransportsAPI defines an API with mixed JSON-RPC transports. -var MixedJSONRPCTransportsAPI = func() { - API("MixedTransports", func() { - Title("Mixed JSON-RPC Transports API") - Description("API demonstrating mixed HTTP and SSE JSON-RPC transports") - }) - - Service("MixedService", func() { - Description("Service with both HTTP and SSE JSON-RPC methods") - - // Regular HTTP method - Method("GetUser", func() { - Payload(func() { - ID("id", String, "User ID") - Required("id") - }) - Result(func() { - ID("id", String, "User ID") - Field(1, "name", String) - Field(2, "email", String) - Required("id") - }) - HTTP(func() { - POST("/users/{id}") - }) - JSONRPC(func() { - }) - }) - - // SSE streaming method - Method("WatchUsers", func() { - Payload(func() { - ID("request_id", String, "Request ID") - Field(1, "filter", String, "Filter expression") - Required("request_id") - }) - StreamingResult(func() { - Field(1, "user_id", String) - Field(2, "event", String) - Field(3, "timestamp", String) - }) - HTTP(func() { - POST("/users/watch") - ServerSentEvents() // Enable SSE for this method - }) - JSONRPC(func() { - }) - }) - - // Another regular HTTP method - Method("CreateUser", func() { - Payload(func() { - Field(1, "name", String) - Field(2, "email", String) - Required("name", "email") - }) - Result(func() { - Field(1, "id", String, "Created user ID") - }) - HTTP(func() { - POST("/users") - }) - JSONRPC(func() { - // Notification - no ID needed - }) - }) - - // Configure JSON-RPC endpoint - JSONRPC(func() { - Path("/api/rpc") - }) - }) -} - -// ValidWebSocketOnlyAPI shows WebSocket cannot mix with other transports. -var ValidWebSocketOnlyAPI = func() { - API("WebSocketOnly", func() { - Title("WebSocket Only API") - }) - - Service("WebSocketService", func() { - Description("Service with only WebSocket JSON-RPC methods") - - Method("Connect", func() { - Payload(func() { - ID("token", String, "Request token used as ID") - Required("token") - }) - StreamingPayload(func() { - Field(1, "message", String) - }) - StreamingResult(func() { - Field(1, "response", String) - }) - HTTP(func() { - GET("/ws") - }) - JSONRPC(func() { - }) - }) - - JSONRPC(func() { - Path("/ws") - }) - }) -} - -// InvalidMixedWebSocketAPI shows invalid mixing of WebSocket with other transports. -var InvalidMixedWebSocketAPI = func() { - API("InvalidMixed", func() { - Title("Invalid Mixed API") - }) - - Service("InvalidService", func() { - Description("Service incorrectly mixing WebSocket with HTTP") - - // WebSocket method - Method("Stream", func() { - StreamingPayload(String) - StreamingResult(String) - HTTP(func() { - GET("/stream") - }) - JSONRPC(func() { - // Streaming methods typically don't use ID - }) - }) - - // Regular HTTP method - THIS SHOULD CAUSE VALIDATION ERROR - Method("Get", func() { - Payload(String) - Result(String) - HTTP(func() { - POST("/get") - }) - JSONRPC(func() { - // This method mixes with WebSocket - should error - }) - }) - - JSONRPC(func() { - Path("/invalid") - }) - }) -} \ No newline at end of file diff --git a/expr/transport_error_contract_test.go b/expr/transport_error_contract_test.go index cadac768d9..0dae5fff8e 100644 --- a/expr/transport_error_contract_test.go +++ b/expr/transport_error_contract_test.go @@ -98,6 +98,49 @@ func TestGRPCInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { require.ErrorContains(t, err, "must define the same error attribute") } +func TestInheritedErrorMappingReportsDifferentQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: Temporary}, + {name: "timeout", apply: func() { Timeout() }}, + {name: "fault", apply: Fault}, + } + for _, transport := range []string{"HTTP", "gRPC"} { + for _, qualifier := range qualifiers { + t.Run(transport+" "+qualifier.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, qualifierErrorMappingDSL(transport, qualifier.apply)) + require.ErrorContains(t, err, transport+` error mapping "busy"`) + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + } + } +} + +func qualifierErrorMappingDSL(transport string, qualifier func()) func() { + return func() { + API("errors", func() { + Error("busy", qualifier) + if transport == "HTTP" { + HTTP(func() { Response(StatusServiceUnavailable, "busy") }) + } else { + GRPC(func() { Response("busy", CodeUnavailable) }) + } + }) + Service("Jobs", func() { + Method("Run", func() { + Error("busy") + if transport == "HTTP" { + HTTP(func() { POST("/run") }) + } else { + GRPC(func() {}) + } + }) + }) + } +} + var equivalentHTTPErrorMappingDSL = func() { API("errors", func() { Error("bad_request", String) diff --git a/expr/types.go b/expr/types.go index 74f6499139..5ef84bd451 100644 --- a/expr/types.go +++ b/expr/types.go @@ -79,8 +79,8 @@ type ( CompositeExpr // ID returns the identifier for the user type. ID() string - // Origin returns the earliest comparable user type declaration from - // which this value was copied. + // Origin returns the first user type declaration from which this value + // was copied. An authored value returns itself. Origin() UserType // Rename changes the type name to the given value. Rename(string) @@ -185,6 +185,7 @@ var Empty = &UserTypeExpr{ AttributeExpr: &AttributeExpr{ Description: "Empty represents empty values", Type: &Object{}, + finalized: true, }, } diff --git a/expr/user_type.go b/expr/user_type.go index e43c4ce42a..7f9504b327 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -1,34 +1,40 @@ -// This file defines user-authored type declarations and the distinction -// between their stable semantic IDs and in-memory copy provenance. +// This file defines user types and records the original declaration from +// which each copied type was created. package expr type ( - // UserTypeExpr describes user defined types. While a given design must - // ensure that the names are unique the code used to generate code can - // create multiple user types that share the same name (for example because - // generated in different packages). When supplied, UID is a stable semantic - // identifier used by authored examples and media-type behavior; generated - // types retain a separate opaque example owner. Origin identifies copied - // in-memory declarations. + // UserTypeExpr describes a type declared in a Goa design or created by a + // generator. One design cannot declare two types with the same name, but + // generators may create same-named types in different Go packages. UID keeps + // authored examples and result-type behavior tied to the declared type. + // Generated types use exampleIdentity instead. Origin points to the first + // UserTypeExpr from which a copied type was made. UserTypeExpr struct { // The embedded attribute expression. *AttributeExpr // Name of type TypeName string - // UID is the optional stable semantic identifier of the type. + // UID identifies an authored type across copies of its expression. UID string // origin is the earliest declaration copied to create this type. origin UserType - // exampleIdentity is the semantic owner of a type synthesized by a - // transport generator. Authored types leave it empty and use ID. + // exampleIdentity selects the repeatable example sequence for a type created + // by a transport generator. Authored types leave it empty and use ID. exampleIdentity ExampleIdentity } ) -// NewGeneratedUserType creates a synthesized user type whose stable ID and -// examples are derived from identity. Code generators use this constructor so -// a copied wire type cannot accidentally inherit an authored type's identity. +// NewGeneratedUserType creates a user type for generated transport data. +// The supplied ExampleIdentity selects the generated type's ID and repeatable +// example sequence. Copies of a request or response type therefore do not use +// examples belonging to the authored service type. func NewGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity) *UserTypeExpr { + return newGeneratedUserType(name, attribute, identity, nil) +} + +// newGeneratedUserType creates one generated wrapper. origin identifies a +// prior wrapper that represents the same generated Go declaration. +func newGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity, origin UserType) *UserTypeExpr { if identity.seed == "" { panic("generated user type requires an example identity") } @@ -36,6 +42,7 @@ func NewGeneratedUserType(name string, attribute *AttributeExpr, identity Exampl AttributeExpr: attribute, TypeName: name, UID: "generated:" + identity.Seed(), + origin: origin, exampleIdentity: identity, } } diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index 4d0ea48cd7..903445e846 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -11,16 +11,15 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the client files that contain client methods to call the -// corresponding service methods along with the encoding and decoding logic. -func ClientFiles(services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// clientFiles returns the planned client methods and their encoders and decoders. +func clientFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(clientFile(svc, services), services, svc.GRPCEndpoints...) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(clientFile(servicePlan.expression, services), services, servicePlan) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = addEndpointImports(clientEncodeDecode(svc, services), services, svc.GRPCEndpoints...) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(clientEncodeDecode(servicePlan.expression, services), services, servicePlan) } return fw } @@ -35,6 +34,7 @@ func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "client.go") imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -42,11 +42,11 @@ func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), codegen.GoaNamedImport("grpc/pb", "goapb"), - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } if serviceHasViewedClientStream(data) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC client", "client", imports), @@ -124,9 +124,9 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "encode_decode.go") imports := []*codegen.ImportSpec{ - {Path: "fmt"}, {Path: "context"}, {Path: "strconv"}, {Path: "unicode/utf8"}, @@ -134,17 +134,20 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + } + if requestMetadataNeedsFormat(data) { + imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) } if serviceHasUnaryViewedResult(data) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} - fm := transTmplFuncs(svc, services) + fm := transTmplFuncs(data) fm["hasInitArg"] = hasInitArg fm["metadataEncodeDecodeData"] = metadataEncodeDecodeData - fm["typeConversionData"] = typeConversionData + fm["typeStringExpressionData"] = typeStringExpressionData fm["isBearer"] = isBearer for _, e := range data.Endpoints { sections = append(sections, &codegen.SectionTemplate{ @@ -155,7 +158,7 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code if e.PayloadRef != "" { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: grpcTemplates.Read(grpcRequestEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcRequestEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: fm, }) @@ -174,8 +177,8 @@ func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code } // hasInitArg reports whether a generated constructor consumes the named -// source variable. Templates use it to avoid binding an empty protobuf -// message that only carries response metadata. +// source variable. Templates use it to avoid declaring an unused variable for +// an empty protobuf message that only carries response metadata. func hasInitArg(args []*InitArgData, name string) bool { for _, arg := range args { if arg.Name == name { diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 2fabd74bb4..64457668f1 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -9,51 +9,73 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/cli" - "goa.design/goa/v3/expr" ) -// ClientCLIFiles returns the CLI files to generate a command-line client that -// makes gRPC requests. -func ClientCLIFiles(services *ServicesData) []*codegen.File { - if len(services.Root.API.GRPC.Services) == 0 { +type ( + // commandData adds the exact gRPC client constructor to the shared command + // data used by transport command-line generators. + commandData struct { + *cli.CommandData + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + } +) + +// clientCLIFiles returns the planned command-line client files. +func clientCLIFiles(services *ServicesData) []*codegen.File { + if len(services.servicePlans) == 0 { return nil } var ( - data = make([]*cli.CommandData, 0, len(services.Root.API.GRPC.Services)) - svcs = make([]*expr.GRPCServiceExpr, 0, len(services.Root.API.GRPC.Services)) + data = make([]*commandData, 0, len(services.servicePlans)) + svcs = make([]*grpcServicePlan, 0, len(services.servicePlans)) ) - for _, svc := range services.Root.API.GRPC.Services { + for _, servicePlan := range services.servicePlans { + svc := servicePlan.expression if len(svc.GRPCEndpoints) == 0 { continue } sd := services.Get(svc.Name()) - command := cli.BuildCommandData(sd.Service, sd.ClientPkgName) + command := &commandData{ + CommandData: cli.BuildCommandData(sd.Service), + ClientInit: sd.ClientInitDeclaration, + } for index, e := range sd.Endpoints { flags, buildFunction := buildFlags(e, services.cliPlan.builders[svc.GRPCEndpoints[index]]) subcmd := cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags) - command.Subcommands = append(command.Subcommands, subcmd) + command.CommandData.Subcommands = append(command.CommandData.Subcommands, subcmd) } command.Example = command.Subcommands[0].Example data = append(data, command) - svcs = append(svcs, svc) + svcs = append(svcs, servicePlan) } - files := make([]*codegen.File, 0, len(services.Root.API.Servers)+len(svcs)) - for _, svr := range services.Root.API.Servers { - files = append(files, endpointParser(services, svr, data)) + files := make([]*codegen.File, 0, len(services.cliPlan.servers)+len(svcs)) + for _, serverPlan := range services.cliPlan.servers { + serverData := make([]*commandData, 0, len(serverPlan.expression.Services)) + for _, serviceName := range serverPlan.expression.Services { + for _, command := range data { + if command.ServiceName == serviceName { + serverData = append(serverData, command) + break + } + } + } + files = append(files, endpointParser(services, serverPlan, serverData)) } for i, svc := range svcs { - files = append(files, payloadBuilders(svc, data[i], services)) + files = append(files, payloadBuilders(svc, data[i].CommandData, services)) } return files } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.CommandData) *codegen.File { +func endpointParser(services *ServicesData, serverPlan *grpcCLIServerPlan, data []*commandData) *codegen.File { genpkg := services.GenPkg() - pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) + pkg := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPackage := path.Join(genpkg, "grpc", "cli", pkg) fpath := filepath.Join(codegen.Gendir, "grpc", "cli", pkg, "cli.go") - title := svr.Name + " gRPC client CLI support package" + title := serverPlan.name + " gRPC client CLI support package" specs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "flag"}, @@ -67,8 +89,9 @@ func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.Co } // Add structpb import if Any type is used needsAnyPb := false - for _, svc := range services.Root.API.GRPC.Services { - if usesAnyType(svc.GRPCEndpoints, false) { + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(services.servicePlans, serviceName) + if servicePlan != nil && servicePlan.usesAny { needsAnyPb = true break } @@ -78,32 +101,46 @@ func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.Co &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - for _, svc := range services.Root.API.GRPC.Services { + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(services.servicePlans, serviceName) + if servicePlan == nil { + continue + } + svc := servicePlan.expression sd := services.Get(svc.Name()) if sd == nil { continue } svcName := sd.Service.PathName specs = append(specs, - services.PackageImport(path.Join(genpkg, "grpc", svcName, "client")), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName))) + services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, "client")), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName))) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, services.ServiceImport(svc.Name())) + specs = append(specs, services.ServiceImport(outputPackage, svc.Name())) } } - parser := services.cliPlan.parsers[svr] + parser := serverPlan.parser if parser == nil { - panic(fmt.Sprintf("gRPC command parser names are missing for server %q", svr.Name)) + panic(fmt.Sprintf("gRPC command parser names are missing for server %q", serverPlan.name)) } - plannedData := make([]*cli.CommandData, len(data)) + plannedData := make([]*commandData, len(data)) + plannedCommands := make([]*cli.CommandData, len(data)) for index, command := range data { commandNames := parser.Commands[command.ServiceName] if commandNames == nil { panic(fmt.Sprintf("gRPC command names are missing for service %q", command.ServiceName)) } - commandCopy := *command + commandCopy := *command.CommandData + sd := services.Get(command.ServiceName) + clientPath := path.Join(genpkg, "grpc", sd.Service.PathName, "client") + commandCopy.PkgName = services.PackageImport(outputPackage, clientPath).Name + if command.Interceptors != nil { + interceptors := *command.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.ServiceName).Name + commandCopy.Interceptors = &interceptors + } commandCopy.UsageDeclaration = commandNames.Usage commandCopy.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) for methodIndex, subcommand := range command.Subcommands { @@ -115,29 +152,38 @@ func endpointParser(services *ServicesData, svr *expr.ServerExpr, data []*cli.Co subcommandCopy.UsageDeclaration = usage commandCopy.Subcommands[methodIndex] = &subcommandCopy } - plannedData[index] = &commandCopy + plannedData[index] = &commandData{ + CommandData: &commandCopy, + ClientInit: command.ClientInit, + } + plannedCommands[index] = &commandCopy } + parser.PlanVariables(plannedCommands, nil) parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint-grpc", Source: grpcTemplates.Read(grpcParseEndpointT), Data: struct { Declaration *codegen.NameDeclaration FlagsCode string - Commands []*cli.CommandData + Commands []*commandData + Variables *cli.ParserVariablesData }{ parser.Declarations.ParseEndpoint, - cli.FlagsCode(plannedData), + parser.FlagsCode(plannedCommands), plannedData, + parser.Variables, }, } - return cli.EndpointParserFile(fpath, title, specs, plannedData, parser.Declarations, parseSection) + return parser.EndpointParserFile(fpath, title, specs, plannedCommands, parseSection) } // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(svc *expr.GRPCServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(servicePlan *grpcServicePlan, data *cli.CommandData, services *ServicesData) *codegen.File { + svc := servicePlan.expression sd := services.Get(svc.Name()) svcName := sd.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath := filepath.Join(codegen.Gendir, "grpc", svcName, "client", "cli.go") title := svc.Name() + " gRPC client CLI support package" specs := []*codegen.ImportSpec{ @@ -146,16 +192,16 @@ func payloadBuilders(svc *expr.GRPCServiceExpr, data *cli.CommandData, services {Path: "strconv"}, {Path: "unicode/utf8"}, codegen.GoaImport(""), - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } // Add structpb import if Any type is used - if usesAnyType(svc.GRPCEndpoints, false) { + if servicePlan.usesAny { specs = append(specs, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), services, svc.GRPCEndpoints...) + return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), services, servicePlan) } func buildFlags(e *EndpointData, declaration *codegen.NameDeclaration) ([]*cli.FlagData, *cli.BuildFunctionData) { @@ -165,7 +211,7 @@ func buildFlags(e *EndpointData, declaration *codegen.NameDeclaration) ([]*cli.F if declaration == nil { panic(fmt.Sprintf("gRPC payload builder name is missing for %q.%q", e.ServiceName, e.Method.Name)) } - buildFunction.Declaration = declaration + buildFunction.Name = declaration.Name() } return flags, buildFunction } @@ -187,20 +233,20 @@ func makeFlags(e *EndpointData, args []*InitArgData) ([]*cli.FlagData, *cli.Buil fargs[i] = &cli.FlagArgData{ Name: arg.Name, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, } } var pinit *cli.PayloadInitData if e.Method.PayloadRef != "" && e.Request.ServerConvert != nil { pinit = &cli.PayloadInitData{ - Code: e.Request.ServerConvert.Init.Code, + Code: e.Request.CLIInitCode, ReturnIsStruct: e.Request.ServerConvert.Init.ReturnIsStruct, ReturnTypePkg: e.Request.ServerConvert.Init.ReturnTypePkg, Args: pInitArgs, diff --git a/grpc/codegen/client_cli_test.go b/grpc/codegen/client_cli_test.go index 416f0f5340..2aba391791 100644 --- a/grpc/codegen/client_cli_test.go +++ b/grpc/codegen/client_cli_test.go @@ -2,12 +2,13 @@ package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -23,7 +24,7 @@ func TestClientCLIFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientCLIFiles(services) + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[1].SectionTemplates) var buf bytes.Buffer @@ -35,3 +36,37 @@ func TestClientCLIFiles(t *testing.T) { }) } } + +// TestReleasedGRPCNamesMatchDeclarations verifies released plugins can read +// final method and validation names without choosing those names themselves. +func TestReleasedGRPCNamesMatchDeclarations(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithValidationsDSL) + services := CreateGRPCServices(root) + files := clientCLIFiles(services) + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + servicePlan := services.servicePlans[0] + declaration := services.cliPlan.builders[servicePlan.expression.GRPCEndpoints[0]] + require.NotNil(t, declaration) + require.Equal(t, declaration.Name(), build.Name) + + service := services.GRPCServices["PayloadWithValidation"] + require.NotNil(t, service) + require.NotEmpty(t, service.Endpoints) + endpoint := service.Endpoints[0] + require.Equal(t, endpoint.ProtoMethodName, endpoint.ClientMethodName) + + validationRoot := RunGRPCDSL(t, testdata.ElemValidationDSL) + validationService := CreateGRPCServices(validationRoot).GRPCServices["ServiceElemValidation"] + require.NotNil(t, validationService) + require.NotEmpty(t, validationService.Endpoints) + request := validationService.Endpoints[0].Request + require.NotNil(t, request) + require.NotNil(t, request.ServerConvert) + validation := request.ServerConvert.Validation + require.NotNil(t, validation) + require.NotNil(t, validation.Declaration) + require.Equal(t, validation.Declaration.Name(), validation.Name) +} diff --git a/grpc/codegen/client_test.go b/grpc/codegen/client_test.go index 7bb591001b..55f965f747 100644 --- a/grpc/codegen/client_test.go +++ b/grpc/codegen/client_test.go @@ -32,7 +32,7 @@ func TestClientEndpointInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles(services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("client-endpoint-init") if len(sections) == 0 { @@ -64,7 +64,7 @@ func TestRequestEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles(services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-encoder") require.NotEmpty(t, sections) @@ -95,7 +95,7 @@ func TestResponseDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles(services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-decoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/client_types_test.go b/grpc/codegen/client_types_test.go index 083097add3..b1d315a5f7 100644 --- a/grpc/codegen/client_types_test.go +++ b/grpc/codegen/client_types_test.go @@ -1,16 +1,147 @@ +// This file checks the generated client and server conversion functions. package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/grpc/codegen/testdata" ) +// TestTypeFilesShareRepeatedConversions checks that two endpoints using the +// same payload call one conversion function in each generated package. +func TestTypeFilesShareRepeatedConversions(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Equal(t, 1, strings.Count(client, "func NewProtoDupePayload(")) + require.Equal(t, 1, strings.Count(server, "func NewDupePayload(")) + require.NotContains(t, server, "func NewMethodPayloadDuplicateAPayload(") + require.NotContains(t, server, "func NewMethodPayloadDuplicateBPayload(") +} + +// TestCLIConversionsShareTypePair checks that command-line payload builders +// for the same protobuf message and Goa type keep the same conversion plan. +func TestCLIConversionsShareTypePair(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + first := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].cliPayload + second := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].cliPayload + + require.Same(t, first, second) +} + +// TestRequestMetadataKeepsDistinctConversions checks that the same Goa type +// gets separate constructors when endpoint metadata produces different +// protobuf messages and different function arguments. +func TestRequestMetadataKeepsDistinctConversions(t *testing.T) { + root := RunGRPCDSL(t, func() { + payload := dsl.Type("SharedPayload", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "token", dsl.String) + }) + dsl.Service("MetadataConversions", func() { + dsl.Method("Plain", func() { + dsl.Payload(payload) + dsl.GRPC(func() {}) + }) + dsl.Method("WithMetadata", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + plain := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].serverInits[grpcInitKey{role: grpcRequestInit}] + metadata := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].serverInits[grpcInitKey{role: grpcRequestInit}] + + require.NotSame(t, plain, metadata) + require.NotSame(t, plain.declaration, metadata.declaration) +} + +// TestOneUseConversionsKeepReleasedNames checks that conversions used by one +// method keep the names generated by released Goa versions. +func TestOneUseConversionsKeepReleasedNames(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMixedAttributesDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewUnaryMethodPayload(") + require.Contains(t, server, "func NewStreamingMethodStreamingRequestAPayload(") + require.NotContains(t, server, "func NewAPayloadFromProto") +} + +// TestReleasedConversionNameCollisionsUseDeclaredNames checks that two old +// names which become equal get stable suffixes in definitions and calls. +func TestReleasedConversionNameCollisionsUseDeclaredNames(t *testing.T) { + root := RunGRPCDSL(t, func() { + first := dsl.Type("FirstPayload", func() { + dsl.Field(1, "first", dsl.String) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Field(1, "second", dsl.String) + }) + dsl.Service("CollidingConversions", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(first) + dsl.GRPC(func() {}) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(second) + dsl.GRPC(func() {}) + }) + }) + }) + services := CreateGRPCServices(root) + serverTypes := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverFiles(services)[1].Section("request-decoder")) + + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload(")) + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload2(")) + require.Contains(t, server, "NewFooBarPayload(") + require.Contains(t, server, "NewFooBarPayload2(") +} + +// TestLegacyMetadataConversionKeepsReleasedName checks that legacy request +// metadata conversion keeps its released method-specific name. +func TestLegacyMetadataConversionKeepsReleasedName(t *testing.T) { + root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewMethodBidirectionalStreamingRPCWithPayloadLegacyCompatPayloadFromMetadata(") +} + +// TestTransformHelperNamesDescribeNestedTypes checks that each helper name +// identifies the nested value it converts and its direction. +func TestTransformHelperNamesDescribeNestedTypes(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithNestedTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, client, "func transformAParamsToProtoAParams(") + require.Contains(t, client, "func transformBParamsToProtoBParams(") + require.Contains(t, server, "func transformProtoAParamsToAParams(") + require.Contains(t, server, "func transformProtoBParamsToBParams(") + require.NotRegexp(t, `func transform\w+\d+\(`, client) + require.NotRegexp(t, `func transform\w+\d+\(`, server) +} + func TestClientTypeFiles(t *testing.T) { cases := []struct { Name string @@ -26,12 +157,15 @@ func TestClientTypeFiles(t *testing.T) { {"client-struct-meta-type", testdata.StructMetaTypeDSL}, {"client-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"client-default-fields", testdata.DefaultFieldsDSL}, + {"client-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"client-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"client-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientTypeFiles(services) + fs := clientTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/compatibility.go b/grpc/codegen/compatibility.go new file mode 100644 index 0000000000..beffc37d10 --- /dev/null +++ b/grpc/codegen/compatibility.go @@ -0,0 +1,59 @@ +// This file keeps released gRPC generator entry points available to plugins +// while all rendering uses the one service plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create services. +func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientFiles(services) +} + +// ClientCLIFiles returns the planned command-line client files. genpkg must +// match the package used to create services. +func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientCLIFiles(services) +} + +// ProtoFiles returns the planned protobuf files. genpkg must match the package +// used to create services. +func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return protoFiles(services) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create services. +func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverFiles(services) +} + +// ServerTypeFiles returns the planned server conversion files. genpkg must +// match the package used to create services. +func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverTypeFiles(services) +} + +// ClientTypeFiles returns the planned client conversion files. genpkg must +// match the package used to create services. +func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientTypeFiles(services) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the service data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, services *ServicesData) { + if genpkg != services.GenPkg() { + panic(fmt.Sprintf("gRPC generation package %q does not match planned package %q", genpkg, services.GenPkg())) + } +} diff --git a/grpc/codegen/compatibility_test.go b/grpc/codegen/compatibility_test.go new file mode 100644 index 0000000000..7fb4f18d1b --- /dev/null +++ b/grpc/codegen/compatibility_test.go @@ -0,0 +1,39 @@ +// This file pins released gRPC generator entry points that plugins call after +// Goa has built the service data for one generated package. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ProtoFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles +) + +// TestReleasedFileFunctionsUsePlannedPackage checks that the compatibility +// entry points render one retained plan and reject a different package. +func TestReleasedFileFunctionsUsePlannedPackage(t *testing.T) { + services := CreateGRPCServices(RunGRPCDSL(t, testdata.UnaryRPCsDSL)) + genpkg := services.GenPkg() + require.Len(t, ClientFiles(genpkg, services), len(clientFiles(services))) + require.Len(t, ClientCLIFiles(genpkg, services), len(clientCLIFiles(services))) + require.Len(t, ProtoFiles(genpkg, services), len(protoFiles(services))) + require.Len(t, ServerFiles(genpkg, services), len(serverFiles(services))) + require.Len(t, ServerTypeFiles(genpkg, services), len(serverTypeFiles(services))) + require.Len(t, ClientTypeFiles(genpkg, services), len(clientTypeFiles(services))) + require.PanicsWithValue( + t, + `gRPC generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { ClientFiles("other.local/gen", services) }, + ) +} diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index c8f7e1eb42..4ac8c683dd 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -1,49 +1,47 @@ -// This file renders runnable gRPC client examples whose generated CLI and -// interceptor imports use the qualifiers selected during planning. +// This file writes runnable gRPC command-line examples with the package names +// already chosen for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example gRPC client tool implementation. -func ExampleCLIFiles(services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example gRPC client tool implementation. +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := exampleCLI(services, svr); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(services, server); f != nil { files = append(files, f) } } return files } -// exampleCLI returns an example gRPC client tool for the given server -// expression. -func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { +// exampleCLI writes the gRPC command-line program for server. +func exampleCLI(services *ServicesData, server *example.Data) *codegen.File { genpkg := services.GenPkg() - svrdata := example.Servers.Get(svr, services.Root) - mainPath := filepath.Join("cmd", svrdata.Dir+"-cli", "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } + mainPath := filepath.Join("cmd", server.Dir+"-cli", "grpc.go") rootPath := path.Dir(genpkg) - cliImport := services.PackageImport(path.Join(genpkg, "grpc", "cli", svrdata.Dir)) - parser := services.cliPlan.parsers[svr] + outputPackage := path.Join(rootPath, "cmd", server.Dir+"-cli") + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", "cli", server.Dir)) + parser := services.cliPlan.parser(server.Name) if parser == nil { - panic("gRPC command parser names are missing for server " + svr.Name) + panic("gRPC command parser names are missing for server " + server.Name) } specs := []*codegen.ImportSpec{ {Path: "context"}, - {Path: "encoding/json"}, + {Path: "errors"}, {Path: "flag"}, {Path: "fmt"}, + {Path: "io"}, {Path: "google.golang.org/grpc"}, {Path: "google.golang.org/grpc/credentials/insecure"}, {Path: "os"}, @@ -55,15 +53,21 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { var svcData []*ServiceData hasClientInterceptors := false - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + svcData = append(svcData, services.exampleServiceData(data, outputPackage, false)) hasClientInterceptors = hasClientInterceptors || len(data.Service.ClientInterceptors) > 0 + for _, endpoint := range data.Endpoints { + if cliStreamsOutput(endpoint.Method) { + specs = append(specs, services.ServiceImport(outputPackage, svc)) + break + } + } } } var interceptorsPkg string if hasClientInterceptors { - interceptorImport := services.PackageImport(rootPath + "/interceptors") + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") interceptorsPkg = interceptorImport.Name specs = append(specs, interceptorImport) } @@ -74,14 +78,89 @@ func exampleCLI(services *ServicesData, svr *expr.ServerExpr) *codegen.File { Name: "do-grpc-cli", Source: grpcTemplates.Read(grpcDoGRPCCLIT), Data: map[string]any{ - "DefaultTransport": svrdata.DefaultTransport(), + "DefaultTransport": server.DefaultTransport(), "Services": svcData, "InterceptorsPkg": interceptorsPkg, "CLIPkg": cliImport.Name, "Parser": parser.Declarations, }, + FuncMap: map[string]any{ + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, + }, }, } return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} } + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// parser returns the command parser saved for the named server. +func (p *grpcCLIPlan) parser(serverName string) *cli.ParserPlan { + for _, server := range p.servers { + if server.name == serverName { + return server.parser + } + } + return nil +} diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index cddf5638e8..e5c8c2f70d 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" @@ -27,14 +26,15 @@ func TestExampleCLIFiles(t *testing.T) { {"server-hosting-service-subset-pkgpath", ctestdata.ServerHostingServiceSubsetDSL, "my/pkg/path"}, {"server-hosting-multiple-services-pkgpath", ctestdata.ServerHostingMultipleServicesDSL, "my/pkg/path"}, {"interceptors", testdata.InterceptorsDSL, "generated.local/gen"}, + {"server-streaming", testdata.ServerStreamingRPCDSL, "generated.local/gen"}, + {"client-streaming", testdata.ClientStreamingRPCDSL, "generated.local/gen"}, + {"bidirectional-streaming", testdata.BidirectionalStreamingRPCDSL, "generated.local/gen"}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := createServiceServicesForPackage(root, c.PkgPath) - fs := ExampleCLIFiles(services) + examples := createExamplePlan(root, c.PkgPath) + fs := examples.CLIFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index 2f072ca6d4..607c6efcb0 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -1,23 +1,20 @@ -// This file renders runnable gRPC servers whose generated service, transport, -// protobuf, and application imports use the qualifiers selected during -// planning. +// This file writes runnable gRPC servers with the package names already chosen +// for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example gRPC server implementation. -func ExampleServerFiles(services *ServicesData) []*codegen.File { +// exampleServerFiles returns an example gRPC server implementation. +func exampleServerFiles(root *example.Root, services *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range services.Root.API.Servers { - if m := exampleServer(services, svr); m != nil { + for _, server := range root.Servers { + if m := exampleServer(services, server); m != nil { fw = append(fw, m) } } @@ -25,17 +22,13 @@ func ExampleServerFiles(services *ServicesData) []*codegen.File { } // exampleServer returns an example gRPC server implementation. -func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { +func exampleServer(services *ServicesData, server *example.Data) *codegen.File { var ( mainPath string genpkg = services.GenPkg() - - svrdata = example.Servers.Get(svr, services.Root) ) - mainPath = filepath.Join("cmd", svrdata.Dir, "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } + mainPath = filepath.Join("cmd", server.Dir, "grpc.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) specs := []*codegen.ImportSpec{ {Path: "context"}, @@ -49,26 +42,29 @@ func exampleServer(services *ServicesData, svr *expr.ServerExpr) *codegen.File { {Path: "google.golang.org/grpc"}, {Path: "google.golang.org/grpc/reflection"}, } - for _, svc := range services.Root.API.GRPC.Services { - sd := services.Get(svc.Name()) + for _, serviceName := range server.Services { + sd := services.Get(serviceName) + if sd == nil { + continue + } svcName := sd.Service.PathName - serverImport := services.PackageImport(path.Join(genpkg, "grpc", svcName, "server")) - serviceImport := services.ServiceImport(svc.Name()) - protobufImport := services.PackageImport(path.Join(genpkg, "grpc", svcName, pbPkgName)) + serverImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, "server")) + serviceImport := services.ServiceImport(outputPackage, serviceName) + protobufImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, pbPkgName)) specs = append(specs, serverImport, serviceImport, protobufImport) } rootPath := path.Dir(genpkg) - apiImport := services.PackageImport(rootPath) + apiImport := services.PackageImport(outputPackage, rootPath) specs = append(specs, apiImport) var ( sections []*codegen.SectionTemplate ) var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + svcdata = append(svcdata, services.exampleServiceData(data, outputPackage, true)) } } sections = []*codegen.SectionTemplate{ diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index a2d5a89e05..d83a9abeed 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -4,11 +4,11 @@ package codegen import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/testutil" ) @@ -24,11 +24,9 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := createServiceServices(root) - fs := ExampleServerFiles(services) + examples := createExamplePlan(root, "generated.local/gen") + fs := examples.ServerFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -36,6 +34,12 @@ func TestExampleServerFiles(t *testing.T) { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + if strings.Contains(code, "GetServiceInfo") { + t.Errorf("generated server discovers methods at runtime:\n%s", code) + } + if !strings.Contains(code, "serving gRPC method") { + t.Errorf("generated server does not log its planned methods:\n%s", code) + } golden := filepath.Join("testdata", "server-"+c.Name+".golden") testutil.AssertGo(t, golden, code) }) diff --git a/grpc/codegen/idempotency_test.go b/grpc/codegen/idempotency_test.go index dbe96a3bac..af6a9089f9 100644 --- a/grpc/codegen/idempotency_test.go +++ b/grpc/codegen/idempotency_test.go @@ -15,14 +15,14 @@ func TestIdempotentRPCCodegen(t *testing.T) { root := RunGRPCDSL(t, testdata.IdempotentRPCsDSL) services := CreateGRPCServices(root) - protoFiles := ProtoFiles(services) + protoFiles := protoFiles(services) require.Len(t, protoFiles, 1) protoCode := sectionCode(t, protoFiles[0].SectionTemplates[1:]...) assert.Equal(t, 2, strings.Count(protoCode, "option idempotency_level = IDEMPOTENT;")) protoPath := codegen.CreateTempFile(t, protoCode) - assert.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, protoPath)) - clientFiles := ClientFiles(services) + clientFiles := clientFiles(services) require.Len(t, clientFiles, 2) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) assert.Contains(t, clientCode, `goa.RetryEndpoint(endpoint, "busy")`) diff --git a/grpc/codegen/import_plan.go b/grpc/codegen/import_plan.go new file mode 100644 index 0000000000..90e017be16 --- /dev/null +++ b/grpc/codegen/import_plan.go @@ -0,0 +1,388 @@ +// This file records every import in the generated gRPC package that writes +// the reference. Package-local planning keeps an unrelated transport or +// executable from changing a gRPC qualifier. +package codegen + +import ( + "path" + "path/filepath" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" +) + +// planGRPCImports records imports for each generated client, server, and +// command-parser package before Generation.Freeze chooses their names. +func planGRPCImports(generation *codegen.Generation, plan *Plan) error { + for _, servicePlan := range plan.servicesPlan { + service := servicePlan.expression + pathName := servicePlan.packages.pathName + clientPath := path.Join(generation.GenPkg(), "grpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "grpc", pathName, "server") + protobufPath := path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName) + + client := generation.Package(clientPath) + clientFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.GoaNamedImport("grpc/pb", "goapb"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + } + if len(service.GRPCEndpoints) > 0 { + clientFixed = append(clientFixed, + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + ) + } + if servicePlan.usesAny { + clientFixed = append(clientFixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(client, clientFixed); err != nil { + return err + } + if err := reservePackageImports(client, + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", protobufPath), + ); err != nil { + return err + } + if grpcServiceHasViewedResult(service) { + if err := client.ReserveGeneratedImport(servicePlan.packages.views); err != nil { + return err + } + } + if err := planGRPCAttributeImports(client, generation, grpcEndpointAttributes(service.GRPCEndpoints...)); err != nil { + return err + } + if err := requirePackageImports(client, servicePlan.protoGoImports); err != nil { + return err + } + + server := generation.Package(serverPath) + serverFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/codes"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + } + if grpcServiceStreamsPayload(service) { + serverFixed = append(serverFixed, codegen.SimpleImport("io")) + } + if grpcResponseMetadataUsesAny(service) { + serverFixed = append(serverFixed, codegen.SimpleImport("fmt")) + } + if servicePlan.usesAnyInErrors { + serverFixed = append(serverFixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(server, serverFixed); err != nil { + return err + } + if err := reservePackageImports(server, + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", protobufPath), + ); err != nil { + return err + } + if grpcServiceHasViewedResult(service) { + if err := server.ReserveGeneratedImport(servicePlan.packages.views); err != nil { + return err + } + } + if err := planGRPCAttributeImports(server, generation, grpcEndpointAttributes(service.GRPCEndpoints...)); err != nil { + return err + } + if err := requirePackageImports(server, servicePlan.protoGoImports); err != nil { + return err + } + } + + for _, serverPlan := range plan.cli.servers { + serverName := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPath := path.Join(generation.GenPkg(), "grpc", "cli", serverName) + output := generation.Package(outputPath) + fixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("os"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + } + if grpcServerPlansUseAny(plan.servicesPlan, serverPlan.expression.Services) { + fixed = append(fixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(output, fixed); err != nil { + return err + } + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + if err := reservePackageImports(output, + codegen.NewImport(servicePlan.packages.service.Name+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client")), + codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName)), + ); err != nil { + return err + } + if len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + if err := output.ReserveGeneratedImport(servicePlan.packages.service); err != nil { + return err + } + } + } + } + return nil +} + +// planGRPCExampleImports adds the gRPC files' imports to the executable +// packages already claimed by the shared example planner. +func planGRPCExampleImports(generation *codegen.Generation, plan *Plan, root *example.Root) error { + rootPath := path.Dir(generation.GenPkg()) + for _, server := range root.Servers { + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, filepath.Join("cmd", server.Dir)) + if err != nil { + return err + } + if err := requirePackageImports(serverPackage, []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("sync"), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/reflection"), + }); err != nil { + return err + } + for _, serviceName := range server.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + if err := reservePackageImports(serverPackage, + codegen.NewImport(servicePlan.packages.service.Name+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server")), + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName)), + ); err != nil { + return err + } + } + + if server.DefaultTransport() == nil { + continue + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, filepath.Join("cmd", server.Dir+"-cli")) + if err != nil { + return err + } + if err := requirePackageImports(clientPackage, []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("os"), + codegen.SimpleImport("time"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/credentials/insecure"), + }); err != nil { + return err + } + if err := clientPackage.ReserveGeneratedImport(codegen.NewImport( + "cli", + path.Join(generation.GenPkg(), "grpc", "cli", server.Dir), + )); err != nil { + return err + } + for _, serviceName := range server.Services { + service := plan.root.API.GRPC.Service(serviceName) + if service == nil { + continue + } + if grpcServiceStreamsResult(service) { + if err := clientPackage.ReserveGeneratedImport(plan.packages[service].service); err != nil { + return err + } + } + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan != nil && len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + if err := clientPackage.ReserveGeneratedImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { + return err + } + } + } + } + return nil +} + +func requirePackageImports(output *codegen.GeneratedPackage, imports []*codegen.ImportSpec) error { + for _, spec := range imports { + if err := output.RequireImport(spec); err != nil { + return err + } + } + return nil +} + +func reservePackageImports(output *codegen.GeneratedPackage, imports ...*codegen.ImportSpec) error { + for _, spec := range imports { + if err := output.ReserveGeneratedImport(spec); err != nil { + return err + } + } + return nil +} + +// planGRPCAttributeImports preserves authored metadata aliases while generated +// service types use a package-local preferred name. +func planGRPCAttributeImports(output *codegen.GeneratedPackage, generation *codegen.Generation, attributes []*expr.AttributeExpr) error { + seen := make(map[expr.UserType]struct{}) + var walk func(*expr.AttributeExpr) error + walk = func(attribute *expr.AttributeExpr) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := codegen.GetMetaType(attribute); spec != nil && spec.Path != output.ImportPath() { + if err := output.DeclareImport(spec); err != nil { + return err + } + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + importPath := path.Join(generation.GenPkg(), location.RelImportPath) + if importPath != output.ImportPath() { + preferred := strings.ToLower(codegen.Goify(path.Base(importPath), false)) + if err := output.ReserveGeneratedImport(codegen.NewImport(preferred, importPath)); err != nil { + return err + } + } + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return walk(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + if err := walk(named.Attribute); err != nil { + return err + } + } + case *expr.Array: + return walk(actual.ElemType) + case *expr.Map: + if err := walk(actual.KeyType); err != nil { + return err + } + return walk(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + if err := walk(named.Attribute); err != nil { + return err + } + } + } + return nil + } + for _, attribute := range attributes { + if err := walk(attribute); err != nil { + return err + } + } + return nil +} + +func grpcServiceHasViewedResult(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if _, ok := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); ok { + return true + } + } + return false +} + +func grpcServiceStreamsPayload(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsPayloadStreaming() && !isEmpty(endpoint.Request.Type) { + return true + } + } + return false +} + +func grpcServiceStreamsResult(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsResultStreaming() && !endpoint.MethodExpr.IsPayloadStreaming() { + return true + } + } + return false +} + +// grpcServerPlansUseAny reports whether one server's generated command parser +// handles a service with protobuf Any fields. +func grpcServerPlansUseAny(services []*grpcServicePlan, names []string) bool { + for _, name := range names { + service := grpcServicePlanByName(services, name) + if service != nil && service.usesAny { + return true + } + } + return false +} + +func grpcResponseMetadataUsesAny(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + for _, metadata := range []*expr.MappedAttributeExpr{endpoint.Response.Headers, endpoint.Response.Trailers} { + if metadata == nil { + continue + } + for _, named := range *expr.AsObject(metadata.Type) { + typeKind := named.Attribute.Type.Kind() + if array := expr.AsArray(named.Attribute.Type); array != nil { + typeKind = array.ElemType.Type.Kind() + } + if typeKind == expr.AnyKind { + return true + } + } + } + } + return false +} + +func grpcServicePlanByName(services []*grpcServicePlan, name string) *grpcServicePlan { + for _, service := range services { + if service.expression.Name() == name { + return service + } + } + return nil +} diff --git a/grpc/codegen/metadata_specialization_test.go b/grpc/codegen/metadata_specialization_test.go new file mode 100644 index 0000000000..d019a82b01 --- /dev/null +++ b/grpc/codegen/metadata_specialization_test.go @@ -0,0 +1,107 @@ +// This file verifies that gRPC metadata uses the exact string conversion for +// each primitive type selected by the design. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestMetadataEncodingSpecializesPrimitiveFormatting(t *testing.T) { + root := expr.RunDSL(t, func() { + alias := dsl.Type("Count", dsl.Int) + fields := dsl.Type("Fields", func() { + dsl.Field(1, "boolean", dsl.Boolean) + dsl.Field(2, "integer", dsl.Int) + dsl.Field(3, "small", dsl.Int32) + dsl.Field(4, "large", dsl.Int64) + dsl.Field(5, "unsigned", dsl.UInt) + dsl.Field(6, "unsigned_small", dsl.UInt32) + dsl.Field(7, "unsigned_large", dsl.UInt64) + dsl.Field(8, "ratio", dsl.Float32) + dsl.Field(9, "score", dsl.Float64) + dsl.Field(10, "text", dsl.String) + dsl.Field(11, "bytes", dsl.Bytes) + dsl.Field(12, "count", alias) + dsl.Field(13, "booleans", dsl.ArrayOf(dsl.Boolean)) + dsl.Field(14, "dynamic", dsl.Any) + dsl.Field(15, "dynamic_values", dsl.ArrayOf(dsl.Any)) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(fields) + dsl.Result(fields) + dsl.GRPC(func() { + dsl.Metadata(func() { + metadataFields() + }) + dsl.Response(func() { + dsl.Headers(func() { + metadataFields() + }) + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + generatedClientFiles := clientFiles(services) + generatedServerFiles := serverFiles(services) + request := codegen.SectionsCode(t, generatedClientFiles[1].Section("request-encoder")) + response := codegen.SectionsCode(t, generatedServerFiles[1].Section("response-encoder")) + for _, generated := range []string{request, response} { + require.Contains(t, generated, "strconv.FormatBool(booleanWire)") + require.Contains(t, generated, "strconv.Itoa(integerWire)") + require.Contains(t, generated, "strconv.FormatInt(int64(smallWire), 10)") + require.Contains(t, generated, "strconv.FormatInt(largeWire, 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedSmallWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(unsignedLargeWire, 10)") + require.Contains(t, generated, "strconv.FormatFloat(float64(ratioWire), 'f', -1, 32)") + require.Contains(t, generated, "strconv.FormatFloat(scoreWire, 'f', -1, 64)") + require.Contains(t, generated, `Append("text", textWire)`) + require.Contains(t, generated, `Append("bytes", string(bytesWire))`) + require.Contains(t, generated, "strconv.Itoa(countWire)") + require.Contains(t, generated, "strconv.FormatBool(value)") + require.Contains(t, generated, `fmt.Sprintf("%v", dynamicWire)`) + require.Contains(t, generated, `fmt.Sprintf("%v", value)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", integerWire)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", booleanWire)`) + } + require.Contains(t, sectionCode(t, generatedClientFiles[1].SectionTemplates[0]), `"fmt"`) + require.Contains(t, sectionCode(t, generatedServerFiles[1].SectionTemplates[0]), `"fmt"`) + + withoutAny := CreateGRPCServices(RunGRPCDSL(t, testdata.MessageWithMetadataDSL)) + require.NotContains(t, sectionCode(t, clientFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) + require.NotContains(t, sectionCode(t, serverFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) +} + +// metadataFields maps every test field to a metadata key with the same name. +func metadataFields() { + for _, name := range []string{ + "boolean", + "integer", + "small", + "large", + "unsigned", + "unsigned_small", + "unsigned_large", + "ratio", + "score", + "text", + "bytes", + "count", + "booleans", + "dynamic", + "dynamic_values", + } { + dsl.Attribute(name) + } +} diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 624ca0b336..151b9562af 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -55,7 +55,7 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { target.Type.Name(), testGRPCMessageExampleIdentity("anonymous-user-union"), ) - freezeProtoBufTransformMessages(sd, source) + freezeProtoBufTransformMessages(t, sd, source) pbCtx := protoBufTypeContext("proto", sd, true) code, _, err := protoBufTransform(source, target, "source", "target", pbCtx, svcCtx, false, true) diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index 2e33ac46ec..5d2d85094b 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -28,7 +28,7 @@ func TestParseEndpointWithInterceptors(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := createServiceServicesForPackage(root, "generated.local/gen") - fs := ClientCLIFiles(services) + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) var buf bytes.Buffer diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go index dd068f962d..a3fd71dcec 100644 --- a/grpc/codegen/plan.go +++ b/grpc/codegen/plan.go @@ -1,14 +1,14 @@ -// This file retains one gRPC design, its chosen Go names, and every generated -// file from planning through rendering. +// This file stores one gRPC design, its chosen Go names, and every file built +// from it. package codegen import ( "fmt" "path" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -22,21 +22,32 @@ type ( Service *service.Plan } - // Plan retains one design root and every gRPC file built from it. + // Plan stores one design and every gRPC file built from it. Plan struct { - generation *codegen.Generation - root *expr.RootExpr - service *service.Plan - cli *grpcCLIPlan - services *ServicesData - proto []*codegen.File - server []*codegen.File - client []*codegen.File - serverType []*codegen.File - clientType []*codegen.File - clientCLI []*codegen.File - example []*codegen.File - exampleCLI []*codegen.File + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + cli *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + packages map[*expr.GRPCServiceExpr]*grpcServicePackage + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + expressions []*expr.GRPCServiceExpr + servicesPlan []*grpcServicePlan + services *ServicesData + proto []*codegen.File + server []*codegen.File + client []*codegen.File + serverType []*codegen.File + clientType []*codegen.File + clientCLI []*codegen.File + } + + // ExamplePlan builds runnable gRPC programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan } // grpcCLIPlan contains the command parser and payload function names for one @@ -44,12 +55,47 @@ type ( grpcCLIPlan struct { parsers map[*expr.ServerExpr]*cli.ParserPlan builders map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration + servers []*grpcCLIServerPlan + } + + // grpcCLIServerPlan stores one server name and the command parser declared + // for that server before generated files are built. + grpcCLIServerPlan struct { + expression *expr.ServerExpr + name string + parser *cli.ParserPlan + } + + // grpcServicePackage stores the generated service import and the directory + // used by every gRPC package for that service. + grpcServicePackage struct { + service *codegen.ImportSpec + views *codegen.ImportSpec + pathName string } ) -// NewPlans reads every service design in generation and retains one plan for -// each input. It chooses shared package names before generation freezes. +// NewPlans reads every service design and stores one plan for each input. It +// chooses all shared package names before files are built. func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, systemProtobufTools(), inputs...) +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.service) + if !ok { + return nil, fmt.Errorf("gRPC examples require server data created from the same service design") + } + if err := planGRPCExampleImports(transport.generation, transport, root); err != nil { + return nil, err + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// newPlans lets tests provide fixed protobuf executable paths and versions. +func newPlans(generation *codegen.Generation, resolver protobufToolResolver, inputs ...PlanInput) ([]*Plan, error) { owned := make(map[*expr.RootExpr]struct{}) for _, candidate := range generation.Roots() { if root, ok := candidate.(*expr.RootExpr); ok && len(root.API.GRPC.Services) > 0 { @@ -72,20 +118,69 @@ func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, err } seen[input.Root] = struct{}{} } - if err := requireGRPCImports(generation); err != nil { + toolPlans, err := planProtobufTools(inputs, resolver) + if err != nil { return nil, err } plans := make([]*Plan, len(inputs)) for index, input := range inputs { - cliPlan, err := planGRPCCLI(generation, input) + packages, err := planGRPCServicePackages(input) if err != nil { return nil, err } + cliPlan, err := planGRPCCLI(generation, input, packages) + if err != nil { + return nil, err + } + tools := make(map[*expr.GRPCServiceExpr]*protobufToolPlan, len(input.Root.API.GRPC.Services)) + for _, grpcService := range input.Root.API.GRPC.Services { + tools[grpcService] = toolPlans[grpcService] + } plans[index] = &Plan{ - generation: generation, - root: input.Root, - service: input.Service, - cli: cliPlan, + generation: generation, + root: input.Root, + service: input.Service, + cli: cliPlan, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: packages, + tools: tools, + symbols: make(map[*expr.GRPCServiceExpr]*grpcSymbols), + expressions: append([]*expr.GRPCServiceExpr(nil), input.Root.API.GRPC.Services...), + } + } + if err := planProtobufServices(generation, plans); err != nil { + return nil, err + } + conversions := make(map[grpcConversionKey]*grpcConversion) + var helpers []*grpcTransform + for _, plan := range plans { + input := PlanInput{Root: plan.root, Service: plan.service} + for _, grpcService := range plan.expressions { + pathName := plan.packages[grpcService].pathName + symbols, err := collectGRPCSymbols(generation, input, grpcService, pathName) + if err != nil { + return nil, err + } + if err := planGRPCValidations(generation, input, grpcService, plan.protobuf[grpcService], pathName); err != nil { + return nil, err + } + if err := planGRPCTransforms(generation, input, grpcService, plan.protobuf[grpcService], symbols, conversions, &helpers, pathName); err != nil { + return nil, err + } + plan.symbols[grpcService] = symbols + } + } + if err := declareGRPCTransforms(conversions, helpers); err != nil { + return nil, err + } + for _, plan := range plans { + servicesPlan, err := collectGRPCServicePlans(generation, plan) + if err != nil { + return nil, err + } + plan.servicesPlan = servicesPlan + if err := planGRPCImports(generation, plan); err != nil { + return nil, err } } return plans, nil @@ -106,8 +201,16 @@ func (p *Plan) Service() *service.Plan { return p.service } -// Link builds the gRPC render data and files after all names are frozen. The -// service plan must already be linked. +// ServiceData returns the finalized gRPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) ServiceData(service *expr.GRPCServiceExpr) (*ServiceData, bool) { + p.requireLinked() + data, ok := p.services.serviceByExpr[service] + return data, ok +} + +// Link builds the gRPC files after all generated Go names are fixed. The +// service plan must already have built its files. func (p *Plan) Link() error { if !p.generation.Frozen() { return fmt.Errorf("gRPC plan cannot link before generation freeze") @@ -116,18 +219,13 @@ func (p *Plan) Link() error { return fmt.Errorf("gRPC plan is already linked") } services := newServicesData(p.service.Services(), p) - for _, grpcService := range p.root.API.GRPC.Services { - services.Get(grpcService.Name()) - } p.services = services - p.proto = ProtoFiles(services) - p.server = ServerFiles(services) - p.client = ClientFiles(services) - p.serverType = ServerTypeFiles(services) - p.clientType = ClientTypeFiles(services) - p.clientCLI = ClientCLIFiles(services) - p.example = ExampleServerFiles(services) - p.exampleCLI = ExampleCLIFiles(services) + p.proto = protoFiles(services) + p.server = serverFiles(services) + p.client = clientFiles(services) + p.serverType = serverTypeFiles(services) + p.clientType = clientTypeFiles(services) + p.clientCLI = clientCLIFiles(services) return nil } @@ -167,74 +265,27 @@ func (p *Plan) ClientCLIFiles() []*codegen.File { return p.clientCLI } -// ExampleServerFiles returns the runnable gRPC server files built by Link. -func (p *Plan) ExampleServerFiles() []*codegen.File { - p.requireLinked() - return p.example -} - -// ExampleCLIFiles returns the runnable gRPC client files built by Link. -func (p *Plan) ExampleCLIFiles() []*codegen.File { - p.requireLinked() - return p.exampleCLI +// ServerFiles builds runnable gRPC servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) } -// requireGRPCImports records packages used by gRPC files before names freeze. -func requireGRPCImports(generation *codegen.Generation) error { - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("context"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("errors"), - codegen.SimpleImport("flag"), - codegen.SimpleImport("fmt"), - codegen.SimpleImport("io"), - codegen.SimpleImport("net"), - codegen.SimpleImport("net/url"), - codegen.SimpleImport("os"), - codegen.SimpleImport("strconv"), - codegen.SimpleImport("strings"), - codegen.SimpleImport("sync"), - codegen.SimpleImport("time"), - codegen.SimpleImport("unicode/utf8"), - codegen.SimpleImport("goa.design/clue/debug"), - codegen.SimpleImport("goa.design/clue/log"), - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - codegen.GoaNamedImport("grpc/pb", "goapb"), - codegen.SimpleImport("google.golang.org/grpc"), - codegen.SimpleImport("google.golang.org/grpc/codes"), - codegen.SimpleImport("google.golang.org/grpc/credentials/insecure"), - codegen.SimpleImport("google.golang.org/grpc/metadata"), - codegen.SimpleImport("google.golang.org/grpc/reflection"), - codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb"), - } - for _, spec := range imports { - if err := generation.RequireImport(spec); err != nil { - return err - } - } - return nil +// CLIFiles builds runnable gRPC clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) } // planGRPCCLI chooses parser and payload builder names for one design. -func planGRPCCLI(generation *codegen.Generation, input PlanInput) (*grpcCLIPlan, error) { +func planGRPCCLI(generation *codegen.Generation, input PlanInput, packages map[*expr.GRPCServiceExpr]*grpcServicePackage) (*grpcCLIPlan, error) { design := input.Root plan := &grpcCLIPlan{ parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), } for _, grpcService := range design.API.GRPC.Services { - pathName := codegen.SnakeCase(codegen.Goify(grpcService.Name(), false)) - packageName := strings.ToLower(codegen.Goify(grpcService.Name(), false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client"))); err != nil { - return nil, err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server"))); err != nil { - return nil, err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName))); err != nil { - return nil, err - } + pathName := packages[grpcService].pathName clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) if err != nil { return nil, err @@ -259,15 +310,16 @@ func planGRPCCLI(generation *codegen.Generation, input PlanInput) (*grpcCLIPlan, } for _, server := range design.API.Servers { serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) - if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "grpc", "cli", serverName))); err != nil { - return nil, err - } serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) if err != nil { return nil, err } var commands []cli.CommandDeclarationInput - for _, grpcService := range design.API.GRPC.Services { + for _, serviceName := range server.Services { + grpcService := design.API.GRPC.Service(serviceName) + if grpcService == nil { + continue + } if len(grpcService.GRPCEndpoints) == 0 { continue } @@ -282,10 +334,33 @@ func planGRPCCLI(generation *codegen.Generation, input PlanInput) (*grpcCLIPlan, return nil, err } plan.parsers[server] = parser + plan.servers = append(plan.servers, &grpcCLIServerPlan{ + expression: server, + name: server.Name, + parser: parser, + }) } return plan, nil } +// planGRPCServicePackages records the exact service imports before generated +// package names become final. Every later gRPC planning step uses these paths. +func planGRPCServicePackages(input PlanInput) (map[*expr.GRPCServiceExpr]*grpcServicePackage, error) { + packages := make(map[*expr.GRPCServiceExpr]*grpcServicePackage, len(input.Root.API.GRPC.Services)) + for _, transportService := range input.Root.API.GRPC.Services { + serviceImport, viewsImport, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + packages[transportService] = &grpcServicePackage{ + service: serviceImport, + views: viewsImport, + pathName: path.Base(serviceImport.Path), + } + } + return packages, nil +} + // requireLinked stops file reads before Link stores the files. func (p *Plan) requireLinked() { if p.services == nil { diff --git a/grpc/codegen/plan_retention_test.go b/grpc/codegen/plan_retention_test.go new file mode 100644 index 0000000000..82d31b9880 --- /dev/null +++ b/grpc/codegen/plan_retention_test.go @@ -0,0 +1,210 @@ +// This file proves gRPC files use the service and endpoint values saved by +// NewPlans even when a caller changes the evaluated design before Link. +package codegen + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +type grpcPlanRetentionFixture struct { + root *expr.RootExpr + service *expr.GRPCServiceExpr + endpoint *expr.GRPCEndpointExpr + method *expr.MethodExpr +} + +// TestGRPCPlanIgnoresDesignChangesAfterPlanning checks endpoint membership, +// messages, metadata, validation, and streaming decisions separately. +func TestGRPCPlanIgnoresDesignChangesAfterPlanning(t *testing.T) { + baselineFixture := grpcPlanRetentionDSL(t) + baseline := renderGRPCPlanRetentionFixture(t, baselineFixture, nil) + + tests := []struct { + name string + mutate func(*grpcPlanRetentionFixture) + }{ + {"service membership", func(f *grpcPlanRetentionFixture) { + f.root.API.GRPC.Services = nil + }}, + {"server membership", func(f *grpcPlanRetentionFixture) { + f.root.API.Servers = nil + }}, + {"endpoint membership", func(f *grpcPlanRetentionFixture) { + f.service.GRPCEndpoints = append(f.service.GRPCEndpoints, &expr.GRPCEndpointExpr{}) + }}, + {"request messages", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Type = expr.Empty + f.endpoint.StreamingRequest.Type = expr.Empty + }}, + {"response message and metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Response.Message.Type = expr.Empty + f.endpoint.Response.Headers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.Trailers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.StatusCode = 13 + }}, + {"request metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Metadata = expr.NewEmptyMappedAttributeExpr() + }}, + {"validation", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Validation.Required = nil + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Validation.MinLength = nil + }}, + {"imports", func(f *grpcPlanRetentionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Meta["struct:field:type"] = []string{"time.Time", "time"} + }}, + {"streaming method", func(f *grpcPlanRetentionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Type = expr.Empty + f.method.StreamingResult.Type = expr.Empty + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + actual := renderGRPCPlanRetentionFixture(t, fixture, test.mutate) + require.Equal(t, baseline, actual) + }) + } +} + +// TestGRPCPlanCopiesMissingStreamingResult checks that a unary method keeps +// its allowed nil streaming result when NewPlans copies the method. +func TestGRPCPlanCopiesMissingStreamingResult(t *testing.T) { + root := grpcPlanRoots(t, "Unary")[0] + require.Nil(t, root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.StreamingResult) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.Nil(t, plans[0].servicesPlan[0].expression.GRPCEndpoints[0].MethodExpr.StreamingResult) +} + +// TestGRPCPlanCopiesMethodErrors checks that each copied gRPC error uses the +// matching copied method error, including Goa's built-in error value. +func TestGRPCPlanCopiesMethodErrors(t *testing.T) { + root := RunGRPCDSL(t, testdata.UnaryRPCWithErrorsDSL) + service, err := copyGRPCService(root.API.GRPC.Services[0]) + require.NoError(t, err) + endpoint := service.expression.GRPCEndpoints[0] + for _, grpcError := range endpoint.GRPCErrors { + require.Same(t, endpoint.MethodExpr.Error(grpcError.Name), grpcError.ErrorExpr) + } + require.Same(t, expr.ErrorResult, endpoint.MethodExpr.Error("timeout").Type) +} + +// TestGRPCPlanKeepsEmptyPayloadResponseConversion checks that a method without +// a payload still saves and renders its result conversion. +func TestGRPCPlanKeepsEmptyPayloadResponseConversion(t *testing.T) { + baseline := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), nil) + actual := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), func(f *grpcPlanRetentionFixture) { + f.method.Result.Type = expr.Empty + f.endpoint.Response.Message.Type = expr.Empty + }) + require.Equal(t, baseline, actual) +} + +// grpcPlanRetentionDSL creates one streaming endpoint with request and +// response metadata and message validation. +func grpcPlanRetentionDSL(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + payload := dsl.Type("SavedPayload", func() { + dsl.Field(1, "value", dsl.String, func() { dsl.MinLength(2) }) + dsl.Field(2, "token", dsl.String) + dsl.Required("value", "token") + }) + result := dsl.Type("SavedResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "count", dsl.Int) + dsl.Required("value", "count") + }) + stream := dsl.Type("SavedStream", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedTransport", func() { + fixture.method = dsl.Method("Watch", func() { + dsl.Payload(payload) + dsl.StreamingPayload(stream) + dsl.StreamingResult(result) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("token:authorization") }) + dsl.Response(dsl.CodeOK, func() { + dsl.Headers(func() { dsl.Attribute("count:x-count") }) + dsl.Trailers(func() { dsl.Attribute("value:x-value") }) + }) + }) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// grpcEmptyPayloadFixture creates one unary method with no payload and a +// custom result that needs a protobuf conversion. +func grpcEmptyPayloadFixture(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + result := dsl.Type("SavedEmptyPayloadResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedEmptyPayload", func() { + fixture.method = dsl.Method("Read", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// renderGRPCPlanRetentionFixture saves the design, applies one later change, +// and renders every non-example gRPC file. +func renderGRPCPlanRetentionFixture(t *testing.T, fixture *grpcPlanRetentionFixture, mutate func(*grpcPlanRetentionFixture)) []string { + t.Helper() + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + if mutate != nil { + mutate(fixture) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + files := plans[0].ProtoFiles() + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ClientCLIFiles()...) + result := make([]string, len(files)) + for index, file := range files { + result[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(result) + return result +} diff --git a/grpc/codegen/plan_service_data_test.go b/grpc/codegen/plan_service_data_test.go new file mode 100644 index 0000000000..51c2b84b39 --- /dev/null +++ b/grpc/codegen/plan_service_data_test.go @@ -0,0 +1,70 @@ +// This file checks that retained gRPC plans link the exact service expressions +// and do not invent protobuf messages for payloads built only from metadata. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" +) + +func TestPlanServiceDataUsesExactExpressionAfterLink(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "gRPC files requested before plan linking", func() { + plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "Calc", data.Name) + require.NotEmpty(t, data.ClientStruct) + require.NotEmpty(t, data.ServerStruct) + + foreign := grpcPlanRoots(t, "Calc") + data, ok = plans[0].ServiceData(foreign[0].API.GRPC.Services[0]) + require.False(t, ok) + require.Nil(t, data) +} + +// TestPlanLinksMetadataOnlyStreamingPayload verifies that a payload built only +// from metadata does not require a protobuf request message. +func TestPlanLinksMetadataOnlyStreamingPayload(t *testing.T) { + root := RunGRPCDSL(t, func() { + dsl.Service("Chatter", func() { + dsl.Method("Echo", func() { + dsl.Payload(func() { + dsl.Field(1, "token", dsl.String) + dsl.Required("token") + }) + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + request := services.Get("Chatter").Endpoints[0].Request + require.Nil(t, request.PayloadMessage) + require.NotNil(t, request.ServerConvert) + require.Empty(t, request.ServerConvert.SrcName) + require.Empty(t, request.ServerConvert.SrcRef) + require.Nil(t, request.ServerConvert.Validation) +} diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go index 5f1abb466b..7e0d2bb160 100644 --- a/grpc/codegen/plan_test.go +++ b/grpc/codegen/plan_test.go @@ -4,6 +4,7 @@ package codegen import ( "fmt" + "path" "sort" "testing" @@ -33,6 +34,23 @@ func TestNewPlansKeepsExactInputs(t *testing.T) { require.Same(t, services[0], plans[1].Service()) } +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "Service") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans(generation, fixedProtobufToolResolver(), PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + + otherRoots := grpcPlanRoots(t, "Service") + otherGeneration, otherServices := grpcServicePlans(t, otherRoots) + examples, err := example.NewPlan(otherGeneration, otherServices[0]) + require.NoError(t, err) + + _, err = NewExamplePlan(plans[0], examples) + require.EqualError(t, err, "gRPC examples require server data created from the same service design") +} + // TestNewPlansRequiresEveryRoot checks that a batch cannot omit a design. func TestNewPlansRequiresEveryRoot(t *testing.T) { roots := grpcPlanRoots(t, "First", "Second") @@ -70,20 +88,37 @@ func TestPlanLinksOnceAfterFreeze(t *testing.T) { plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) require.NoError(t, err) require.EqualError(t, plans[0].Link(), "gRPC plan cannot link before generation freeze") - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, services[0].Link()) require.NoError(t, plans[0].Link()) require.EqualError(t, plans[0].Link(), "gRPC plan is already linked") } +// TestPlanLinksStoredServicesAfterRootListRemoved checks that Link builds the +// services selected by NewPlans without reading the root service list again. +func TestPlanLinksStoredServicesAfterRootListRemoved(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + roots[0].API.GRPC.Services = nil + require.NoError(t, plans[0].Link()) + require.NotEmpty(t, plans[0].ProtoFiles()) + require.NotEmpty(t, plans[0].ServerFiles()) + require.NotEmpty(t, plans[0].ClientFiles()) + require.NotEmpty(t, plans[0].ServerTypeFiles()) + require.NotEmpty(t, plans[0].ClientTypeFiles()) +} + // TestPlanReturnsStoredFiles checks that later reads reuse the linked files. func TestPlanReturnsStoredFiles(t *testing.T) { roots := grpcPlanRoots(t, "Calc") generation, services := grpcServicePlans(t, roots) plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, services[0].Link()) require.NoError(t, plans[0].Link()) @@ -103,6 +138,73 @@ func TestNewPlansIsIndependentOfInputOrder(t *testing.T) { require.Equal(t, forwardNames, reverseNames) } +// TestGRPCCLIImportAliasesBelongToOutputPackage checks that a name used by an +// unrelated HTTP package cannot change the qualifier written by the gRPC +// command parser package. +func TestGRPCCLIImportAliasesBelongToOutputPackage(t *testing.T) { + roots := grpcPlanRoots(t, "Echo") + generation, services := grpcServicePlans(t, roots) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/unrelated/client") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(codegen.NewImport("echoc", "example.com/unrelated/client"))) + + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverName := codegen.SnakeCase(codegen.Goify(roots[0].API.Servers[0].Name, true)) + cliPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + clientPath := path.Join(generation.GenPkg(), "grpc", "echo", "client") + require.Equal(t, "echoc", cliPackage.ImportName(clientPath)) +} + +// TestGRPCReferencesUseTheirOutputPackageAlias checks that client and server +// source use their own service import names when only the server package also +// imports the standard strings package. +func TestGRPCReferencesUseTheirOutputPackageAlias(t *testing.T) { + roots := grpcPlanRoots(t, "Strings") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + clientCode := sectionCode(t, plans[0].ClientFiles()[1].SectionTemplates...) + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, clientCode, `strings "generated.local/gen/strings"`) + require.Contains(t, clientCode, `*strings.ReadPayload`) + require.Contains(t, serverCode, `strings2 "generated.local/gen/strings"`) + require.Contains(t, serverCode, `*strings2.Endpoints`) +} + +// TestGRPCServerUsesItsOwnProtobufAlias checks that the runtime protobuf +// import used by a client cannot carry its suffixed alias into server source. +func TestGRPCServerUsesItsOwnProtobufAlias(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, serviceName := range []string{"Goa", "Goapb"} { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + } + }) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, serverCode, `goapb "generated.local/gen/grpc/goa/pb"`) + require.NotContains(t, serverCode, "goapb2.") +} + // grpcPlanRoots creates independent designs with one unary gRPC method. func grpcPlanRoots(t *testing.T, serviceNames ...string) []*expr.RootExpr { t.Helper() @@ -149,8 +251,6 @@ func grpcPlanFileSignatures(t *testing.T, plan *Plan) []string { files = append(files, plan.ServerTypeFiles()...) files = append(files, plan.ClientTypeFiles()...) files = append(files, plan.ClientCLIFiles()...) - files = append(files, plan.ExampleServerFiles()...) - files = append(files, plan.ExampleCLIFiles()...) signatures := make([]string, len(files)) for index, file := range files { signatures[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) @@ -175,7 +275,6 @@ func collidingGRPCPlanResult(t *testing.T, reverse bool) ([]string, string) { dsl.Service(serviceName, func() { dsl.Method("Sync2URL", func() { dsl.Payload(choice) - dsl.Result(choice) dsl.StreamingPayload(choice) dsl.StreamingResult(choice) dsl.GRPC(func() {}) @@ -193,7 +292,6 @@ func collidingGRPCPlanResult(t *testing.T, reverse bool) ([]string, string) { if err != nil { return nil, err.Error() } - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) for _, servicePlan := range services { require.NoError(t, servicePlan.Link()) diff --git a/grpc/codegen/planned_name_collision_test.go b/grpc/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..e4918e99b6 --- /dev/null +++ b/grpc/codegen/planned_name_collision_test.go @@ -0,0 +1,133 @@ +// This file checks that gRPC definitions and their callers use the package +// names selected after preferred names are already taken. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" +) + +// TestGRPCPlannedNamesSurvivePackageCollisions covers endpoint functions, +// stream types, conversion constructors, and validators in both definitions +// and calls. +func TestGRPCPlannedNamesSurvivePackageCollisions(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + clientPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildWatchFunc"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "NewProtoWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchClientStream"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "NewWatchHandler"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "ValidateWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(fixture.service) + require.True(t, ok) + endpoint := data.Endpoints[0] + require.NotEqual(t, "BuildWatchFunc", endpoint.ClientBuildDeclaration.Name()) + require.NotEqual(t, "EncodeWatchRequest", endpoint.ClientEncodeDeclaration.Name()) + require.NotEqual(t, "DecodeWatchResponse", endpoint.ClientDecodeDeclaration.Name()) + require.NotEqual(t, "NewProtoWatchRequest", endpoint.Request.ClientConvert.Init.Declaration.Name()) + require.NotEqual(t, "NewWatchHandler", endpoint.ServerHandlerDeclaration.Name()) + require.NotEqual(t, "DecodeWatchRequest", endpoint.ServerDecodeDeclaration.Name()) + require.NotEqual(t, "EncodeWatchResponse", endpoint.ServerEncodeDeclaration.Name()) + require.NotEqual(t, "ValidateWatchRequest", endpoint.Request.ServerConvert.Validation.Declaration.Name()) + require.NotEqual(t, "WatchClientStream", endpoint.ClientStream.Declaration.Name()) + require.NotEqual(t, "WatchServerStream", endpoint.ServerStream.Declaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientFiles(), "remote-method-builder"}, + {plans[0].ClientFiles(), "request-encoder"}, + {plans[0].ClientFiles(), "response-decoder"}, + {plans[0].ClientFiles(), "client-endpoint-init"}, + {plans[0].ClientFiles(), "client-stream-struct-type"}, + {plans[0].ServerFiles(), "request-decoder"}, + {plans[0].ServerFiles(), "response-encoder"}, + {plans[0].ServerFiles(), "grpc-handler-init"}, + {plans[0].ServerFiles(), "server-grpc-interface"}, + {plans[0].ServerFiles(), "server-stream-struct-type"}, + } { + sections := matchingGRPCSections(selection.files, selection.section, endpoint) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientTypeFiles(), "client-type-init"}, + {plans[0].ServerTypeFiles(), "server-type-init"}, + {plans[0].ServerTypeFiles(), "server-validate"}, + } { + sections := namedGRPCSections(selection.files, selection.section) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// namedGRPCSections returns every section with name in file order. +func namedGRPCSections(files []*codegen.File, name string) []*codegen.SectionTemplate { + var result []*codegen.SectionTemplate + for _, file := range files { + result = append(result, file.Section(name)...) + } + return result +} + +// matchingGRPCSections returns sections for endpoint without depending on file +// order or on the number of other sections in the file. +func matchingGRPCSections(files []*codegen.File, name string, endpoint *EndpointData) []*codegen.SectionTemplate { + var result []*codegen.SectionTemplate + for _, file := range files { + for _, section := range file.Section(name) { + switch data := section.Data.(type) { + case *EndpointData: + if data == endpoint { + result = append(result, section) + } + case *StreamData: + if data.Endpoint == endpoint { + result = append(result, section) + } + } + } + } + return result +} diff --git a/grpc/codegen/proto.go b/grpc/codegen/proto.go index 4251cb4c82..99763062c6 100644 --- a/grpc/codegen/proto.go +++ b/grpc/codegen/proto.go @@ -1,3 +1,5 @@ +// This file builds protobuf schema files and compiles each one with the tools +// selected before rendering starts. package codegen import ( @@ -5,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "goa.design/goa/v3/codegen" @@ -19,11 +22,14 @@ const ( ProtoPrefix = "goagen" ) -// ProtoFiles returns the protobuf file for every gRPC service. -func ProtoFiles(services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = protoFile(svc, services) +// defaultProtocCmd selects protoc when the design does not choose a compiler. +var defaultProtocCmd = []string{expr.DefaultProtoc} + +// protoFiles returns the planned protobuf file for every gRPC service. +func protoFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = protoFile(servicePlan.expression, services) } return fw } @@ -33,6 +39,10 @@ func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File genpkg := services.GenPkg() data := services.Get(svc.Name()) svcName := data.Service.PathName + fileServiceName := svcName + if planned := services.protobuf[svc]; planned != nil && planned.fileIndex > 1 { + fileServiceName += strconv.Itoa(planned.fileIndex) + } parts := strings.Split(genpkg, "/") var repoName string if len(parts) > 1 { @@ -40,8 +50,9 @@ func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File } else { repoName = parts[0] } - // the filename is used by protoc to set the namespace so try to make it unique - fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, svcName) + // Include the repository and service so two services do not write the same + // file. + fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, fileServiceName) path := filepath.Join(codegen.Gendir, "grpc", svcName, pbPkgName, fname) sections := make([]*codegen.SectionTemplate, 0, 3+len(data.Messages)) @@ -81,31 +92,22 @@ func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File }) } - runProtoc := func(path string) error { - includes := svc.ServiceExpr.Meta["protoc:include"] - includes = append(includes, services.Root.API.Meta["protoc:include"]...) - - cmd := defaultProtocCmd - if c, ok := services.Root.API.Meta["protoc:cmd"]; ok { - cmd = c - } - if c, ok := svc.ServiceExpr.Meta["protoc:cmd"]; ok { - cmd = c - } - if len(cmd) == 0 { - return fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) - } - - return protoc(cmd, path, includes) + tools := services.tools[svc] + if tools == nil { + panic(fmt.Sprintf("protobuf tools for service %q were not planned", svc.Name())) } return &codegen.File{ Path: path, SectionTemplates: sections, - FinalizeFunc: runProtoc, + FinalizeFunc: func(path string) error { + return runProtoc(tools, path) + }, } } +// pkgName returns the protobuf package chosen by the design or the service +// name used when the design does not choose one. func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { if svc.ProtoPkg != "" { return svc.ProtoPkg @@ -113,9 +115,8 @@ func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { return codegen.SnakeCase(svcName) } -var defaultProtocCmd = []string{expr.DefaultProtoc} - -func protoc(protocCmd []string, path string, includes []string) error { +// runProtoc compiles one schema with the command fixed during planning. +func runProtoc(tools *protobufToolPlan, path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0750); err != nil { return err @@ -128,11 +129,14 @@ func protoc(protocCmd []string, path string, includes []string) error { "--go-grpc_out", dir, "--go_opt=paths=source_relative", "--go-grpc_opt=paths=source_relative", + "--plugin=protoc-gen-go=" + tools.goPlugin, + "--plugin=protoc-gen-go-grpc=" + tools.goGRPCPlugin, } - for _, include := range includes { + for _, include := range tools.includes { args = append(args, "-I", include) } - cmd := exec.Command(protocCmd[0], append(protocCmd[1:len(protocCmd):len(protocCmd)], args...)...) + command := tools.command + cmd := exec.Command(command[0], append(command[1:len(command):len(command)], args...)...) cmd.Dir = filepath.Dir(path) if output, err := cmd.CombinedOutput(); err != nil { diff --git a/grpc/codegen/proto_hooks.go b/grpc/codegen/proto_hooks.go index 82438a96e6..3733474c6e 100644 --- a/grpc/codegen/proto_hooks.go +++ b/grpc/codegen/proto_hooks.go @@ -1,3 +1,5 @@ +// This file tells the shared Go conversion code how protobuf wrappers, +// collections, unions, and nil values differ from service values. package codegen import ( @@ -10,22 +12,27 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // protobufOneofAttributor returns the Go wrapper type generated for one + // protobuf union branch. + protobufOneofAttributor interface { + OneofWrapper(*expr.AttributeExpr) string + } +) + var ( - // renderGoArrayT is the template rendering protocol buffer array - // transformations driven by the shared transform engine. + // renderGoArrayT writes a conversion between service and protobuf arrays. renderGoArrayT *template.Template - // renderGoMapT is the template rendering protocol buffer map - // transformations driven by the shared transform engine. + // renderGoMapT writes a conversion between service and protobuf maps. renderGoMapT *template.Template - // renderGoUnionToProtoT is the template rendering Go union to protobuf - // oneof transformations. + // renderGoUnionToProtoT writes a service union into a protobuf oneof. renderGoUnionToProtoT *template.Template - // renderGoUnionFromProtoT is the template rendering protobuf oneof to - // Go union transformations. + // renderGoUnionFromProtoT writes a protobuf oneof into a service union. renderGoUnionFromProtoT *template.Template ) -// NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 +// The templates are initialized here because Go cannot initialize this cycle +// of template functions in the variable declarations. func init() { fm := template.FuncMap{"transformAttribute": codegen.TransformAttribute} renderGoArrayT = template.Must(template.New("renderGoArray").Funcs(fm).Parse(grpcTemplates.Read(grpcTransformGoArrayT))) @@ -34,24 +41,19 @@ func init() { renderGoUnionFromProtoT = template.Must(template.New("renderGoUnionFromProto").Parse(grpcTemplates.Read(grpcTransformGoUnionFromProtoT))) } -// protoHooks returns the transform hooks that specialize the shared Go -// transform engine for protocol buffer transformations. proto is true when -// the transformation initializes a protocol buffer type from a service type -// and false when it initializes a service type from a protocol buffer type. -// targetCtx is the target attribute context of the transformation; it is used -// to name the synthetic wrapper messages initialized by the generated code. -func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.TransformHooks { +// protoHooks returns the functions that convert between service and protobuf +// values. proto is true when the target is the protobuf value. +func protoHooks(proto bool) *codegen.TransformHooks { return &codegen.TransformHooks{ UnwrapPair: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *codegen.WrapDirective) { if proto { if isWrappedAttr(tgt) { - name := targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault) - return src, unwrapAttr(expr.DupAtt(tgt)), &codegen.WrapDirective{WrapTarget: true, InitTypeName: name, FieldName: "Field"} + return src, unwrapAttr(tgt), &codegen.WrapDirective{WrapTarget: true, Target: tgt, FieldName: "Field"} } return src, tgt, nil } if isWrappedAttr(src) { - return unwrapAttr(expr.DupAtt(src)), tgt, &codegen.WrapDirective{FieldName: "Field"} + return unwrapAttr(src), tgt, &codegen.WrapDirective{FieldName: "Field"} } return src, tgt, nil }, @@ -61,8 +63,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf ConvertPrimitive: func(src, tgt *expr.AttributeExpr, srcVar string, srcPtr, tgtPtr bool, ta *codegen.TransformAttrs) (string, bool) { exp := convertType(src, tgt, srcPtr, tgtPtr, srcVar, proto, ta) if _, isSrcUT := src.Type.(expr.UserType); isSrcUT && !proto { - // If the source is an alias type and the code is initializing a - // service type then we must cast to the alias type. + // A service alias must keep its named Go type. deref := "" if srcPtr { deref = "*" @@ -77,37 +78,25 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf TransformMap: func(source, target *expr.Map, sourceVar, targetVar string, newVar bool, ta *codegen.TransformAttrs) (string, error) { return renderMapTransform(source, target, sourceVar, targetVar, newVar, proto, ta) }, - TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, srcParent, tgtParent *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { + TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, _, _ *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { if proto { - // Go service union fields are interfaces (nil when absent); do - // not dereference. - return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, oneofMessageName(tgtParent, proto, ta), ta) + // Service union fields are interfaces, so they are not dereferenced. + return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, ta) } - // Service unions in Goa are represented as interface types, not - // *interface. Always assign concrete values to the interface (no - // pointer-to-interface). - return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, oneofMessageName(srcParent, proto, ta), ta) + // Store the selected value directly in the service union interface. + return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, ta) }, - HelperNameAttrs: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - // Do not consider package overrides for protogen generated types. - if proto { - tgt = expr.DupAtt(tgt) - codegen.Walk(tgt, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) - } else { - src = expr.DupAtt(src) - codegen.Walk(src, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + for index, sourceBranch := range sourceUnion.Values { + targetBranch := targetUnion.Values[index] + if protoUnionBranchUsesHelper(sourceBranch.Attribute, targetBranch.Attribute) { + record(sourceBranch.Attribute, targetBranch.Attribute) + } } - return src, tgt }, GuardCondition: func(src *expr.AttributeExpr, srcVar string, _, srcPtr bool) (string, bool) { - // Non-primitives are always guarded (proto3 message fields are - // always nilable). + // Protobuf message fields can be nil, so check them before use. if expr.IsPrimitive(src.Type) && !srcPtr { return "", true } @@ -126,7 +115,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf return "", false }, ObjectDeref: func(tgt *expr.AttributeExpr) (string, bool) { - // if the target is a raw struct no need to return a pointer + // An unnamed struct value does not need a pointer. if _, ok := tgt.Type.(*expr.Object); ok { return "", true } @@ -136,28 +125,9 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf } } -// oneofMessageName returns the reference to the protoc generated Go type of -// the message containing the oneof being transformed: protoc builds the union -// wrapper struct type names from the parent message type name. parent is the -// attribute of the object owning the union field, nil when the union is -// transformed directly in which case there is no message context. -func oneofMessageName(parent *expr.AttributeExpr, proto bool, ta *codegen.TransformAttrs) string { - if parent == nil { - return "" - } - if _, ok := parent.Type.(expr.UserType); !ok { - return "" - } - if proto { - return ta.TargetCtx.Scope.Name(parent, ta.TargetCtx.Pkg(parent), false, false) - } - return ta.SourceCtx.Scope.Ref(parent, ta.SourceCtx.Pkg(parent)) -} - -// renderArrayTransform renders the code transforming the source array held by -// sourceVar into the target array held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing. +// renderArrayTransform writes code that copies sourceVar into the target array. +// proto is true when the target is a protobuf array. The shared conversion code +// opens any protobuf wrapper around an array element. func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { elem := target.ElemType if proto { @@ -170,15 +140,16 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin valVar = "" } + loopVar, childAttrs := ta.EnterCollection() data := map[string]any{ "ElemTypeRef": targetRef, "SourceElem": source.ElemType, - "TargetElem": elem, + "TargetElem": target.ElemType, "SourceVar": sourceVar, "TargetVar": targetVar, "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), + "TransformAttrs": childAttrs, + "LoopVar": loopVar, "ValVar": valVar, } var buf bytes.Buffer @@ -188,11 +159,9 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin return ensureTrailingNewline(buf.String()), nil } -// renderMapTransform renders the code transforming the source map held by -// sourceVar into the target map held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing; map keys cannot be -// nested in protocol buffers so only elements may be wrapped. +// renderMapTransform writes code that copies sourceVar into the target map. +// proto is true when the target is a protobuf map. Protobuf map values may use +// wrapper messages; protobuf map keys may not. func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.KeyType.Type, target.KeyType.Type, sourceVar+"[key]", targetVar+"[key]"); err != nil { return "", err @@ -226,11 +195,9 @@ func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, n return ensureTrailingNewline(buf.String()), nil } -// renderUnionToProtoTransform renders the code transforming the source Goa -// union held by sourceVar into the protoc generated oneof field held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionToProtoTransform writes a service union from sourceVar into the +// protobuf oneof in targetVar. +func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -239,10 +206,12 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, for i, sv := range src.Values { tv := tgt.Values[i] fieldName := ta.TargetCtx.Scope.Field(tv.Attribute, tv.Name, true) + scope := ta.TargetCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(tv.Attribute) cases = append(cases, map[string]any{ "TypeTag": sv.Name, "SourceFieldName": codegen.Goify(sv.Name, true), - "TargetWrapperType": protocOneofWrapperRef(message, fieldName), + "TargetWrapperType": wrapperType, "TargetFieldName": fieldName, "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "actual", true, ta), }) @@ -261,11 +230,9 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, return ensureTrailingNewline(buf.String()), nil } -// renderUnionFromProtoTransform renders the code transforming the protoc -// generated oneof field held by sourceVar into the target Goa union held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionFromProtoTransform writes the protobuf oneof in sourceVar into the +// service union in targetVar. +func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -274,8 +241,10 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar for i, sv := range src.Values { tv := tgt.Values[i] sourceFieldName := ta.SourceCtx.Scope.Field(sv.Attribute, sv.Name, true) + scope := ta.SourceCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(sv.Attribute) cases = append(cases, map[string]any{ - "SourceValueTypeRef": protocOneofWrapperRef(message, sourceFieldName), + "SourceValueTypeRef": "*" + wrapperType, "TargetFieldName": codegen.Goify(tv.Name, true), "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "val."+sourceFieldName, false, ta), }) @@ -292,9 +261,8 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar return ensureTrailingNewline(buf.String()), nil } -// ensureTrailingNewline appends a newline to code when missing so that the -// rendered transformations compose with the code the engine emits around -// them. +// ensureTrailingNewline appends a newline when surrounding generated code must +// continue on the next line. func ensureTrailingNewline(code string) string { if code != "" && !strings.HasSuffix(code, "\n") { code += "\n" diff --git a/grpc/codegen/proto_hooks_specialization_test.go b/grpc/codegen/proto_hooks_specialization_test.go new file mode 100644 index 0000000000..28559f27e3 --- /dev/null +++ b/grpc/codegen/proto_hooks_specialization_test.go @@ -0,0 +1,119 @@ +// This file verifies that gRPC collection conversions use the recorded nesting +// level to choose loop variables instead of examining generated Go expression +// text. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufOneofSnapshotAttributor records the exact union branch used to + // resolve a protobuf wrapper name. + protobufOneofSnapshotAttributor struct { + codegen.Attributor + branches *[]*expr.AttributeExpr + } +) + +func TestRenderArrayTransformUsesTraversalDepthForLoopVariable(t *testing.T) { + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: protoHooks(true), + } + + generated, err := renderArrayTransform(source, target, "source", "target[key]", false, true, attributes) + require.NoError(t, err) + require.Contains(t, generated, "for i, val := range source") + require.Contains(t, generated, "target[key][i] =") +} + +func TestRenderArrayTransformPreservesPrimitiveAliasForElementConversion(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Alias", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + } + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var convertedTarget *expr.AttributeExpr + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: &codegen.TransformHooks{ + ConvertPrimitive: func(_ *expr.AttributeExpr, target *expr.AttributeExpr, _ string, _, _ bool, _ *codegen.TransformAttrs) (string, bool) { + convertedTarget = target + return "string(val)", true + }, + }, + } + + _, err := renderArrayTransform(source, target, "source", "target", true, true, attributes) + require.NoError(t, err) + require.Same(t, target.ElemType, convertedTarget) +} + +func TestTransformPlanOneofLookupUsesOriginalBranch(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: sourceBranch}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: targetBranch}, + }, + }} + plan, err := codegen.NewTransformPlan(source, target, "", protoHooks(true)) + require.NoError(t, err) + require.Empty(t, plan.Helpers()) + + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var branches []*expr.AttributeExpr + targetContext.Scope = &protobufOneofSnapshotAttributor{ + Attributor: targetContext.Scope, + branches: &branches, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + generated, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Empty(t, definitions) + require.Contains(t, generated, "TargetChoice_Text") + require.Len(t, branches, 1) + require.Same(t, targetBranch, branches[0]) +} + +// Enter preserves protobuf wrapper lookup while entering a copied union. +func (a *protobufOneofSnapshotAttributor) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + return &protobufOneofSnapshotAttributor{ + Attributor: a.Attributor.Enter(attribute), + branches: a.branches, + } +} + +// IsSumType reports that this test resolver uses protobuf oneof wrappers. +func (*protobufOneofSnapshotAttributor) IsSumType() bool { + return false +} + +// OneofWrapper records the branch and returns its planned protobuf type. +func (a *protobufOneofSnapshotAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + *a.branches = append(*a.branches, attribute) + return "TargetChoice_Text" +} diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go index 1d181f81ae..cfb2e9953a 100644 --- a/grpc/codegen/proto_test.go +++ b/grpc/codegen/proto_test.go @@ -43,7 +43,7 @@ func TestProtoFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles(services) + fs := protoFiles(services) if len(fs) != 1 { t.Fatalf("got %d files, expected one", len(fs)) } @@ -53,7 +53,7 @@ func TestProtoFiles(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code) fpath := codegen.CreateTempFile(t, code) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -78,7 +78,7 @@ func TestMessageDefSection(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles(services) + fs := protoFiles(services) require.Len(t, fs, 1) sections := fs[0].SectionTemplates require.GreaterOrEqual(t, len(sections), 3) @@ -87,7 +87,7 @@ func TestMessageDefSection(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code+msgCode) fpath := codegen.CreateTempFile(t, code+msgCode) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -121,7 +121,7 @@ func TestProtoc(t *testing.T) { t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) }) fpath := filepath.Join(dir, "schema") require.NoError(t, os.WriteFile(fpath, []byte(code), 0o600), "error occurred writing proto schema") - require.NoError(t, protoc(c.Cmd, fpath, nil), "error occurred when compiling proto file with the standard protoc %q", fpath) + require.NoError(t, protoc(c.Cmd, fpath), "error occurred when compiling proto file with the standard protoc %q", fpath) fcontents, err := os.ReadFile(fpath + ".pb.go") require.NoError(t, err) diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index e67f1d4861..01881ccb12 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -4,7 +4,6 @@ package codegen import ( "fmt" - "regexp" "slices" "strconv" "strings" @@ -15,7 +14,8 @@ import ( ) type ( - // protoBufScope is the scope for protocol buffer attribute types. + // protoBufScope supplies the Go names used for protobuf fields and types in + // one generated service package. protoBufScope struct { service *ServiceData pkg string @@ -49,31 +49,48 @@ func (p *protoBufScope) Package(*expr.AttributeExpr) string { return p.pkg } -// Enter keeps protobuf messages in the wire package owned by p. +// Enter keeps nested protobuf messages in the same generated package. func (p *protoBufScope) Enter(*expr.AttributeExpr) codegen.Attributor { return p } // IsSumType reports that protobuf unions use generated oneof messages rather -// than Goa service sum-type structs. +// than Goa service values that hold one selected branch. func (*protoBufScope) IsSumType() bool { return false } -// ValidatorName returns the protobuf validation helper convention used before -// the package catalog replaces it with an exact side-specific record. -func (p *protoBufScope) ValidatorName(att *expr.AttributeExpr, view string) string { - return "Validate" + p.Name(att, "", false, true) + codegen.Goify(view, true) +// ValidatorCall returns a call using the first choice for a protobuf validation +// function name. The service may add a suffix when another function uses it. +func (p *protoBufScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + p.Name(att, "", false, true) + codegen.Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) } -// Field returns the field name as generated by protocol buffer compiler. -// NOTE: protoc does not care about common initialisms like api -> API so we -// first transform the name into snake case to end up with Api. -func (*protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { - return protoBufifyAtt(att, codegen.SnakeCase(name), firstUpper) +// Field returns the exact Go field name produced by the supported protobuf +// tools. +func (p *protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + planned, ok := p.service.protobuf.plan.fieldName(att) + if !ok { + panic(fmt.Sprintf("protobuf field %q was not planned", name)) + } + return planned } -// Scope returns the name scope. +// OneofWrapper returns the generated wrapper type for one branch in one +// parent message. +func (p *protoBufScope) OneofWrapper(attribute *expr.AttributeExpr) string { + name, ok := p.service.protobuf.plan.wrapperName(attribute) + if !ok { + panic("protobuf oneof branch was not planned") + } + if p.pkg == "" { + return name + } + return p.pkg + "." + name +} + +// Scope returns the object that assigns unique Go names in this package. func (p *protoBufScope) Scope() *codegen.NameScope { return p.service.Scope } @@ -133,12 +150,12 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, owner expr.Exa switch { case expr.IsArray(att.Type): wrapAttr(att, "ArrayOf"+tname+ - protoBufify(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType), true, true), true, owner) + codegen.ProtobufName(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType)), true, owner) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) wrapAttr(att, tname+"MapOf"+ - protoBufify(protoBufShapeTypeName(m.KeyType), true, true)+ - protoBufify(protoBufShapeTypeName(m.ElemType), true, true), true, owner) + codegen.ProtobufName(protoBufShapeTypeName(m.KeyType))+ + codegen.ProtobufName(protoBufShapeTypeName(m.ElemType)), true, owner) } } @@ -255,6 +272,23 @@ func protoBufMessageName(att *expr.AttributeExpr, service *ServiceData) string { return protoBufFullMessageName(att, "", service) } +// protoBufSourceMessageName returns the message name written to the .proto +// file. +func protoBufSourceMessageName(att *expr.AttributeExpr, service *ServiceData) string { + userType, ok := att.Type.(expr.UserType) + if !ok { + if composite, ok := att.Type.(expr.CompositeExpr); ok { + return protoBufSourceMessageName(composite.Attribute(), service) + } + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", att.Type)) // bug + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no planned name", userType.Name())) + } + return record.protoName +} + // protoBufFullMessageName returns the protocol buffer message name of the // given user type qualified with the given package name if applicable. func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { @@ -279,12 +313,6 @@ func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, service *Servi } } -// protoBufGoTypeName returns the protocol buffer type name for the given -// attribute generated after compiling the proto file (in *.pb.go). -func protoBufGoTypeName(att *expr.AttributeExpr, service *ServiceData) string { - return protoBufGoFullTypeName(att, "", service) -} - // protoBufGoFullTypeName returns the protocol buffer type name qualified with // the given package name for the given attribute generated after compiling // the proto file (in *.pb.go). @@ -327,9 +355,9 @@ func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, service *Servic } } -// protoBufShapeTypeName returns the stable type fragment used while wrapping -// nested collection values before the package declaration catalog is frozen. -// It reads authored type facts but never allocates an emitted message name. +// protoBufShapeTypeName returns the type name used when Goa wraps an array or +// map inside a protobuf message. It reads the design but does not reserve a Go +// name for the generated message. func protoBufShapeTypeName(att *expr.AttributeExpr) string { if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { return protos[0] @@ -344,14 +372,14 @@ func protoBufShapeTypeName(att *expr.AttributeExpr) string { if names := actual.Attribute().Meta["struct:name:proto"]; len(names) > 0 { return names[0] } - return protoBufify(actual.Name(), true, true) + return codegen.ProtobufName(actual.Name()) case expr.CompositeExpr: return protoBufShapeTypeName(actual.Attribute()) case *expr.Object: return "Object" case *expr.Union: if actual.TypeName != "" { - return protoBufify(actual.TypeName, true, true) + return codegen.ProtobufName(actual.TypeName) } return "Union" default: @@ -379,15 +407,22 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { case *expr.Map: return fmt.Sprintf("map<%s, %s>", protoType(actual.KeyType, sd), protoType(actual.ElemType, sd)) case *expr.Union: - // Compute oneof name and ensure it does not collide with any of the member field names - oneofName := codegen.SnakeCase(protoBufify(actual.Name(), false, false)) + oneofName := codegen.ProtobufFieldName(actual.Name()) + if sd.protobuf != nil { + oneofName = sd.protobuf.plan.sourceOneofName(att) + } var fieldNames []string for _, nat := range actual.Values { - fn := codegen.SnakeCase(protoBufify(nat.Name, false, false)) + fn := protobufSourceFieldName(nat.Name) + if sd.protobuf != nil { + fn = sd.protobuf.plan.sourceFieldName(nat.Attribute) + } fieldNames = append(fieldNames, fn) } - for slices.Contains(fieldNames, oneofName) { - oneofName += "_oneof" + if sd.protobuf == nil { + for slices.Contains(fieldNames, oneofName) { + oneofName += "_oneof" + } } def := "\toneof " + oneofName + " {" for i, nat := range actual.Values { @@ -415,7 +450,7 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { if prim := getPrimitive(att); prim != nil { return protoBufMessageDef(prim, sd) } - return protoBufMessageName(att, sd) + return protoBufSourceMessageName(att, sd) case *expr.Object: var ss []string ss = append(ss, " {") @@ -432,7 +467,10 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { desc string ) { - fn = codegen.SnakeCase(protoBufify(nat.Name, false, false)) + fn = protobufSourceFieldName(nat.Name) + if sd.protobuf != nil { + fn = sd.protobuf.plan.sourceFieldName(nat.Attribute) + } fnum = rpcTag(nat.Attribute) if prim := getPrimitive(nat.Attribute); prim != nil { typ = protoType(prim, sd) @@ -477,63 +515,6 @@ func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, service *Service return name } -var digits = regexp.MustCompile("[0-9]+") - -// protoBufify makes a valid protocol buffer identifier out of any string. -// It does that by removing any non letter and non digit character and by -// making sure the first character is a letter or "_". protoBufify produces a -// "CamelCase" version of the string. -// -// If firstUpper is true the first character of the identifier is uppercase -// otherwise it's lowercase. -func protoBufify(str string, firstUpper, acronym bool) string { - // Optimize trivial case - if str == "" { - return "" - } - - // Remove optional suffix that defines corresponding transport specific - // name. - idx := strings.Index(str, ":") - if idx > 0 { - str = str[:idx] - } - - // The CamelCase implementation of protoc-gen-go considers digits as words - // but our CamelCase implementation considers them as lower case characters, - // compensate by adding an underscore after any series of digits. - // See https://github.com/golang/protobuf/blob/d04d7b157bb510b1e0c10132224b616ac0e26b17/protoc-gen-go/generator/generator.go#L2648-L2685 - str = string(digits.ReplaceAllFunc([]byte(str), func(match []byte) []byte { - res := make([]byte, len(match)+1) // need to allocate new slice - copy(res, match) - res[len(res)-1] = '_' - return res - })) - - str = codegen.CamelCase(str, firstUpper, acronym) - if str == "" { - // All characters are invalid. Produce a default value. - if firstUpper { - return "Val" - } - return "val" - } - - return fixReservedProtoBuf(str) -} - -// protoBufifyAtt honors any struct:field:name meta set on the attribute and -// and calls protoBufify with the tag value if present or the given name -// otherwise. -func protoBufifyAtt(att *expr.AttributeExpr, name string, upper bool) string { - if tname, ok := att.Meta["struct:field:name"]; ok { - if len(tname) > 0 { - name = tname[0] - } - } - return protoBufify(name, upper, false) -} - // protoNativeType returns the protocol buffer built-in type // corresponding to the given primitive type. It panics if t is not a // primitive type. @@ -617,50 +598,3 @@ func rpcTag(a *expr.AttributeExpr) uint64 { } return tag } - -// fixReservedProtoBuf appends an underscore on to protocol buffer reserved -// keywords. -func fixReservedProtoBuf(w string) string { - if _, ok := reservedProtoBuf[codegen.CamelCase(w, false, false)]; ok { - w += "_" - } - return w -} - -var ( - // reserved protocol buffer keywords and package names - reservedProtoBuf = map[string]struct{}{ - // types - "bool": {}, - "bytes": {}, - "double": {}, - "fixed32": {}, - "fixed64": {}, - "float": {}, - "int32": {}, - "int64": {}, - "sfixed32": {}, - "sfixed64": {}, - "sint32": {}, - "sint64": {}, - "string": {}, - "uint32": {}, - "uint64": {}, - - // reserved - "enum": {}, - "import": {}, - "map": {}, - "message": {}, - "oneof": {}, - "option": {}, - "package": {}, - "public": {}, - "repeated": {}, - "reserved": {}, - "returns": {}, - "rpc": {}, - "service": {}, - "syntax": {}, - } -) diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go index e86479d667..12cbfed8ea 100644 --- a/grpc/codegen/protobuf_catalog.go +++ b/grpc/codegen/protobuf_catalog.go @@ -1,12 +1,12 @@ -// This file owns the protobuf declarations and validation helpers emitted by -// one generated gRPC protobuf package. It separates declaration identity from -// traversal identity and freezes names before conversion data refers to them. +// This file records the protobuf messages and validation functions written for +// one gRPC service. It chooses every package-level name before conversion code +// refers to that name. package codegen import ( "fmt" "reflect" - "strconv" + "regexp" "strings" "goa.design/goa/v3/codegen" @@ -15,24 +15,25 @@ import ( ) type ( - // protobufPackageCatalog owns every protobuf message and validator emitted - // into one generated protobuf package. + // protobufPackageCatalog stores every message and validation function that + // Goa writes for one service. protobufPackageCatalog struct { packageName string + plan *protobufServicePlan messages []*protobufMessageRecord messageUses map[*expr.AttributeExpr]*protobufMessageRecord unions []*protobufUnionRecord unionUses map[*expr.AttributeExpr]*protobufUnionRecord rootSources map[expr.UserType]protobufMessageSource - reservedNames []string validators []*protobufValidationRecord + validationUses map[protobufValidationUse]*protobufValidationRecord frozen bool messagesRendered bool validationsFrozen bool } - // protobufEndpointMessages contains every detached protobuf-shaped value for - // one endpoint before conversion and render records are built. + // protobufEndpointMessages contains the copied request, response, and error + // values used to write one endpoint's protobuf code. protobufEndpointMessages struct { request *expr.AttributeExpr streamingRequest *expr.AttributeExpr @@ -41,18 +42,22 @@ type ( errors map[string]*expr.AttributeExpr } - // protobufMessageRecord is the canonical declaration selected for one typed - // protobuf wire contract. + // protobufMessageRecord stores one protobuf message and every place that + // uses it. protobufMessageRecord struct { - identity protobufMessageIdentity - uses []*expr.AttributeExpr - name string - goRef string - data *service.UserTypeData + identity protobufMessageIdentity + uses []*expr.AttributeExpr + protoName string + plannedName string + declaration *codegen.NameDeclaration + name string + goRef string + data *service.UserTypeData } - // protobufMessageIdentity contains the source declaration and the wire facts - // that can change the emitted protobuf message. + // This record stores the source declaration, requested name, explicit-name + // flag, and type fields used to decide whether two values can share one + // protobuf message. protobufMessageIdentity struct { source protobufMessageSource preferredName string @@ -61,9 +66,7 @@ type ( attribute *expr.AttributeExpr } - // protobufUnionRecord identifies one oneof declaration nested in an owning - // protobuf message. Generated transformation helpers use this typed owner - // instead of allocating a name during lookup. + // protobufUnionRecord stores one oneof inside its protobuf message. protobufUnionRecord struct { owner *protobufMessageRecord attribute *expr.AttributeExpr @@ -72,50 +75,73 @@ type ( name string } - // protobufMessageSource identifies either an authored declaration or a - // synthetic endpoint message whose declaration does not exist in the design. + // protobufMessageSource points to either an authored declaration or an + // endpoint message created by the generator. protobufMessageSource struct { origin expr.UserType synthetic protobufSyntheticMessage } - // protobufSyntheticMessage identifies a compiler-created endpoint message. + // protobufSyntheticMessage stores a message that Goa creates for an endpoint. protobufSyntheticMessage struct { endpoint *expr.GRPCEndpointExpr error *expr.GRPCErrorExpr role protobufSyntheticRole } - // protobufSyntheticRole identifies which endpoint wire value a synthetic - // protobuf message represents. + // protobufSyntheticRole says which endpoint value a Goa-created message holds. protobufSyntheticRole uint8 - // protobufValidationRecord is the canonical validation helper emitted in one - // generated client or server package. + // protobufValidationRecord stores one validation function written to a + // client or server package. protobufValidationRecord struct { - declaration *protobufMessageRecord + message *protobufMessageRecord + declaration *codegen.NameDeclaration attribute *expr.AttributeExpr + source protobufValidationSource side validateKind targetName string contextName string uses []*expr.AttributeExpr - name string data *ValidationData } - // protobufAttributePair breaks cycles while comparing two typed wire or - // validation graphs. + // protobufValidationSource records the endpoint value and nested field that + // first needs one validation function. + protobufValidationSource struct { + api string + service string + method string + error string + path string + role protobufValidationRole + } + + // protobufValidationRole says which endpoint value is checked. + protobufValidationRole uint8 + + // protobufValidationUse records which function checks one copied value in a + // generated client or server package. + protobufValidationUse struct { + attribute *expr.AttributeExpr + side validateKind + } + + // protobufAttributePair stops a comparison when recursive values lead back + // to the same pair. protobufAttributePair struct { left *expr.AttributeExpr right *expr.AttributeExpr } - // protobufValidationScope resolves nested validation calls through frozen - // validator records while delegating fields and type references to protobuf. + // This value supplies the protobuf message names and validation function + // names used while Goa writes validation code. protobufValidationScope struct { *protoBufScope catalog *protobufPackageCatalog side validateKind + message *protobufMessageRecord + parent expr.UserType } ) @@ -128,29 +154,29 @@ const ( protobufWrapperMessage ) -// newProtobufPackageCatalog constructs the declaration owner for one actual -// generated protobuf package. +var protobufExactNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +const ( + protobufRequestValidation protobufValidationRole = iota + 1 + protobufResponseValidation + protobufErrorValidation + protobufStreamingRequestValidation +) + +// newProtobufPackageCatalog creates the protobuf messages for one service and +// the functions that validate those messages in its client and server. func newProtobufPackageCatalog(packageName string) *protobufPackageCatalog { return &protobufPackageCatalog{ - packageName: packageName, - messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), - unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), - rootSources: make(map[expr.UserType]protobufMessageSource), + packageName: packageName, + messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), + unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), + rootSources: make(map[expr.UserType]protobufMessageSource), + validationUses: make(map[protobufValidationUse]*protobufValidationRecord), } } -// reserveName prevents message declarations from colliding with another -// package-level protobuf declaration such as the service interface. -func (c *protobufPackageCatalog) reserveName(name string) { - if c.frozen { - panic("cannot reserve a protobuf package name after the catalog freezes") - } - c.reservedNames = append(c.reservedNames, name) -} - -// bindRootSource associates a shaped root declaration and all of its later -// copies with the authored service declaration or synthetic endpoint role -// whose value it carries. +// bindRootSource records which service or endpoint value a generated top-level +// message carries. Copies of that message use the same information. func (c *protobufPackageCatalog) bindRootSource(attribute *expr.AttributeExpr, source protobufMessageSource) { if attribute.Type == expr.Empty { return @@ -162,18 +188,18 @@ func (c *protobufPackageCatalog) bindRootSource(attribute *expr.AttributeExpr, s c.rootSources[userType.Origin()] = source } -// collectMessage records every protobuf declaration reachable from attribute. -// source identifies the service declaration or synthetic role carried by the -// root; nested authored declarations retain their own origins. -func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource, sd *ServiceData) []string { +// collectMessage records every protobuf message reachable from an endpoint's +// request, response, or error attribute. source identifies that top-level +// attribute. Nested user types keep their own declarations. +func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource) error { if c.frozen { panic("cannot collect a protobuf message after the package catalog is frozen") } - return c.collectMessageRecursive(attribute, source, true, nil, "", sd) + return c.collectMessageRecursive(attribute, source, true, nil, "") } -// freezeMessages assigns every declaration its final protobuf and generated Go -// names, binds all occurrences, and builds immutable template records. +// freezeMessages chooses the final protobuf and Go names for every message. It +// then connects each copied value to its message and prepares template data. func (c *protobufPackageCatalog) freezeMessages(sd *ServiceData) []*service.UserTypeData { c.freezeMessageNames() if c.messagesRendered { @@ -202,63 +228,49 @@ func (c *protobufPackageCatalog) freezeMessages(sd *ServiceData) []*service.User return c.messageData() } -// freezeMessageNames assigns every declaration its final package-level name -// without rendering .proto definitions. Transformation-only consumers use -// this phase because they need references but do not emit declarations. +// freezeMessageNames chooses the final package-level name for every message +// without creating its .proto definition. Conversion code uses this when it +// needs the Go type name but another service writes the message. func (c *protobufPackageCatalog) freezeMessageNames() { if c.frozen { return } c.frozen = true - used := make(map[string]struct{}, len(c.messages)) - counts := make(map[string]int, len(c.messages)) - for _, name := range c.reservedNames { - used[name] = struct{}{} - counts[name] = 1 + if c.plan == nil { + panic("protobuf message names were not planned") } for _, record := range c.messages { - record.name = uniqueProtobufName(record.identity.preferredName, used, counts) + record.name = record.declaration.Name() record.goRef = "*" + c.packageName + "." + record.name } for _, record := range c.unions { - record.name = record.owner.name + "_" + protoBufify(record.fieldName, true, true) + record.name = c.plan.oneofInterfaceName(record) } } -// collectValidation records the validation helper needed for attribute and all -// nested protobuf message declarations on one generated side. -func (c *protobufPackageCatalog) collectValidation(attribute *expr.AttributeExpr, side validateKind, targetName, contextName string) { - if !c.frozen { - panic("cannot collect protobuf validators before message declarations freeze") - } +// collectValidation records the validation function needed for attribute and +// every nested protobuf message it can call. +func (c *protobufPackageCatalog) collectValidation(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string) { if c.validationsFrozen { panic("cannot collect a protobuf validator after validators freeze") } - c.collectValidationRecursive(attribute, side, targetName, contextName, make(map[*protobufValidationRecord]struct{})) + c.collectValidationRecursive(attribute, side, source, targetName, contextName, make(map[*protobufValidationRecord]struct{})) } -// freezeValidations assigns helper names independently in the generated client -// and server packages, then renders definitions through those frozen records. +// freezeValidations builds each validation function with the name already +// chosen for its generated client or server package. func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*ValidationData { if c.validationsFrozen { return c.validationData() } c.validationsFrozen = true - used := map[validateKind]map[string]struct{}{ - validateServer: {}, - validateClient: {}, - } - counts := map[validateKind]map[string]int{ - validateServer: {}, - validateClient: {}, - } - for _, record := range c.validators { - base := "Validate" + record.declaration.name - record.name = uniqueProtobufName(base, used[record.side], counts[record.side]) - } for _, record := range c.validators { + if record.declaration == nil { + panic(fmt.Sprintf("protobuf validator for %q has no generated declaration", record.message.plannedName)) + } validationAttribute := expr.DupAtt(record.attribute) - c.bindEquivalentMessageUses(record.attribute, validationAttribute, make(map[protobufAttributePair]struct{})) + c.plan.bindAttributeCopy(record.attribute, validationAttribute) + c.bindCopiedValidationUses(record.attribute, validationAttribute, record.side) removeMeta(validationAttribute) userType := validationAttribute.Type.(expr.UserType) context := protoBufTypeContext(c.packageName, sd, false) @@ -266,6 +278,8 @@ func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*Validatio protoBufScope: context.Scope.(*protoBufScope), catalog: c, side: record.side, + message: record.message, + parent: userType, } definition := codegen.AttributeValidationCode( userTypeAttribute(userType), @@ -280,122 +294,88 @@ func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*Validatio continue } record.data = &ValidationData{ - Name: record.name, - Def: definition, - ArgName: record.targetName, - SrcName: record.declaration.name, - SrcRef: record.declaration.goRef, - Kind: record.side, + Declaration: record.declaration, + Name: record.declaration.Name(), + Def: definition, + ArgName: record.targetName, + SrcName: record.message.name, + SrcRef: record.message.goRef, + Kind: record.side, } } return c.validationData() } -// message returns the frozen declaration bound to attribute. +// message returns the completed message used for attribute. func (c *protobufPackageCatalog) message(attribute *expr.AttributeExpr) *protobufMessageRecord { if !c.frozen { panic("cannot resolve a protobuf message before the package catalog freezes") } - if record := c.messageUses[attribute]; record != nil { - return record - } - if _, ok := attribute.Type.(expr.UserType); !ok { - return nil - } - userType := attribute.Type.(expr.UserType) - source := protobufMessageSource{origin: userType.Origin()} - if synthetic, ok := c.rootSources[userType.Origin()]; ok { - source = synthetic - } - identity := protobufMessageIdentityFor(attribute, source) - for _, record := range c.messages { - if sameProtobufMessageIdentity(record.identity, identity) { - return record - } - } - if len(userType.Attribute().Meta[wrappedAttrMeta]) > 0 { - identity.source = protobufMessageSource{synthetic: protobufSyntheticMessage{role: protobufWrapperMessage}} - for _, record := range c.messages { - if sameProtobufMessageIdentity(record.identity, identity) { - return record - } - } - } - return nil + return c.messageRecord(attribute) } -// unionName returns the frozen helper identity for one oneof declaration. +// messageRecord returns the protobuf message collected for attribute. It may +// be called before package names are fixed. +func (c *protobufPackageCatalog) messageRecord(attribute *expr.AttributeExpr) *protobufMessageRecord { + return c.messageUses[attribute] +} + +// unionName returns the chosen Go interface name for one oneof declaration. func (c *protobufPackageCatalog) unionName(attribute *expr.AttributeExpr) string { if !c.frozen { panic("cannot resolve a protobuf oneof before the package catalog freezes") } record := c.unionUses[attribute] - if record == nil { - for _, candidate := range c.unions { - if !sameProtobufWireAttribute(candidate.attribute, attribute, make(map[protobufAttributePair]struct{})) { - continue - } - if record != nil && record != candidate { - panic(fmt.Sprintf("protobuf oneof %q matches multiple frozen declarations", attribute.Type.Name())) - } - record = candidate - } - } if record == nil { panic(fmt.Sprintf("protobuf oneof %q has no frozen declaration", attribute.Type.Name())) } return record.name } -// validation returns the frozen validator bound to attribute on side. +// validation returns the completed validation function used for attribute in +// the client or server package. func (c *protobufPackageCatalog) validation(attribute *expr.AttributeExpr, side validateKind) *ValidationData { if !c.validationsFrozen { panic("cannot resolve a protobuf validator before validators freeze") } - declaration := c.message(attribute) - if declaration == nil { + record := c.validationUses[protobufValidationUse{attribute: attribute, side: side}] + if record == nil { return nil } - for _, record := range c.validators { - if record.side == side && record.declaration == declaration && - sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { - return record.data - } - } - return nil + return record.data } -// Name returns the frozen protobuf declaration name, or the validator-specific -// source name while validation code is being rendered. -func (s *protobufValidationScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { - if validator := s.catalog.validationRecord(attribute, s.side); validator != nil { - return strings.TrimPrefix(validator.name, "Validate") +// Ref returns the current message name when union validation asks for its +// parent type. Other values use the protobuf package records. +func (s *protobufValidationScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + if attribute.Type == s.parent { + name := s.message.name + if pkg != "" { + name = pkg + "." + name + } + return "*" + name } - return s.protoBufScope.Name(attribute, pkg, pointer, useDefault) + return s.protoBufScope.Ref(attribute, pkg) } -// ValidatorName returns the exact side-specific protobuf validator retained by -// the package catalog. -func (s *protobufValidationScope) ValidatorName(attribute *expr.AttributeExpr, _ string) string { +// ValidatorCall returns a call to the validation function written to the +// current client or server package. +func (s *protobufValidationScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, _ string) string { validator := s.catalog.validationRecord(attribute, s.side) if validator == nil { panic("protobuf validator was not retained") } - return validator.name + return fmt.Sprintf("%s(%s)", validator.declaration.Name(), target) } -// collectMessageRecursive gathers imports and declarations while using record -// identity itself as the cycle guard. -func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string, sd *ServiceData) []string { +// collectMessageRecursive records messages and oneofs. Existing message +// records stop recursive user types. +func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string) error { if attribute == nil { return nil } - imports := protobufAttributeImports(attribute, sd) if expr.IsPrimitive(attribute.Type) { - if attribute.Type.Kind() == expr.AnyKind { - imports = append(imports, "google/protobuf/struct.proto") - } - return imports + return nil } switch actual := attribute.Type.(type) { case expr.UserType: @@ -413,27 +393,34 @@ func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.Attribu identitySource = source c.rootSources[origin] = source } - identity := protobufMessageIdentityFor(attribute, identitySource) + identity, err := protobufMessageIdentityFor(attribute, identitySource) + if err != nil { + return err + } record := c.findMessage(identity) if record != nil { record.uses = append(record.uses, attribute) c.messageUses[attribute] = record - c.bindEquivalentMessageUses(record.uses[0], attribute, make(map[protobufAttributePair]struct{})) - return imports + c.bindCopiedMessageUses(record.uses[0], attribute) + return nil } record = &protobufMessageRecord{identity: identity, uses: []*expr.AttributeExpr{attribute}} c.messages = append(c.messages, record) c.messageUses[attribute] = record - imports = append(imports, c.collectMessageRecursive(userTypeAttribute(actual), protobufMessageSource{}, false, record, "", sd)...) + return c.collectMessageRecursive(userTypeAttribute(actual), protobufMessageSource{}, false, record, "") case *expr.Object: for _, named := range *actual { - imports = append(imports, c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, named.Name, sd)...) + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, named.Name); err != nil { + return err + } } case *expr.Array: - imports = append(imports, c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem", sd)...) + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") case *expr.Map: - imports = append(imports, c.collectMessageRecursive(actual.KeyType, protobufMessageSource{}, false, owner, fieldName+"Key", sd)...) - imports = append(imports, c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem", sd)...) + if err := c.collectMessageRecursive(actual.KeyType, protobufMessageSource{}, false, owner, fieldName+"Key"); err != nil { + return err + } + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") case *expr.Union: if owner == nil { panic(fmt.Sprintf("protobuf oneof %q has no owning message", actual.Name())) @@ -453,56 +440,52 @@ func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.Attribu record.uses = append(record.uses, attribute) c.unionUses[attribute] = record for _, named := range actual.Values { - imports = append(imports, c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, fieldName+named.Name, sd)...) + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, fieldName+named.Name); err != nil { + return err + } } } - return imports + return nil } -// bindEquivalentMessageUses associates every nested declaration occurrence in -// a reused wire graph with the canonical records already collected for the -// first occurrence. -func (c *protobufPackageCatalog) bindEquivalentMessageUses(canonical, duplicate *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) { - pair := protobufAttributePair{left: canonical, right: duplicate} - if _, ok := seen[pair]; ok { - return - } - seen[pair] = struct{}{} - switch canonicalType := canonical.Type.(type) { - case expr.UserType: - if record := c.messageUses[canonical]; record != nil { - if c.messageUses[duplicate] == nil { - c.messageUses[duplicate] = record - record.uses = append(record.uses, duplicate) +// bindCopiedMessageUses records the message, choice, field, and wrapper names +// for every part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedMessageUses(original, copy *expr.AttributeExpr) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if record := c.messageUses[original]; record != nil { + if existing := c.messageUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two message declarations") + } + if c.messageUses[copy] == nil { + c.messageUses[copy] = record + record.uses = append(record.uses, copy) } } - duplicateType := duplicate.Type.(expr.UserType) - c.bindEquivalentMessageUses(userTypeAttribute(canonicalType), userTypeAttribute(duplicateType), seen) - case *expr.Object: - duplicateType := duplicate.Type.(*expr.Object) - for index, named := range *canonicalType { - c.bindEquivalentMessageUses(named.Attribute, (*duplicateType)[index].Attribute, seen) - } - case *expr.Array: - c.bindEquivalentMessageUses(canonicalType.ElemType, duplicate.Type.(*expr.Array).ElemType, seen) - case *expr.Map: - duplicateType := duplicate.Type.(*expr.Map) - c.bindEquivalentMessageUses(canonicalType.KeyType, duplicateType.KeyType, seen) - c.bindEquivalentMessageUses(canonicalType.ElemType, duplicateType.ElemType, seen) - case *expr.Union: - if record := c.unionUses[canonical]; record != nil { - c.unionUses[duplicate] = record - record.uses = append(record.uses, duplicate) + if record := c.unionUses[original]; record != nil { + if existing := c.unionUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two oneof declarations") + } + if c.unionUses[copy] == nil { + c.unionUses[copy] = record + record.uses = append(record.uses, copy) + } } - duplicateType := duplicate.Type.(*expr.Union) - for index, named := range canonicalType.Values { - c.bindEquivalentMessageUses(named.Attribute, duplicateType.Values[index].Attribute, seen) + }) +} + +// bindCopiedValidationUses records the validation function for each matching +// part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedValidationUses(original, copy *expr.AttributeExpr, side validateKind) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + record := c.validationUses[protobufValidationUse{attribute: original, side: side}] + if record != nil { + c.bindValidationUse(copy, side, record) } - } + }) } -// findUnion returns the oneof declaration with the same owning message, field, -// and typed wire schema. +// findUnion returns the oneof with the same parent message, field name, and +// branch types. func (c *protobufPackageCatalog) findUnion(owner *protobufMessageRecord, fieldName string, attribute *expr.AttributeExpr) *protobufUnionRecord { for _, record := range c.unions { if record.owner == owner && record.fieldName == fieldName && @@ -513,54 +496,62 @@ func (c *protobufPackageCatalog) findUnion(owner *protobufMessageRecord, fieldNa return nil } -// collectValidationRecursive declares one validator per message, rule graph, -// and generated side, then descends to the nested declarations it may call. -func (c *protobufPackageCatalog) collectValidationRecursive(attribute *expr.AttributeExpr, side validateKind, targetName, contextName string, seen map[*protobufValidationRecord]struct{}) { +// collectValidationRecursive records one function per message, set of rules, +// and client or server package, then visits the nested messages it may call. +func (c *protobufPackageCatalog) collectValidationRecursive(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string, seen map[*protobufValidationRecord]struct{}) { switch actual := attribute.Type.(type) { case expr.UserType: if expr.IsPrimitive(actual) { return } - declaration := c.message(attribute) - if declaration == nil { + policy := codegen.GoLayoutPolicy{IgnoreRequired: true} + if !codegen.NeedsValidation(userTypeAttribute(actual), policy) { + return + } + message := c.messageRecord(attribute) + if message == nil { panic(fmt.Sprintf("no protobuf declaration collected for validation type %q", actual.Name())) } - record := c.findValidation(declaration, attribute, side) + record := c.findValidation(message, attribute, side) if record == nil { record = &protobufValidationRecord{ - declaration: declaration, + message: message, attribute: attribute, + source: source, side: side, targetName: targetName, contextName: contextName, - uses: []*expr.AttributeExpr{attribute}, } c.validators = append(c.validators, record) - } else { - record.uses = append(record.uses, attribute) + } else if source.compare(record.source) < 0 { + record.source = source + record.targetName = targetName + record.contextName = contextName } + c.bindValidationUse(attribute, side, record) if _, ok := seen[record]; ok { return } seen[record] = struct{}{} - c.collectValidationRecursive(userTypeAttribute(actual), side, targetName, contextName, seen) + c.collectValidationRecursive(userTypeAttribute(actual), side, source, targetName, contextName, seen) case *expr.Object: for _, named := range *actual { - c.collectValidationRecursive(named.Attribute, side, codegen.Goify(named.Name, false), named.Name, seen) + c.collectValidationRecursive(named.Attribute, side, source.child(named.Name), codegen.Goify(named.Name, false), named.Name, seen) } case *expr.Array: - c.collectValidationRecursive(actual.ElemType, side, "elem", "elem", seen) + c.collectValidationRecursive(actual.ElemType, side, source.child("element"), "elem", "elem", seen) case *expr.Map: - c.collectValidationRecursive(actual.KeyType, side, "key", "key", seen) - c.collectValidationRecursive(actual.ElemType, side, "val", "val", seen) + c.collectValidationRecursive(actual.KeyType, side, source.child("key"), "key", "key", seen) + c.collectValidationRecursive(actual.ElemType, side, source.child("value"), "val", "val", seen) case *expr.Union: for _, named := range actual.Values { - c.collectValidationRecursive(named.Attribute, side, codegen.Goify(named.Name, false), named.Name, seen) + c.collectValidationRecursive(named.Attribute, side, source.child(named.Name), codegen.Goify(named.Name, false), named.Name, seen) } } } -// findMessage returns the existing declaration with the same typed identity. +// findMessage returns the message written for the same source type, requested +// name, and protobuf fields. func (c *protobufPackageCatalog) findMessage(identity protobufMessageIdentity) *protobufMessageRecord { for _, record := range c.messages { if sameProtobufMessageIdentity(record.identity, identity) { @@ -570,11 +561,11 @@ func (c *protobufPackageCatalog) findMessage(identity protobufMessageIdentity) * return nil } -// findValidation returns the existing validator with the same declaration, -// validation contract, and generated side. +// findValidation returns the existing function that checks the same message +// rules in the same client or server package. func (c *protobufPackageCatalog) findValidation(declaration *protobufMessageRecord, attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { for _, record := range c.validators { - if record.declaration == declaration && record.side == side && + if record.message == declaration && record.side == side && sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { return record } @@ -582,16 +573,63 @@ func (c *protobufPackageCatalog) findValidation(declaration *protobufMessageReco return nil } -// validationRecord resolves the validator called for a nested message use. +// validationRecord returns the validation function called for one copied +// message value. func (c *protobufPackageCatalog) validationRecord(attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { - declaration := c.message(attribute) - if declaration == nil { - return nil + return c.validationUses[protobufValidationUse{attribute: attribute, side: side}] +} + +// bindValidationUse records the function that checks one protobuf value in a +// generated client or server package. +func (c *protobufPackageCatalog) bindValidationUse(attribute *expr.AttributeExpr, side validateKind, record *protobufValidationRecord) { + key := protobufValidationUse{attribute: attribute, side: side} + if existing := c.validationUses[key]; existing != nil && existing != record { + panic("protobuf value is connected to two validation functions") + } + if c.validationUses[key] == nil { + c.validationUses[key] = record + record.uses = append(record.uses, attribute) + } +} + +// walkProtobufCopy visits matching parts of an original protobuf value and its +// copy. Recursive user types are visited once. +func walkProtobufCopy(original, copy *expr.AttributeExpr, visit func(*expr.AttributeExpr, *expr.AttributeExpr)) { + seen := make(map[protobufAttributePair]struct{}) + var walk func(*expr.AttributeExpr, *expr.AttributeExpr) + walk = func(original, copy *expr.AttributeExpr) { + pair := protobufAttributePair{left: original, right: copy} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + visit(original, copy) + switch originalType := original.Type.(type) { + case expr.UserType: + copyType := copy.Type.(expr.UserType) + walk(userTypeAttribute(originalType), userTypeAttribute(copyType)) + case *expr.Object: + copyType := copy.Type.(*expr.Object) + for index, field := range *originalType { + walk(field.Attribute, (*copyType)[index].Attribute) + } + case *expr.Array: + walk(originalType.ElemType, copy.Type.(*expr.Array).ElemType) + case *expr.Map: + copyType := copy.Type.(*expr.Map) + walk(originalType.KeyType, copyType.KeyType) + walk(originalType.ElemType, copyType.ElemType) + case *expr.Union: + copyType := copy.Type.(*expr.Union) + for index, branch := range originalType.Values { + walk(branch.Attribute, copyType.Values[index].Attribute) + } + } } - return c.findValidation(declaration, attribute, side) + walk(original, copy) } -// messageData returns only declarations with completed immutable render data. +// messageData returns only messages whose names and template data are complete. func (c *protobufPackageCatalog) messageData() []*service.UserTypeData { data := make([]*service.UserTypeData, 0, len(c.messages)) for _, record := range c.messages { @@ -602,7 +640,23 @@ func (c *protobufPackageCatalog) messageData() []*service.UserTypeData { return data } -// validationData returns only validators whose typed rules emit code. +// protoMessageData returns message records with the names written to the +// protobuf source file. +func (c *protobufPackageCatalog) protoMessageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data == nil { + continue + } + message := *record.data + message.Name = record.protoName + message.VarName = record.protoName + data = append(data, &message) + } + return data +} + +// validationData returns only validation functions that contain checks. func (c *protobufPackageCatalog) validationData() []*ValidationData { data := make([]*ValidationData, 0, len(c.validators)) for _, record := range c.validators { @@ -613,20 +667,58 @@ func (c *protobufPackageCatalog) validationData() []*ValidationData { return data } -// protobufMessageIdentityFor derives message identity without consulting a -// naming scope or rendered source. -func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMessageSource) protobufMessageIdentity { +// child returns the same endpoint value at one nested field or collection +// part. +func (s protobufValidationSource) child(name string) protobufValidationSource { + if s.path == "" { + s.path = name + } else { + s.path += "." + name + } + return s +} + +// compare orders endpoint values and their nested fields the same way even when +// the design lists them in a different order. +func (s protobufValidationSource) compare(other protobufValidationSource) int { + if result := strings.Compare(s.api, other.api); result != 0 { + return result + } + if result := strings.Compare(s.service, other.service); result != 0 { + return result + } + if result := strings.Compare(s.method, other.method); result != 0 { + return result + } + if result := strings.Compare(s.error, other.error); result != 0 { + return result + } + if s.role < other.role { + return -1 + } + if s.role > other.role { + return 1 + } + return strings.Compare(s.path, other.path) +} + +// protobufMessageIdentityFor records the source type, requested name, and +// protobuf fields that decide whether two uses share one message. +func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMessageSource) (protobufMessageIdentity, error) { userType := attribute.Type.(expr.UserType) if source.origin == nil && source.synthetic.role == 0 { source.origin = userType.Origin() } - preferred := protoBufify(userType.Name(), true, true) + preferred := codegen.ProtobufName(userType.Name()) explicit := false names := attribute.Meta["struct:name:proto"] if len(names) == 0 { names = userType.Attribute().Meta["struct:name:proto"] } if len(names) > 0 { + if !protobufExactNamePattern.MatchString(names[0]) { + return protobufMessageIdentity{}, fmt.Errorf("protobuf message name %q from struct:name:proto is not a valid protobuf identifier", names[0]) + } preferred = names[0] explicit = true } @@ -636,11 +728,12 @@ func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMe explicitName: explicit, userType: userType, attribute: userTypeAttribute(userType), - } + }, nil } -// sameProtobufMessageIdentity compares the typed source and every protobuf -// schema fact rather than expression hashes or generated names. +// sameProtobufMessageIdentity reports whether two requests have the same source +// type, requested name, and protobuf fields. It does not compare generated Go +// names. func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { if left.source != right.source || left.preferredName != right.preferredName || left.explicitName != right.explicitName { return false @@ -648,7 +741,8 @@ func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { return sameProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) } -// sameProtobufWireAttribute compares facts that affect a protobuf declaration. +// sameProtobufWireAttribute reports whether two values produce the same fields +// and rules in a .proto message. func sameProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { if left == right { return true @@ -674,8 +768,8 @@ func sameProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobu return sameProtobufWireType(left.Type, right.Type, seen) } -// sameProtobufWireType compares protobuf-native type, ordered field, oneof, -// collection, and nested declaration contracts. +// sameProtobufWireType reports whether two types produce the same protobuf type, +// including ordered fields, choices, arrays, maps, and nested messages. func sameProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) bool { if left.Kind() != right.Kind() { return false @@ -724,8 +818,8 @@ func sameProtobufWireType(left, right expr.DataType, seen map[protobufAttributeP } } -// sameProtobufValidationAttribute compares typed validation provenance and -// rules independently from protobuf wire declaration identity. +// sameProtobufValidationAttribute compares the source types and validation +// rules without comparing the protobuf message name. func sameProtobufValidationAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { if left == right { return true @@ -784,46 +878,6 @@ func sameProtobufValidationAttribute(left, right *expr.AttributeExpr, seen map[p } } -// protobufAttributeImports returns imports declared directly on attribute. -func protobufAttributeImports(attribute *expr.AttributeExpr, sd *ServiceData) []string { - proto := attribute.Meta["struct:field:proto"] - if len(proto) <= 1 { - return nil - } - protobufImport := proto[1] - for _, spec := range sd.Service.ProtoImports { - if spec.Path == protobufImport { - return nil - } - } - if len(proto) > 3 { - elements := strings.Split(proto[3], "/") - sd.Service.ProtoImports = append(sd.Service.ProtoImports, &codegen.ImportSpec{ - Path: proto[3], - Name: elements[len(elements)-1], - }) - } - return []string{protobufImport} -} - -// uniqueProtobufName reserves one deterministic package-level identifier. -func uniqueProtobufName(base string, used map[string]struct{}, counts map[string]int) string { - if _, ok := used[base]; !ok { - used[base] = struct{}{} - counts[base] = 1 - return base - } - for index := counts[base] + 1; ; index++ { - candidate := base + strconv.Itoa(index) - if _, ok := used[candidate]; ok { - continue - } - used[candidate] = struct{}{} - counts[base] = index - return candidate - } -} - // sameProtobufMeta compares metadata that changes protobuf field numbers, // external types, explicit names, wrapper layout, or JSON names. func sameProtobufMeta(left, right expr.MetaExpr) bool { diff --git a/grpc/codegen/protobuf_descriptor_plan_test.go b/grpc/codegen/protobuf_descriptor_plan_test.go new file mode 100644 index 0000000000..9c128f5c85 --- /dev/null +++ b/grpc/codegen/protobuf_descriptor_plan_test.go @@ -0,0 +1,349 @@ +// This file checks that a linked gRPC plan uses the Go names produced by the +// supported protobuf tools in every generated client and server file. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestPlanUsesNamesFromSupportedProtobufTools checks both method orders because +// changing source order must not change the Go declarations chosen for a file. +func TestPlanUsesNamesFromSupportedProtobufTools(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "unary-first" + if reverse { + name = "stream-first" + } + t.Run(name, func(t *testing.T) { + moduleDir, protoPath, generatedGo := renderProtobufDescriptorPlan(t, reverse) + descriptor := describeGeneratedProto(t, protoPath) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + serviceDescriptor := descriptor.GetService()[0] + messageDescriptor := messageWithOneof(t, descriptor, "result2_kind") + oneofDescriptor := messageDescriptor.GetOneofDecl()[0] + resetDescriptor := fieldNamed(t, messageDescriptor, "reset") + apiURLDescriptor := fieldNamed(t, messageDescriptor, "api_url") + dns2Descriptor := fieldNamed(t, messageDescriptor, "dns2_server") + stringDescriptor := fieldNamed(t, messageDescriptor, "string_") + oneofStringDescriptor := fieldNamed(t, messageDescriptor, "string_2") + require.NotEqual(t, stringDescriptor.GetName(), oneofStringDescriptor.GetName()) + require.NotNil(t, oneofStringDescriptor.OneofIndex) + packageName := descriptor.GetPackage() + serviceName := packageName + "." + serviceDescriptor.GetName() + messageName := packageName + "." + messageDescriptor.GetName() + outerStringName, ok := names.lookup(messageName+"."+stringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_", outerStringName) + oneofStringName, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_2", oneofStringName) + oneofStringWrapper, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocOneofWrapperName) + require.True(t, ok) + require.Equal(t, messageDescriptor.GetName()+"_String_2", oneofStringWrapper) + methodNames := make(map[string]string, len(serviceDescriptor.GetMethod())) + for _, method := range serviceDescriptor.GetMethod() { + methodNames[method.GetName()] = serviceName + "." + method.GetName() + } + + checks := []struct { + descriptor string + role protocNameRole + }{ + {messageName, protocMessageName}, + {messageName + "." + apiURLDescriptor.GetName(), protocFieldName}, + {messageName + "." + dns2Descriptor.GetName(), protocFieldName}, + {messageName + "." + stringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocOneofWrapperName}, + {messageName + "." + oneofDescriptor.GetName(), protocOneofFieldName}, + {messageName + "." + resetDescriptor.GetName(), protocOneofWrapperName}, + {serviceName, protocServiceClientName}, + {serviceName, protocServiceServerName}, + {methodNames["GetUrl2"], protocMethodName}, + {methodNames["SyncX509"], protocMethodName}, + {methodNames["SyncX509"], protocMethodClientStreamName}, + {methodNames["SyncX509"], protocMethodServerStreamName}, + } + declarations := declaredGoNames(t, + strings.TrimSuffix(protoPath, ".proto")+".pb.go", + strings.TrimSuffix(protoPath, ".proto")+"_grpc.pb.go", + ) + for _, check := range checks { + name, ok := names.lookup(check.descriptor, check.role) + require.True(t, ok, "%s was not recorded for %s", check.role, check.descriptor) + require.Contains(t, declarations, name, "the protobuf tools did not declare %s", name) + require.True(t, strings.Contains(generatedGo, name), "Goa did not use %s", name) + } + + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + require.Contains(t, string(protoSource), "message lower_snake_message {") + require.Contains(t, string(protoSource), "lower_snake_message lower = 2;") + require.Contains(t, string(protoSource), "message "+messageDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "oneof result2_kind {") + require.Contains(t, string(protoSource), "service "+serviceDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "rpc GetUrl2 (") + require.Contains(t, string(protoSource), "rpc CafRead (") + require.Contains(t, string(protoSource), "rpc SyncX509 (stream ") + compileProtobufDescriptorModule(t, moduleDir) + }) + } +} + +// TestPlanWritesLegalFieldAndOneofNames checks names that would make protoc +// reject the complete generated file if Goa wrote them unchanged. +func TestPlanWritesLegalFieldAndOneofNames(t *testing.T) { + _, protoPath, _ := renderProtobufDescriptorPlan(t, false) + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + + require.Contains(t, string(protoSource), "message LeadingDigit {") + require.Contains(t, string(protoSource), "optional string _123_field = 1;") + require.Contains(t, string(protoSource), "message UnicodeName {") + require.Contains(t, string(protoSource), "optional string caf_field = 1;") + require.Contains(t, string(protoSource), "oneof foo_bar_oneof {") + require.Contains(t, string(protoSource), "optional string foo_bar = 3;") + require.Contains(t, string(protoSource), "message CollisionReverse {") + require.Equal(t, 2, strings.Count(string(protoSource), "oneof foo_bar_oneof {")) +} + +// TestPlanRejectsIllegalExactProtobufName checks that an exact metadata name +// fails before Goa writes a protobuf file that protoc cannot parse. +func TestPlanRejectsIllegalExactProtobufName(t *testing.T) { + root := RunGRPCDSL(t, func() { + message := dsl.Type("Message", func() { + dsl.Meta("struct:name:proto", "123_message") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("invalid", func() { + dsl.Method("read", func() { + dsl.Payload(message) + dsl.Result(message) + dsl.GRPC(func() {}) + }) + }) + }) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + + _, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.EqualError(t, err, `service "invalid" protobuf message name "123_message" from struct:name:proto is not a valid protobuf identifier`) +} + +// renderProtobufDescriptorPlan writes one linked plan and returns the temporary +// module, its protobuf source file, and the Goa client and server source. +func renderProtobufDescriptorPlan(t *testing.T, reverse bool) (string, string, string) { + t.Helper() + root := RunGRPCDSL(t, protobufDescriptorPlanDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ProtoFiles()...) + + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + var protoPath string + var generated strings.Builder + for _, file := range files { + path, err := file.Render(moduleDir) + require.NoError(t, err) + if filepath.Ext(path) == ".proto" { + protoPath = path + continue + } + if strings.Contains(filepath.ToSlash(path), "/grpc/") { + source, err := os.ReadFile(path) + require.NoError(t, err) + generated.Write(source) + } + } + require.NotEmpty(t, protoPath) + return moduleDir, protoPath, generated.String() +} + +// protobufDescriptorPlanDSL returns a design that uses one object in unary and +// streaming messages and places its preferred protobuf name beside a service +// declaration with the same Go name. +func protobufDescriptorPlanDSL(reverse bool) func() { + return func() { + leadingDigit := dsl.Type("LeadingDigit", func() { + dsl.Field(1, "123_field", dsl.String, func() { + dsl.Meta("struct:field:name", "LeadingField") + }) + }) + unicodeName := dsl.Type("UnicodeName", func() { + dsl.Field(1, "caféField", dsl.String, func() { + dsl.Meta("struct:field:name", "CafeField") + }) + }) + collision := dsl.Type("Collision", func() { + dsl.OneOf("fooBar", func() { + dsl.Field(2, "text", dsl.String) + }) + dsl.Field(3, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + }) + collisionReverse := dsl.Type("CollisionReverse", func() { + dsl.Field(1, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + dsl.OneOf("fooBar", func() { + dsl.Field(2, "text", dsl.String) + }) + }) + shared := dsl.Type("Api2HttpServiceClient", func() { + dsl.Field(1, "api_url", dsl.String) + dsl.Field(2, "dns_2_server", dsl.String) + dsl.Field(5, "string", dsl.String) + dsl.OneOf("result_2_kind", func() { + dsl.Field(3, "http_2xx", dsl.String) + dsl.Field(4, "reset", dsl.String) + dsl.Field(6, "string", dsl.String) + }) + }) + lowerSnake := dsl.Type("LowerSnake", func() { + dsl.Meta("struct:name:proto", "lower_snake_message") + dsl.Field(1, "value", dsl.String) + }) + envelope := dsl.Type("API2Envelope", func() { + dsl.Field(1, "client", shared) + dsl.Field(2, "lower", lowerSnake) + dsl.Field(3, "leading", leadingDigit) + dsl.Field(4, "collision", collision) + dsl.Field(5, "unicode", unicodeName) + dsl.Field(6, "collisionReverse", collisionReverse) + }) + unary := func() { + dsl.Method("get_url2", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + stream := func() { + dsl.Method("sync_x509", func() { + dsl.StreamingPayload(envelope) + dsl.StreamingResult(envelope) + dsl.GRPC(func() {}) + }) + } + unicodeMethod := func() { + dsl.Method("café_read", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + dsl.Service("api2_http_service", func() { + if reverse { + stream() + unary() + unicodeMethod() + return + } + unary() + stream() + unicodeMethod() + }) + } +} + +// describeGeneratedProto asks protoc for the names and fields written in one +// generated protobuf source file. +func describeGeneratedProto(t *testing.T, protoPath string) *descriptorpb.FileDescriptorProto { + t.Helper() + descriptorPath := filepath.Join(t.TempDir(), "descriptor.pb") + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, + "--proto_path", filepath.Dir(protoPath), + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// messageWithOneof returns the message that declares the named choice field. +func messageWithOneof(t *testing.T, file *descriptorpb.FileDescriptorProto, name string) *descriptorpb.DescriptorProto { + t.Helper() + for _, message := range file.GetMessageType() { + for _, oneof := range message.GetOneofDecl() { + if oneof.GetName() == name { + return message + } + } + } + t.Fatalf("protobuf source did not declare oneof %q", name) + return nil +} + +// fieldNamed returns the field with the requested protobuf source name. +func fieldNamed(t *testing.T, message *descriptorpb.DescriptorProto, name string) *descriptorpb.FieldDescriptorProto { + t.Helper() + for _, field := range message.GetField() { + if field.GetName() == name { + return field + } + } + t.Fatalf("protobuf message %q did not declare field %q", message.GetName(), name) + return nil +} + +// writeProtobufDescriptorModule writes a module that imports this Goa checkout. +func writeProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + goaDirectory := strings.TrimSpace(string(output)) + require.NotEmpty(t, goaDirectory) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDirectory) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) +} + +// compileProtobufDescriptorModule compiles every package written by the plan. +func compileProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/grpc/codegen/protobuf_plan.go b/grpc/codegen/protobuf_plan.go new file mode 100644 index 0000000000..a17feccb88 --- /dev/null +++ b/grpc/codegen/protobuf_plan.go @@ -0,0 +1,921 @@ +// This file asks the supported protobuf tools for every Go name that Goa must +// reference, then stores those names before any generated file is built. +package codegen + +import ( + "cmp" + "fmt" + "sort" + "strconv" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufServicePlan stores one service's copied messages and the Go names + // that protoc and its Go plugins produce for them. + protobufServicePlan struct { + expression *expr.GRPCServiceExpr + catalog *protobufPackageCatalog + messages []*protobufEndpointMessages + protoPackage string + serviceName string + fileIndex int + order protobufServiceOrder + methods map[*expr.GRPCEndpointExpr]string + names map[protocNameKey]*codegen.NameDeclaration + localNames map[protocNameKey]string + fields map[*expr.AttributeExpr]protocNameKey + sourceFields map[*expr.AttributeExpr]string + sourceOneofs map[*expr.AttributeExpr]string + wrappers map[*expr.AttributeExpr]protocNameKey + oneofs map[*expr.AttributeExpr]protocNameKey + } + + // protobufNameGroup holds one name written to a .proto file and every Go name + // generated from it. If a Go name is already used, Goa adds the same number + // to the .proto name and asks the tools for the complete set again. + protobufNameGroup struct { + preferred string + name string + suffix int + message *protobufMessageRecord + method *expr.GRPCEndpointExpr + service bool + } + + // protobufServiceOrder holds the two names used to place services in a + // stable order. + protobufServiceOrder struct { + service string + api string + } +) + +// planProtobufServices records every protobuf declaration in the generated Go +// package that will contain it. +func planProtobufServices(generation *codegen.Generation, roots []*Plan) error { + groups := make(map[string][]*protobufServicePlan) + for _, rootPlan := range roots { + for _, service := range rootPlan.expressions { + pathName := rootPlan.packages[service].pathName + packagePath := generation.GenPkg() + "/grpc/" + pathName + "/" + pbPkgName + catalog := newProtobufPackageCatalog("") + messages, err := collectProtobufPackage(service, catalog) + if err != nil { + return fmt.Errorf("service %q %w", service.Name(), err) + } + plan := &protobufServicePlan{ + expression: service, + catalog: catalog, + messages: messages, + protoPackage: pkgName(service, pathName), + order: protobufServiceOrder{ + service: service.Name(), + api: rootPlan.root.API.Name, + }, + methods: make(map[*expr.GRPCEndpointExpr]string, len(service.GRPCEndpoints)), + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + catalog.plan = plan + rootPlan.protobuf[service] = plan + groups[packagePath] = append(groups[packagePath], plan) + } + } + for packagePath, group := range groups { + pkg, err := generation.ClaimPackage(packagePath) + if err != nil { + return err + } + sort.Slice(group, func(i, j int) bool { + if group[i].order.service != group[j].order.service { + return group[i].order.service < group[j].order.service + } + return group[i].order.api < group[j].order.api + }) + for index := 1; index < len(group); index++ { + if group[index-1].order == group[index].order { + return fmt.Errorf( + "generated protobuf package %q has two services named %q in API %q", + packagePath, + group[index].order.service, + group[index].order.api, + ) + } + } + for _, plan := range group[1:] { + if plan.protoPackage != group[0].protoPackage { + return fmt.Errorf("generated package %q cannot contain protobuf packages %q and %q", packagePath, group[0].protoPackage, plan.protoPackage) + } + } + used := make(map[string]struct{}) + for index, plan := range group { + plan.fileIndex = index + 1 + if err := plan.chooseNames(pkg, used); err != nil { + return fmt.Errorf("plan protobuf names for service %q: %w", plan.expression.Name(), err) + } + } + } + return nil +} + +// name returns one Go name produced by the supported protobuf tools. +func (p *protobufServicePlan) name(descriptor string, role protocNameRole) string { + key := protocNameKey{descriptor: descriptor, role: role} + if declaration := p.names[key]; declaration != nil { + return declaration.Name() + } + name, ok := p.localNames[key] + if !ok { + panic(fmt.Sprintf("protobuf Go name %s for %q was not planned", role, descriptor)) + } + return name +} + +// fieldName returns the Go field name produced for one copied protobuf field. +func (p *protobufServicePlan) fieldName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.fields[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// sourceFieldName returns the field name written to the protobuf file. +func (p *protobufServicePlan) sourceFieldName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceFields[attribute] + if !ok { + panic("protobuf source field was not planned") + } + return name +} + +// sourceOneofName returns the oneof name written to the protobuf file. +func (p *protobufServicePlan) sourceOneofName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceOneofs[attribute] + if !ok { + panic("protobuf source oneof was not planned") + } + return name +} + +// wrapperName returns the Go wrapper type for one branch in one parent +// message. +func (p *protobufServicePlan) wrapperName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.wrappers[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// oneofInterfaceName returns the Go interface produced for one oneof. +func (p *protobufServicePlan) oneofInterfaceName(record *protobufUnionRecord) string { + key, ok := p.oneofs[record.attribute] + if !ok { + panic("protobuf oneof interface name was not planned") + } + return p.name(key.descriptor, key.role) +} + +// bindAttributeCopy records every message, choice, field, and wrapper name for +// the matching parts of a copied protobuf value. +func (p *protobufServicePlan) bindAttributeCopy(original, copy *expr.AttributeExpr) { + p.catalog.bindCopiedMessageUses(original, copy) + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if key, ok := p.fields[original]; ok { + p.fields[copy] = key + } + if name, ok := p.sourceFields[original]; ok { + p.sourceFields[copy] = name + } + if name, ok := p.sourceOneofs[original]; ok { + p.sourceOneofs[copy] = name + } + if key, ok := p.wrappers[original]; ok { + p.wrappers[copy] = key + } + if key, ok := p.oneofs[original]; ok { + p.oneofs[copy] = key + } + }) +} + +// chooseNames tries numbered protobuf names until every generated Go name is +// unique in the package. +func (p *protobufServicePlan) chooseNames(pkg *codegen.GeneratedPackage, used map[string]struct{}) error { + groups, err := p.nameGroups() + if err != nil { + return err + } + p.assignInitialNames(groups) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + if err != nil { + return err + } + for attempts := 0; attempts < 1000; attempts++ { + descriptor, owners, err := p.namingDescriptor(groups) + if err != nil { + return err + } + generated, err := rules.file(descriptor) + if err != nil { + return err + } + colliding := collidingProtobufGroup(generated, owners, groups, used) + if colliding == nil { + if err := p.declareNames(pkg, generated); err != nil { + return err + } + for key, name := range generated.values { + if _, packageName := protocPackageNameKind(key.role); packageName { + used[name] = struct{}{} + } + } + return nil + } + colliding.name = nextAvailableProtobufName(colliding, groups) + } + return fmt.Errorf("could not choose unique protobuf Go names") +} + +// nameGroups puts the service and methods before messages. This keeps the +// requested service and method names when a message would generate the same Go +// name. +func (p *protobufServicePlan) nameGroups() ([]*protobufNameGroup, error) { + service := &protobufNameGroup{ + preferred: codegen.ProtobufName(p.expression.Name()), + service: true, + } + methods := make([]*protobufNameGroup, 0, len(p.expression.GRPCEndpoints)) + for _, endpoint := range p.expression.GRPCEndpoints { + methods = append(methods, &protobufNameGroup{ + preferred: codegen.ProtobufName(endpoint.Name()), + method: endpoint, + }) + } + sort.Slice(methods, func(i, j int) bool { + return compareProtobufEndpointSource(methods[i].method, methods[j].method) < 0 + }) + messages := make([]*protobufNameGroup, 0, len(p.catalog.messages)) + for _, message := range p.catalog.messages { + messages = append(messages, &protobufNameGroup{ + preferred: message.identity.preferredName, + message: message, + }) + } + if err := sortProtobufMessageGroups(messages); err != nil { + return nil, err + } + groups := []*protobufNameGroup{service} + groups = append(groups, methods...) + return append(groups, messages...), nil +} + +// assignInitialNames makes message and service names unique in the file. Method +// names use a separate list because protobuf allows the same method name in a +// different service. +func (p *protobufServicePlan) assignInitialNames(groups []*protobufNameGroup) { + packageNames := make(map[string]struct{}) + methodNames := make(map[string]struct{}) + for _, group := range groups { + used := packageNames + if group.method != nil { + used = methodNames + } + assignProtobufGroupName(group, used) + } +} + +// namingDescriptor builds a small protobuf file containing only declarations +// that can change generated Go names. A field's type cannot change its Go name, +// so these temporary fields all use string. +func (p *protobufServicePlan) namingDescriptor(groups []*protobufNameGroup) (*descriptorpb.FileDescriptorProto, map[protocNameKey]*protobufNameGroup, error) { + owners := make(map[protocNameKey]*protobufNameGroup) + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("goa_names.proto"), + Package: proto.String(p.protoPackage), + Syntax: proto.String(ProtoVersion), + Options: &descriptorpb.FileOptions{GoPackage: proto.String("/" + p.protoPackage + "pb")}, + } + var serviceGroup *protobufNameGroup + for _, group := range groups { + switch { + case group.service: + serviceGroup = group + p.serviceName = group.name + case group.method != nil: + p.methods[group.method] = group.name + case group.message != nil: + group.message.protoName = group.name + message, keys := p.messageDescriptor(group.message) + for _, use := range group.message.uses { + useType := use.Type.(expr.UserType) + p.bindAttributeCopy(group.message.identity.attribute, userTypeAttribute(useType)) + } + file.MessageType = append(file.MessageType, message) + for _, key := range keys { + owners[key] = group + } + } + } + service, keys, err := p.serviceDescriptor(serviceGroup, groups) + if err != nil { + return nil, nil, err + } + file.Service = []*descriptorpb.ServiceDescriptorProto{service} + for _, key := range keys { + owner := serviceGroup + for _, group := range groups { + if group.method != nil && key.descriptor == p.serviceFullName()+"."+group.name { + owner = group + break + } + } + owners[key] = owner + } + return file, owners, nil +} + +// messageDescriptor records one message's fields and oneofs for protogen. +func (p *protobufServicePlan) messageDescriptor(record *protobufMessageRecord) (*descriptorpb.DescriptorProto, []protocNameKey) { + message := &descriptorpb.DescriptorProto{Name: proto.String(record.protoName)} + fullName := p.protoPackage + "." + record.protoName + keys := []protocNameKey{{descriptor: fullName, role: protocMessageName}} + usedFieldNames := make(map[string]struct{}) + fieldNames := make(map[*expr.AttributeExpr]string) + fieldNumber := int32(1) + attribute := record.identity.attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + object := expr.AsObject(attribute.Type) + if object == nil { + if union, ok := attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + oneofName := uniqueProtobufOneofSourceName(union.Name(), usedFieldNames) + p.addOneofDescriptor(message, fullName, oneofName, attribute, union, fieldNames, &fieldNumber, &keys) + } + return message, keys + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + continue + } + fieldNames[named.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(named.Name), usedFieldNames) + } + oneofNames := make(map[*expr.AttributeExpr]string) + for _, named := range *object { + if _, ok := named.Attribute.Type.(*expr.Union); ok { + oneofNames[named.Attribute] = uniqueProtobufOneofSourceName(named.Name, usedFieldNames) + } + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + p.addOneofDescriptor(message, fullName, oneofNames[named.Attribute], named.Attribute, union, fieldNames, &fieldNumber, &keys) + continue + } + fieldName := fieldNames[named.Attribute] + message.Field = append(message.Field, namingField(fieldName, fieldNumber, nil)) + fieldNumber++ + key := protocNameKey{descriptor: fullName + "." + fieldName, role: protocFieldName} + p.fields[named.Attribute] = key + p.sourceFields[named.Attribute] = fieldName + keys = append(keys, key) + } + return message, keys +} + +// addOneofDescriptor records one oneof and each branch field after every source +// name in the message has been selected. +func (p *protobufServicePlan) addOneofDescriptor(message *descriptorpb.DescriptorProto, messageName, oneofName string, attribute *expr.AttributeExpr, union *expr.Union, fieldNames map[*expr.AttributeExpr]string, fieldNumber *int32, keys *[]protocNameKey) { + index := int32(len(message.OneofDecl)) + message.OneofDecl = append(message.OneofDecl, &descriptorpb.OneofDescriptorProto{Name: proto.String(oneofName)}) + fieldKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofFieldName} + interfaceKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofInterfaceName} + p.fields[attribute] = fieldKey + p.sourceOneofs[attribute] = oneofName + p.oneofs[attribute] = interfaceKey + *keys = append(*keys, fieldKey, interfaceKey) + for _, branch := range union.Values { + name := fieldNames[branch.Attribute] + message.Field = append(message.Field, namingField(name, *fieldNumber, &index)) + *fieldNumber++ + fieldKey := protocNameKey{descriptor: messageName + "." + name, role: protocFieldName} + wrapperKey := protocNameKey{descriptor: messageName + "." + name, role: protocOneofWrapperName} + p.fields[branch.Attribute] = fieldKey + p.sourceFields[branch.Attribute] = name + p.wrappers[branch.Attribute] = wrapperKey + *keys = append(*keys, fieldKey, wrapperKey) + } +} + +// serviceDescriptor records the service methods and their stream directions. +func (p *protobufServicePlan) serviceDescriptor(serviceGroup *protobufNameGroup, groups []*protobufNameGroup) (*descriptorpb.ServiceDescriptorProto, []protocNameKey, error) { + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String(serviceGroup.name)} + serviceName := p.protoPackage + "." + serviceGroup.name + keys := serviceNameKeys(serviceName) + for _, group := range groups { + if group.method == nil { + continue + } + endpoint := group.method + index := slicesIndexEndpoint(p.expression.GRPCEndpoints, endpoint) + if index < 0 { + return nil, nil, fmt.Errorf("method %q is not part of service %q", endpoint.Name(), p.expression.Name()) + } + messages := p.messages[index] + request := p.catalog.messageUses[messages.request] + if messages.requestEnvelope != nil { + request = p.catalog.messageUses[messages.requestEnvelope] + } else if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + request = p.catalog.messageUses[messages.streamingRequest] + } + response := p.catalog.messageUses[messages.response] + if request == nil || response == nil { + return nil, nil, fmt.Errorf("method %q has no protobuf request or response message", endpoint.Name()) + } + method := &descriptorpb.MethodDescriptorProto{ + Name: proto.String(group.name), + InputType: proto.String("." + p.protoPackage + "." + request.protoName), + OutputType: proto.String("." + p.protoPackage + "." + response.protoName), + ClientStreaming: proto.Bool(endpoint.MethodExpr.IsPayloadStreaming()), + ServerStreaming: proto.Bool(endpoint.MethodExpr.IsResultStreaming()), + } + service.Method = append(service.Method, method) + keys = append(keys, methodNameKeys(serviceName+"."+group.name, endpoint.MethodExpr.IsStreaming())...) + } + return service, keys, nil +} + +// declareNames stores package-level Go names with the package that writes them. +// It stores field and method names directly because they cannot collide with +// names outside their message or service. +func (p *protobufServicePlan) declareNames(pkg *codegen.GeneratedPackage, generated *protocNames) error { + for key, name := range generated.values { + kind, packageName := protocPackageNameKind(key.role) + if !packageName { + p.localNames[key] = name + continue + } + declaration := codegen.NewExactName(kind, name) + if err := pkg.DeclareName(declaration); err != nil { + return err + } + p.names[key] = declaration + } + for _, record := range p.catalog.messages { + key := protocNameKey{ + descriptor: p.protoPackage + "." + record.protoName, + role: protocMessageName, + } + record.plannedName = generated.values[key] + record.declaration = p.names[key] + if record.declaration == nil { + return fmt.Errorf("protobuf message %q has no generated Go declaration", record.protoName) + } + } + return nil +} + +// collidingProtobufGroup returns the first group that would generate a Go name +// already used in the package. +func collidingProtobufGroup(names *protocNames, owners map[protocNameKey]*protobufNameGroup, groups []*protobufNameGroup, occupied map[string]struct{}) *protobufNameGroup { + byGroup := make(map[*protobufNameGroup][]string) + for key, name := range names.values { + _, packageName := protocPackageNameKind(key.role) + if packageName { + byGroup[owners[key]] = append(byGroup[owners[key]], name) + } + } + used := make(map[string]struct{}, len(occupied)) + for name := range occupied { + used[name] = struct{}{} + } + for _, group := range groups { + for _, name := range byGroup[group] { + if _, ok := used[name]; ok { + return group + } + } + for _, name := range byGroup[group] { + used[name] = struct{}{} + } + } + return nil +} + +// protocPackageNameKind identifies names declared at package level. +func protocPackageNameKind(role protocNameRole) (codegen.PackageNameKind, bool) { + switch role { + case protocMessageName, protocEnumName, protocOneofInterfaceName, protocOneofWrapperName, + protocServiceClientName, protocServiceClientStructName, protocServiceServerName, + protocServiceUnimplementedServerName, protocServiceUnsafeServerName, + protocMethodClientStreamName, protocMethodServerStreamName: + return codegen.NameType, true + case protocServiceClientConstructorName, protocServiceRegisterName, protocMethodHandlerName: + return codegen.NameFunction, true + case protocMethodFullName: + return codegen.NameConstant, true + case protocServiceDescriptorName: + return codegen.NameVariable, true + default: + return 0, false + } +} + +// sortProtobufMessageGroups puts messages in the same order for every input +// order. It reports separate declarations when their source, requested name, +// and protobuf fields cannot choose which one comes first. +func sortProtobufMessageGroups(groups []*protobufNameGroup) error { + sort.Slice(groups, func(i, j int) bool { + return compareProtobufMessageIdentity(groups[i].message.identity, groups[j].message.identity) < 0 + }) + for index := 1; index < len(groups); index++ { + left := groups[index-1].message.identity + right := groups[index].message.identity + if compareProtobufMessageIdentity(left, right) == 0 && !sameProtobufMessageIdentity(left, right) { + return fmt.Errorf("protobuf messages named %q have the same source, name, and fields but come from separate declarations", left.preferredName) + } + } + return nil +} + +// compareProtobufMessageIdentity compares the source, requested name, and +// protobuf fields that decide whether two values use one message. +func compareProtobufMessageIdentity(left, right protobufMessageIdentity) int { + if order := compareProtobufMessageSource(left.source, right.source); order != 0 { + return order + } + if order := cmp.Compare(left.preferredName, right.preferredName); order != 0 { + return order + } + if order := compareBool(left.explicitName, right.explicitName); order != 0 { + return order + } + return compareProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// compareProtobufMessageSource compares the design declaration or method value +// that produced a protobuf message. +func compareProtobufMessageSource(left, right protobufMessageSource) int { + leftAuthored := left.origin != nil + rightAuthored := right.origin != nil + if order := compareBool(leftAuthored, rightAuthored); order != 0 { + return order + } + if leftAuthored { + if left.origin == right.origin { + return 0 + } + if order := cmp.Compare(left.origin.ID(), right.origin.ID()); order != 0 { + return order + } + return cmp.Compare(left.origin.Name(), right.origin.Name()) + } + if order := cmp.Compare(left.synthetic.role, right.synthetic.role); order != 0 { + return order + } + if order := compareProtobufEndpointSource(left.synthetic.endpoint, right.synthetic.endpoint); order != 0 { + return order + } + return compareProtobufErrorSource(left.synthetic.error, right.synthetic.error) +} + +// compareProtobufEndpointSource compares the service and method names that +// produced a generated request or response message. +func compareProtobufEndpointSource(left, right *expr.GRPCEndpointExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + if order := cmp.Compare(left.Service.Name(), right.Service.Name()); order != 0 { + return order + } + return cmp.Compare(left.Name(), right.Name()) +} + +// compareProtobufErrorSource compares the error names that produced generated +// error messages. +func compareProtobufErrorSource(left, right *expr.GRPCErrorExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + return cmp.Compare(left.Name, right.Name) +} + +// compareProtobufWireAttribute orders values by the description, protobuf +// settings, required primitive fields, and type written to the .proto file. +func compareProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) int { + if left == right { + return 0 + } + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(left.Description, right.Description); order != 0 { + return order + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return 0 + } + seen[pair] = struct{}{} + if order := compareProtobufMeta(left.Meta, right.Meta); order != 0 { + return order + } + if order := compareProtobufWireType(left.Type, right.Type, seen); order != 0 { + return order + } + leftObject, rightObject := expr.AsObject(left.Type), expr.AsObject(right.Type) + if leftObject == nil || rightObject == nil { + return 0 + } + for _, field := range *leftObject { + if !expr.IsPrimitive(field.Attribute.Type) { + continue + } + if order := compareBool(left.IsRequired(field.Name), right.IsRequired(field.Name)); order != 0 { + return order + } + } + return 0 +} + +// compareProtobufWireType orders values by the protobuf type and each nested +// field in the order Goa writes them. +func compareProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) int { + if order := cmp.Compare(left.Kind(), right.Kind()); order != 0 { + return order + } + switch left := left.(type) { + case expr.Primitive: + return cmp.Compare(left, right.(expr.Primitive)) + case expr.UserType: + right := right.(expr.UserType) + leftWrapped := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 + rightWrapped := len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + if order := compareBool(leftWrapped, rightWrapped); order != 0 { + return order + } + if !leftWrapped && left.Origin() != right.Origin() { + if order := cmp.Compare(left.Origin().ID(), right.Origin().ID()); order != 0 { + return order + } + if order := cmp.Compare(left.Origin().Name(), right.Origin().Name()); order != 0 { + return order + } + } + return compareProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if order := cmp.Compare(len(*left), len(*right)); order != 0 { + return order + } + for index, field := range *left { + other := (*right)[index] + if order := cmp.Compare(field.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(field.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + case *expr.Array: + return compareProtobufWireAttribute(left.ElemType, right.(*expr.Array).ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + if order := compareProtobufWireAttribute(left.KeyType, right.KeyType, seen); order != 0 { + return order + } + return compareProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + for _, order := range []int{ + cmp.Compare(left.TypeName, right.TypeName), + cmp.Compare(left.TypeKey, right.TypeKey), + cmp.Compare(left.ValueKey, right.ValueKey), + cmp.Compare(len(left.Values), len(right.Values)), + } { + if order != 0 { + return order + } + } + for index, branch := range left.Values { + other := right.Values[index] + if order := cmp.Compare(branch.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(branch.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// compareProtobufMeta compares settings that change protobuf field numbers, +// names, external types, or generated wrappers. +func compareProtobufMeta(left, right expr.MetaExpr) int { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if order := compareStringList(left[name], right[name]); order != 0 { + return order + } + } + return 0 +} + +// compareStringList compares both presence and contents because a missing list +// and an empty list are separate metadata values. +func compareStringList(left, right []string) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(len(left), len(right)); order != 0 { + return order + } + for index, value := range left { + if order := cmp.Compare(value, right[index]); order != 0 { + return order + } + } + return 0 +} + +// compareBool orders false before true. +func compareBool(left, right bool) int { + switch { + case left == right: + return 0 + case left: + return 1 + default: + return -1 + } +} + +// uniqueProtobufSourceName adds a number until the name is unused in the set. +func uniqueProtobufSourceName(preferred string, used map[string]struct{}) string { + for index := 1; ; index++ { + name := preferred + if index > 1 { + name += strconv.Itoa(index) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + return name + } +} + +// assignProtobufGroupName stores both the selected name and its numeric suffix +// so later collision retries do not need to parse the name. +func assignProtobufGroupName(group *protobufNameGroup, used map[string]struct{}) { + for suffix := 1; ; suffix++ { + name := group.preferred + if suffix > 1 { + name += strconv.Itoa(suffix) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + group.name = name + group.suffix = suffix + return + } +} + +// nextAvailableProtobufName returns the next numbered name that is not used by +// another message, service, or method in the same protobuf file. +func nextAvailableProtobufName(changed *protobufNameGroup, groups []*protobufNameGroup) string { + preferred := changed.preferred + index := max(changed.suffix, 1) + for { + index++ + candidate := preferred + strconv.Itoa(index) + available := true + for _, group := range groups { + if group == changed || group.name != candidate { + continue + } + if (group.method == nil) == (changed.method == nil) { + available = false + break + } + } + if available { + changed.suffix = index + return candidate + } + } +} + +// namingField creates one field whose type is sufficient for Go name planning. +func namingField(name string, number int32, oneof *int32) *descriptorpb.FieldDescriptorProto { + field := &descriptorpb.FieldDescriptorProto{ + Name: proto.String(name), + Number: &number, + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + } + if oneof != nil { + field.OneofIndex = oneof + } + return field +} + +// protobufSourceFieldName returns the field spelling written to .proto. +func protobufSourceFieldName(name string) string { + return codegen.ProtobufFieldName(name) +} + +// uniqueProtobufOneofSourceName adds a suffix until the oneof name differs +// from every field, branch, and earlier oneof in the message. +func uniqueProtobufOneofSourceName(fieldName string, used map[string]struct{}) string { + name := codegen.ProtobufFieldName(fieldName) + for { + if _, ok := used[name]; !ok { + used[name] = struct{}{} + return name + } + name += "_oneof" + } +} + +// serviceNameKeys returns every package name written for one service. +func serviceNameKeys(descriptor string) []protocNameKey { + roles := []protocNameRole{ + protocServiceClientName, + protocServiceClientStructName, + protocServiceClientConstructorName, + protocServiceServerName, + protocServiceUnimplementedServerName, + protocServiceUnsafeServerName, + protocServiceRegisterName, + protocServiceDescriptorName, + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// methodNameKeys returns every name written for one method. +func methodNameKeys(descriptor string, streaming bool) []protocNameKey { + roles := []protocNameRole{protocMethodName, protocMethodFullName, protocMethodHandlerName} + if streaming { + roles = append(roles, protocMethodClientStreamName, protocMethodServerStreamName) + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// serviceFullName returns the current service descriptor name. +func (p *protobufServicePlan) serviceFullName() string { + return p.protoPackage + "." + p.serviceName +} + +// slicesIndexEndpoint returns endpoint's position in endpoints. +func slicesIndexEndpoint(endpoints []*expr.GRPCEndpointExpr, endpoint *expr.GRPCEndpointExpr) int { + for index, candidate := range endpoints { + if candidate == endpoint { + return index + } + } + return -1 +} diff --git a/grpc/codegen/protobuf_plan_order_test.go b/grpc/codegen/protobuf_plan_order_test.go new file mode 100644 index 0000000000..18137523c5 --- /dev/null +++ b/grpc/codegen/protobuf_plan_order_test.go @@ -0,0 +1,216 @@ +// This file checks that protobuf service ordering has one clear result for +// every generation input. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestSortProtobufMessageGroupsUsesWireDetails checks that field numbers and +// explicit protobuf names give messages the same order when the input is reversed. +func TestSortProtobufMessageGroupsUsesWireDetails(t *testing.T) { + tests := []struct { + name string + groups func() []*protobufNameGroup + value func(*protobufNameGroup) string + want []string + }{ + { + name: "field numbers", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Message", false, "2"), + protobufOrderGroup(source, "Message", false, "1"), + } + }, + value: func(group *protobufNameGroup) string { + field := expr.AsObject(group.message.identity.attribute.Type).Attribute("value") + return field.Meta["rpc:tag"][0] + }, + want: []string{"1", "2"}, + }, + { + name: "explicit names", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Zulu", true, "1"), + protobufOrderGroup(source, "Alpha", true, "1"), + } + }, + value: func(group *protobufNameGroup) string { + return group.message.identity.preferredName + }, + want: []string{"Alpha", "Zulu"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + forward := test.groups() + reverse := test.groups() + reverse[0], reverse[1] = reverse[1], reverse[0] + + require.NoError(t, sortProtobufMessageGroups(forward)) + require.NoError(t, sortProtobufMessageGroups(reverse)) + require.Equal(t, test.want, protobufOrderValues(forward, test.value)) + require.Equal(t, test.want, protobufOrderValues(reverse, test.value)) + }) + } +} + +// TestSortProtobufMessageGroupsRejectsEqualOrder checks that two separate +// declarations cannot receive names according to their input order. +func TestSortProtobufMessageGroupsRejectsEqualOrder(t *testing.T) { + left := protobufOrderUserType() + right := protobufOrderUserType() + groups := []*protobufNameGroup{ + protobufOrderGroup(left, "Message", false, "1"), + protobufOrderGroup(right, "Message", false, "1"), + } + + err := sortProtobufMessageGroups(groups) + require.EqualError(t, err, `protobuf messages named "Message" have the same source, name, and fields but come from separate declarations`) +} + +// TestCompareProtobufMessageOrderReversesWithInputs checks objects whose fields +// and required lists appear in opposite orders. +func TestCompareProtobufMessageOrderReversesWithInputs(t *testing.T) { + source := protobufOrderUserType() + left := protobufRequiredOrderGroup(source, "a", "b") + right := protobufRequiredOrderGroup(source, "b", "a") + + forward := compareProtobufMessageIdentity(left.message.identity, right.message.identity) + backward := compareProtobufMessageIdentity(right.message.identity, left.message.identity) + require.NotZero(t, forward) + require.Equal(t, -forward, backward) + + forwardGroups := []*protobufNameGroup{left, right} + reverseGroups := []*protobufNameGroup{right, left} + require.NoError(t, sortProtobufMessageGroups(forwardGroups)) + require.NoError(t, sortProtobufMessageGroups(reverseGroups)) + require.Equal(t, []string{"a", "b"}, protobufFirstFieldNames(forwardGroups)) + require.Equal(t, []string{"a", "b"}, protobufFirstFieldNames(reverseGroups)) +} + +// TestNextAvailableProtobufNameUsesRetainedSuffix checks that collision retries +// do not parse the generated name Goa stored for the group. +func TestNextAvailableProtobufNameUsesRetainedSuffix(t *testing.T) { + group := &protobufNameGroup{preferred: "Message", name: "unrelated"} + occupied := &protobufNameGroup{preferred: "Message", name: "Message2"} + + next := nextAvailableProtobufName(group, []*protobufNameGroup{group, occupied}) + require.Equal(t, "Message3", next) +} + +// TestPlanProtobufServicesRejectsEqualOrder checks that two separate services +// cannot receive names according to their input order. +func TestPlanProtobufServicesRejectsEqualOrder(t *testing.T) { + roots := grpcPlanRoots(t, "Shared", "Shared") + roots[0].API.Name = "Shared API" + roots[1].API.Name = "Shared API" + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1]}) + require.NoError(t, err) + plans := []*Plan{ + { + root: roots[0], + expressions: roots[0].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[0].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + { + root: roots[1], + expressions: roots[1].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[1].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + } + + err = planProtobufServices(generation, plans) + require.EqualError(t, err, `generated protobuf package "generated.local/gen/grpc/shared/pb" has two services named "Shared" in API "Shared API"`) +} + +// protobufOrderUserType creates one separate source declaration for order +// tests. +func protobufOrderUserType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + TypeName: "Message", + UID: "message", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + }, + }, + }}, + } +} + +// protobufOrderGroup creates one message with the requested name and field +// number. +func protobufOrderGroup(source expr.UserType, name string, explicit bool, tag string) *protobufNameGroup { + attribute := expr.DupAtt(source.Attribute()) + expr.AsObject(attribute.Type).Attribute("value").Meta["rpc:tag"] = []string{tag} + return &protobufNameGroup{ + preferred: name, + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: name, + explicitName: explicit, + userType: source, + attribute: attribute, + }}, + } +} + +// protobufRequiredOrderGroup creates one message whose first field is required. +func protobufRequiredOrderGroup(source expr.UserType, first, second string) *protobufNameGroup { + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: first, Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: second, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Validation: &expr.ValidationExpr{Required: []string{first}}, + } + return &protobufNameGroup{ + preferred: "Message", + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: "Message", + userType: source, + attribute: attribute, + }}, + } +} + +// protobufOrderValues reads the fact checked by one order test from every +// message. +func protobufOrderValues(groups []*protobufNameGroup, value func(*protobufNameGroup) string) []string { + values := make([]string, len(groups)) + for index, group := range groups { + values[index] = value(group) + } + return values +} + +// protobufFirstFieldNames returns the first field from each ordered message. +func protobufFirstFieldNames(groups []*protobufNameGroup) []string { + names := make([]string, len(groups)) + for index, group := range groups { + names[index] = (*expr.AsObject(group.message.identity.attribute.Type))[0].Name + } + return names +} diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index a75fc8efbf..e4c79b8815 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -13,52 +13,6 @@ import ( "goa.design/goa/v3/expr" ) -func TestProtobufify(t *testing.T) { - cases := []struct { - Name string - String string - FirstUpper bool - Acronym bool - Expected string - }{{ - "AllLower", "lower", false, false, "lower", - }, { - "AllLowerFirstUpper", "lower", true, false, "Lower", - }, { - "AllUpper", "UPPER", false, false, "uPPER", - }, { - "AllUpperFirstUpper", "UPPER", true, false, "UPPER", - }, { - "StartUpperThenLower", "Upper", false, false, "upper", - }, { - "StartUpperThenLowerFirstUpper", "Upper", true, false, "Upper", - }, { - "StartsWithUnderscore", "_foo", false, false, "foo", - }, { - "EndsWithUnderscore", "foo_", false, false, "foo", - }, { - "ContainsUnderscore", "foo_bar", false, false, "fooBar", - }, { - "StartsWithDigits", "123foo", false, false, "123Foo", - }, { - "EndsWithDigits", "foo123", false, false, "foo123", - }, { - "ContainsDigits", "foo123bar", false, false, "foo123Bar", - }, { - "ContainsIgnoredAcronym", "foo_jwt", false, false, "fooJwt", - }, { - "ContainsAcronym", "foo_jwt", false, true, "fooJWT", - }} - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - got := protoBufify(c.String, c.FirstUpper, c.Acronym) - if got != c.Expected { - t.Errorf("got %q, expected %q", got, c.Expected) - } - }) - } -} - func TestProtoNativeType(t *testing.T) { cases := []struct { Name string diff --git a/grpc/codegen/protobuf_tools.go b/grpc/codegen/protobuf_tools.go new file mode 100644 index 0000000000..883ffd9282 --- /dev/null +++ b/grpc/codegen/protobuf_tools.go @@ -0,0 +1,169 @@ +// This file chooses the protobuf compiler and Go plugins before any generated +// file is built. +package codegen + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // protobufToolPlan stores the compiler, plugins, and include paths for one + // service. + protobufToolPlan struct { + command []string + includes []string + goPlugin string + goGRPCPlugin string + } + + // protobufToolResolver lets tests provide fixed program paths and versions + // without changing PATH or running real programs. + protobufToolResolver struct { + resolve func(string) (string, error) + version func(string) (string, error) + } +) + +const ( + protocGenGoName = "protoc-gen-go" + protocGenGoGRPCName = "protoc-gen-go-grpc" + protocGenGoVersion = "protoc-gen-go v1.36.12" + protocGenGoGRPCVersion = "protoc-gen-go-grpc 1.6.2" +) + +// planProtobufTools copies the compiler settings for every gRPC service and +// uses one checked pair of Go plugins for one call to NewPlans. +func planProtobufTools(inputs []PlanInput, resolver protobufToolResolver) (map[*expr.GRPCServiceExpr]*protobufToolPlan, error) { + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return nil, err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return nil, err + } + + plans := make(map[*expr.GRPCServiceExpr]*protobufToolPlan) + compilers := make(map[string]string) + for _, input := range inputs { + for _, service := range input.Root.API.GRPC.Services { + command := protobufCompilerCommand(input.Root, service) + if len(command) == 0 { + return nil, fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) + } + if plugin := replacedGoPlugin(command[1:]); plugin != "" { + return nil, fmt.Errorf(`Meta("protoc:cmd") cannot replace required plugin %q`, plugin) + } + compiler := compilers[command[0]] + if compiler == "" { + compiler, err = resolver.resolve(command[0]) + if err != nil { + return nil, fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + compilers[command[0]] = compiler + } + command[0] = compiler + includes := append([]string{}, service.ServiceExpr.Meta["protoc:include"]...) + includes = append(includes, input.Root.API.Meta["protoc:include"]...) + plans[service] = &protobufToolPlan{ + command: command, + includes: includes, + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + } + } + } + return plans, nil +} + +// systemProtobufTools uses the programs available to Goa. +func systemProtobufTools() protobufToolResolver { + return protobufToolResolver{ + resolve: resolveProtobufExecutable, + version: protobufExecutableVersion, + } +} + +// protobufCompilerCommand copies the command selected by the service or API. +func protobufCompilerCommand(root *expr.RootExpr, service *expr.GRPCServiceExpr) []string { + command := defaultProtocCmd + if configured, ok := root.API.Meta["protoc:cmd"]; ok { + command = configured + } + if configured, ok := service.ServiceExpr.Meta["protoc:cmd"]; ok { + command = configured + } + return append([]string{}, command...) +} + +// resolveProtobufPlugin finds one required plugin and checks its version. +func resolveProtobufPlugin(resolver protobufToolResolver, name, wantVersion string) (string, error) { + path, err := resolver.resolve(name) + if err != nil { + return "", fmt.Errorf("resolve protobuf plugin %s: %w", name, err) + } + version, err := resolver.version(path) + if err != nil { + return "", fmt.Errorf("read protobuf plugin %s version: %w", name, err) + } + if version != wantVersion { + return "", fmt.Errorf("protobuf plugin %s reports version %s, want %s", name, version, wantVersion) + } + return path, nil +} + +// resolveProtobufExecutable returns an absolute path for one executable. +func resolveProtobufExecutable(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", err + } + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("make executable path absolute: %w", err) + } + return path, nil +} + +// protobufExecutableVersion returns the single version line printed by an +// executable. +func protobufExecutableVersion(path string) (string, error) { + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("run %s --version: %w: %s", path, err, output) + } + return strings.TrimSpace(string(output)), nil +} + +// replacedGoPlugin returns a required plugin name when the flags try to +// replace it. +func replacedGoPlugin(arguments []string) string { + for index := 0; index < len(arguments); index++ { + argument := arguments[index] + var plugin string + switch { + case argument == "--plugin" && index+1 < len(arguments): + index++ + plugin = arguments[index] + case strings.HasPrefix(argument, "--plugin="): + plugin = strings.TrimPrefix(argument, "--plugin=") + default: + continue + } + var name string + if configuredName, _, ok := strings.Cut(plugin, "="); ok { + name = configuredName + } else { + name = filepath.Base(plugin) + } + if name == protocGenGoName || name == protocGenGoGRPCName { + return name + } + } + return "" +} diff --git a/grpc/codegen/protobuf_tools_test.go b/grpc/codegen/protobuf_tools_test.go new file mode 100644 index 0000000000..0977e8f3a4 --- /dev/null +++ b/grpc/codegen/protobuf_tools_test.go @@ -0,0 +1,217 @@ +// This file checks that gRPC planning fixes the protobuf commands before any +// generated file is rendered or compiled. +package codegen + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestNewPlansRetainsProtobufCommands checks that later design and PATH changes +// cannot replace the compiler, plugins, or include paths chosen by NewPlans. +func TestNewPlansRetainsProtobufCommands(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + recordPath := filepath.Join(t.TempDir(), "compiler-arguments") + compilerPath, err := filepath.Abs(os.Args[0]) + require.NoError(t, err) + root.API.Meta = make(expr.MetaExpr) + root.API.GRPC.Services[0].ServiceExpr.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = []string{ + compilerPath, + "-test.run=TestProtobufCompilerProcess", + "--", + recordPath, + } + root.API.Meta["protoc:include"] = []string{"api-before"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-before"} + + resolver := protobufToolResolver{ + resolve: func(name string) (string, error) { + switch name { + case compilerPath: + return compilerPath, nil + case protocGenGoName: + return "/planned/protoc-gen-go", nil + case protocGenGoGRPCName: + return "/planned/protoc-gen-go-grpc", nil + default: + t.Fatalf("unexpected executable lookup %q", name) + return "", nil + } + }, + version: func(path string) (string, error) { + switch path { + case "/planned/protoc-gen-go": + return protocGenGoVersion, nil + case "/planned/protoc-gen-go-grpc": + return protocGenGoGRPCVersion, nil + default: + t.Fatalf("unexpected version check %q", path) + return "", nil + } + }, + } + plans, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + + root.API.Meta["protoc:cmd"] = []string{"compiler-after"} + root.API.Meta["protoc:include"] = []string{"api-after"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-after"} + t.Setenv("PATH", t.TempDir()) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + grpcService := root.API.GRPC.Services[0] + renderData := newServicesData(services[0].Services(), plans[0]) + renderData.GRPCServices[grpcService.Name()] = &ServiceData{ + Service: services[0].Services().Get(grpcService.Name()), + } + files := protoFiles(renderData) + require.Len(t, files, 1) + t.Setenv("GO_WANT_PROTOBUF_COMPILER_PROCESS", "1") + require.NoError(t, files[0].FinalizeFunc(filepath.Join(t.TempDir(), "service.proto"))) + encoded, err := os.ReadFile(recordPath) + require.NoError(t, err) + arguments := strings.Split(string(encoded), "\n") + require.Contains(t, arguments, "--plugin=protoc-gen-go=/planned/protoc-gen-go") + require.Contains(t, arguments, "--plugin=protoc-gen-go-grpc=/planned/protoc-gen-go-grpc") + require.Contains(t, arguments, "service-before") + require.Contains(t, arguments, "api-before") + require.NotContains(t, arguments, "service-after") + require.NotContains(t, arguments, "api-after") +} + +// TestNewPlansChecksProtobufPluginVersions checks both required plugin +// versions before planning succeeds. +func TestNewPlansChecksProtobufPluginVersions(t *testing.T) { + tests := []struct { + name string + plugin string + gotVersion string + wantVersion string + }{ + {"Go plugin", protocGenGoName, "protoc-gen-go v1.36.11", protocGenGoVersion}, + {"gRPC plugin", protocGenGoGRPCName, "protoc-gen-go-grpc 1.6.1", protocGenGoGRPCVersion}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + resolver := fixedProtobufToolResolver() + resolver.version = func(path string) (string, error) { + if filepath.Base(path) == test.plugin { + return test.gotVersion, nil + } + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + } + _, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.EqualError(t, err, "protobuf plugin "+test.plugin+" reports version "+ + test.gotVersion+", want "+test.wantVersion) + }) + } +} + +// TestNewPlansRejectsGoPluginOverrides checks every protoc flag form that +// could replace either required Go plugin. +func TestNewPlansRejectsGoPluginOverrides(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"Go plugin equals", []string{"--plugin=protoc-gen-go=/other/go"}}, + {"gRPC plugin equals", []string{"--plugin=protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin separate", []string{"--plugin", "protoc-gen-go=/other/go"}}, + {"gRPC plugin separate", []string{"--plugin", "protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin path", []string{"--plugin=/other/protoc-gen-go"}}, + {"gRPC plugin path", []string{"--plugin=/other/protoc-gen-go-grpc"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + root.API.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = append([]string{"protoc"}, test.args...) + _, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.ErrorContains(t, err, `Meta("protoc:cmd") cannot replace`) + }) + } +} + +// TestProtobufCompilerProcess records the compiler arguments for its parent +// test and exits without compiling the schema. +func TestProtobufCompilerProcess(t *testing.T) { + if os.Getenv("GO_WANT_PROTOBUF_COMPILER_PROCESS") != "1" { + return + } + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } + } + if separator < 0 || len(os.Args) <= separator+1 { + os.Exit(2) + } + recordPath := os.Args[separator+1] + arguments := strings.Join(os.Args[separator+2:], "\n") + if err := os.WriteFile(recordPath, []byte(arguments), 0o600); err != nil { + os.Exit(3) + } + os.Exit(0) +} + +// fixedProtobufToolResolver returns stable paths and required versions. +func fixedProtobufToolResolver() protobufToolResolver { + return protobufToolResolver{ + resolve: func(name string) (string, error) { + return "/tools/" + filepath.Base(name), nil + }, + version: func(path string) (string, error) { + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + }, + } +} + +// protoc compiles a schema directly for tests that inspect protobuf output. +func protoc(command []string, path string) error { + if len(command) == 0 { + return fmt.Errorf("protobuf compiler command is empty") + } + resolver := systemProtobufTools() + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return err + } + compiler, err := resolver.resolve(command[0]) + if err != nil { + return fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + return runProtoc(&protobufToolPlan{ + command: append([]string{compiler}, command[1:]...), + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + }, path) +} diff --git a/grpc/codegen/protobuf_transform.go b/grpc/codegen/protobuf_transform.go index addd57b591..308f8cdd19 100644 --- a/grpc/codegen/protobuf_transform.go +++ b/grpc/codegen/protobuf_transform.go @@ -1,3 +1,4 @@ +// This file writes Go conversions between service values and protobuf values. package codegen import ( @@ -7,80 +8,66 @@ import ( "goa.design/goa/v3/expr" ) -// protoBufTransform produces Go code to initialize a data structure defined -// by target from an instance of data structure defined by source. The source -// or target is a protocol buffer type. The transformation is generated by the -// shared transform engine specialized via the protocol buffer transform hooks -// (see protoHooks). -// -// source, target are the source and target attributes used in transformation -// -// sourceVar, targetVar are the source and target variables -// -// sourceCtx, targetCtx are the source and target attribute contexts -// -// `proto` param if true indicates that the target is a protocol buffer type -// -// newVar if true initializes a target variable with the generated Go code -// using `:=` operator. If false, it assigns Go code to the target variable -// using `=`. +// protoBufTransform writes code that copies sourceVar into targetVar. One side +// is a service value and the other is a protobuf value. proto is true when the +// target is the protobuf value. newVar chooses between := and =. func protoBufTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, proto, newVar bool) (string, []*codegen.TransformFunctionData, error) { prefix := "protobuf" if proto { + original := target target = expr.DupAtt(target) + targetCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, target) removeMeta(target) prefix = "svc" } else { + original := source source = expr.DupAtt(source) + sourceCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, source) removeMeta(source) } ta := &codegen.TransformAttrs{ SourceCtx: sourceCtx, TargetCtx: targetCtx, Prefix: prefix, - Hooks: protoHooks(proto, targetCtx), + Hooks: protoHooks(proto), } return codegen.GoTransformWithAttrs(source, target, sourceVar, targetVar, ta, newVar) } -// removeMeta removes meta attributes from the given attribute that cannot be -// honored. This is needed to make sure that any field name overridding is -// removed when generating protobuf types (as protogen itself won't honor these -// overrides). +// removeMeta removes service field and package settings from a protobuf copy. +// The protobuf compiler does not use these settings when it writes Go types. func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { + err := codegen.Walk(att, func(a *expr.AttributeExpr) error { delete(a.Meta, "struct:field:name") delete(a.Meta, "struct:field:external") delete(a.Meta, "struct.field.external") // Deprecated syntax. Only present for backward compatibility. + delete(a.Meta, "struct:pkg:path") return nil }) + if err != nil { + panic(fmt.Sprintf("remove protobuf metadata: %s", err)) + } } -// convertType produces code to initialize a target type from a source type -// held by srcVar. proto is true when the transformation initializes a -// protocol buffer type. -// NOTE: For Int and UInt kinds, protocol buffer Go compiler generates -// int32 and uint32 respectively whereas Goa generates int and uint. +// convertType writes the expression that converts srcVar to the target type. +// proto is true when the target is a protobuf value. Protobuf uses int32 and +// uint32 where Goa uses int and uint. func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar string, proto bool, ta *codegen.TransformAttrs) string { + if protoUnionBranchUsesHelper(src, tgt) { + return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + } if expr.IsAlias(src.Type) || expr.IsAlias(tgt.Type) { srcp, tgtp := unAlias(src), unAlias(tgt) - if srcp.Type == tgtp.Type { - if proto { - return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) - } - return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) + if proto { + return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) } - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) - } - - if _, ok := src.Type.(expr.UserType); ok { - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) } srcType, _ := codegen.GetMetaType(src) tgtType, _ := codegen.GetMetaType(tgt) if srcType == "" && tgtType == "" && (src.Type != expr.Int) && (src.Type != expr.UInt) && (src.Type != expr.Any) { - // Nothing to do, except for Any type which needs special conversion + // Any values need a protobuf conversion. Other matching values do not. return srcVar } @@ -90,6 +77,17 @@ func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar strin return convertPrimitiveFromProto(src, tgt, srcPtr, tgtPtr, srcVar, ta) } +// protoUnionBranchUsesHelper reports whether protobuf union rendering emits a +// TransformHelperName call for the branch. Planning and rendering use this +// same rule so their helper order cannot differ. +func protoUnionBranchUsesHelper(source, target *expr.AttributeExpr) bool { + if expr.IsAlias(source.Type) || expr.IsAlias(target.Type) { + return unAlias(source).Type != unAlias(target).Type + } + _, named := source.Type.(expr.UserType) + return named +} + const convertGoAnyToProtobufValueFunc = `func() *structpb.Value { // Convert Go any to protobuf Value directly if %s == nil { @@ -113,7 +111,7 @@ const convertProtobufValueToGoAnyFunc = `func() any { // convertPrimitiveToProto returns the code to convert a primitive type to its // protocol buffer representation. func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string) string { - // Special handling for Any type conversion to google.protobuf.Value + // Any values use google.protobuf.Value in protobuf messages. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -132,7 +130,7 @@ func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar // convertPrimitiveFromProto returns the code to convert the protocol buffer // representation of a primitive type back to the service type. func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string, ta *codegen.TransformAttrs) string { - // Special handling for Any type conversion from google.protobuf.Value + // Any values arrive from protobuf as google.protobuf.Value. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -150,11 +148,3 @@ func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVa } return fmt.Sprintf("%s(%s)", tgtType, srcVar) } - -// protocOneofWrapperRef returns the reference to the Go wrapper type that -// protoc generates for a oneof field: it mirrors protoc generated Go oneof -// wrapper type naming which joins the parent message type name and the oneof -// field name with an underscore (Message_Field). -func protocOneofWrapperRef(message, fieldName string) string { - return message + "_" + fieldName -} diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index c6f6693daf..e57b4d449f 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -68,7 +68,6 @@ func TestProtoBufTransform(t *testing.T) { nat.Attribute.Type = expr.String } } - tc := map[string][]struct { Name string Source expr.DataType @@ -175,7 +174,7 @@ func TestProtoBufTransform(t *testing.T) { target.Type.Name(), testGRPCMessageExampleIdentity(name+"/"+c.Name+"/target"), ) - freezeProtoBufTransformMessages(sd, target) + freezeProtoBufTransformMessages(t, sd, target) tgtCtx = protoBufTypeContext("proto", sd, true) } else { source = makeProtoBufMessage( @@ -183,7 +182,7 @@ func TestProtoBufTransform(t *testing.T) { source.Type.Name(), testGRPCMessageExampleIdentity(name+"/"+c.Name+"/source"), ) - freezeProtoBufTransformMessages(sd, source) + freezeProtoBufTransformMessages(t, sd, source) srcCtx = protoBufTypeContext("proto", sd, true) } code, _, err := protoBufTransform(source, target, "source", "target", srcCtx, tgtCtx, c.ToProto, true) @@ -200,8 +199,15 @@ func TestProtoBufTransformAnyType(t *testing.T) { var ( sd = &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} svcCtx = codegen.NewAttributeContext(false, false, true, "", sd.Scope) - pbCtx = protoBufTypeContext("", sd, false) ) + sd.protobuf = newProtobufPackageCatalog("") + sd.protobuf.plan = &protobufServicePlan{ + catalog: sd.protobuf, + fields: make(map[*expr.AttributeExpr]protocNameKey), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + pbCtx := protoBufTypeContext("", sd, false) cases := []struct { Name string @@ -250,9 +256,10 @@ func TestProtoBufTransformAnyType(t *testing.T) { // freezeProtoBufTransformMessages prepares the message names consumed by one // standalone transformation test outside full service analysis. -func freezeProtoBufTransformMessages(sd *ServiceData, attribute *expr.AttributeExpr) { +func freezeProtoBufTransformMessages(t *testing.T, sd *ServiceData, attribute *expr.AttributeExpr) { sd.protobuf = newProtobufPackageCatalog("proto") - sd.protobuf.collectMessage(attribute, protobufMessageSource{}, sd) + require.NoError(t, sd.protobuf.collectMessage(attribute, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) sd.protobuf.freezeMessageNames() } diff --git a/grpc/codegen/protoc_names.go b/grpc/codegen/protoc_names.go index d44789fe96..0ae9d64bce 100644 --- a/grpc/codegen/protoc_names.go +++ b/grpc/codegen/protoc_names.go @@ -56,7 +56,7 @@ const ( protocMethodServerStreamName ) -const protocNameVersionGo1_36GRPC1_6 = "protoc-gen-go-v1.36/protoc-gen-go-grpc-v1.6" +const protocNameVersionGo1_36GRPC1_6 = "protoc-gen-go-v1.36.12/protoc-gen-go-grpc-v1.6.2" // newProtocNameRules returns the name reader for a supported protobuf tool // pair. An unknown value is rejected because its Go names may differ. diff --git a/grpc/codegen/protoc_names_test.go b/grpc/codegen/protoc_names_test.go index 2061b0e621..7f66c33f0d 100644 --- a/grpc/codegen/protoc_names_test.go +++ b/grpc/codegen/protoc_names_test.go @@ -100,10 +100,11 @@ func generateProtocNameFixture(t *testing.T) (*descriptorpb.FileDescriptorProto, require.NoError(t, err) protoPath := filepath.Join(directory, "protoc_names.proto") require.NoError(t, os.WriteFile(protoPath, source, 0o600)) - require.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) + require.NoError(t, protoc(defaultProtocCmd, protoPath)) descriptorPath := filepath.Join(directory, "descriptor.pb") - args := append(defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)], + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, "--proto_path", directory, "--descriptor_set_out", descriptorPath, protoPath, diff --git a/grpc/codegen/released_streaming_name_test.go b/grpc/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..04ea0c9f33 --- /dev/null +++ b/grpc/codegen/released_streaming_name_test.go @@ -0,0 +1,39 @@ +// This file checks that one gRPC response conversion keeps its released public +// name when both the response encoder and a stream send method use it. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +// TestReleasedStreamingResponseConstructorNames catches replacing an honest +// method response name with an internal type-based name when conversions merge. +func TestReleasedStreamingResponseConstructorNames(t *testing.T) { + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_constructors.go.golden", code) + }) + + t.Run("fixed collection view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ClientStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_fixed_view_collection_constructor.go.golden", code) + }) +} diff --git a/grpc/codegen/required_union_validation_test.go b/grpc/codegen/required_union_validation_test.go index 66aa14baf8..949f380597 100644 --- a/grpc/codegen/required_union_validation_test.go +++ b/grpc/codegen/required_union_validation_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" d "goa.design/goa/v3/dsl" ) @@ -16,27 +17,29 @@ func TestRequiredUnionValidationUsesCompleteProtobufBranches(t *testing.T) { services := CreateGRPCServices(root) for _, test := range []struct { - Name string - Files []*codegen.File + name string + files []*codegen.File + sectionName string + golden string }{ - {Name: "server", Files: ServerTypeFiles(services)}, - {Name: "client", Files: ClientTypeFiles(services)}, + { + name: "server", + files: serverTypeFiles(services), + sectionName: "server-validate", + golden: "testdata/golden/server_types_server-required-union-validation.go.golden", + }, + { + name: "client", + files: clientTypeFiles(services), + sectionName: "client-validate", + golden: "testdata/golden/client_types_client-required-union-validation.go.golden", + }, } { - t.Run(test.Name, func(t *testing.T) { - require.Len(t, test.Files, 1) - generated := sectionCode(t, test.Files[0].SectionTemplates[1:]...) - - require.Contains(t, generated, `goa.MissingFieldError("choice", "message")`) - require.Contains(t, generated, `goa.MissingFieldError("detail", "message.choice")`) - require.Contains(t, generated, `goa.MissingFieldError("inactive", "message.choice")`) - require.Contains(t, generated, `goa.MissingFieldError("blob", "message.choice")`) - require.Contains(t, generated, `goa.MissingFieldError("metadata", "message.choice")`) - require.Contains(t, generated, "if v == nil {") - require.Contains(t, generated, "if v.Detail == nil {") - require.Contains(t, generated, "if v.Inactive == nil {") - require.Contains(t, generated, "if v.Metadata == nil {") - require.Contains(t, generated, "if v.Blob == nil {") - require.NotContains(t, generated, "if v.Token == nil {") + t.Run(test.name, func(t *testing.T) { + require.Len(t, test.files, 1) + sections := test.files[0].Section(test.sectionName) + require.Len(t, sections, 1) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[0])) }) } } diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index 4e44d496d5..77e26fbfa2 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -11,18 +11,15 @@ import ( "goa.design/goa/v3/expr" ) -// ServerFiles returns all the server files for every gRPC service. The files -// contain the server which implements the generated gRPC server interface and -// encoders and decoders to transform protocol buffer types and gRPC metadata -// into goa types and vice versa. -func ServerFiles(services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// serverFiles returns the planned server interfaces, encoders, and decoders. +func serverFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(serverFile(svc, services), services, svc.GRPCEndpoints...) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(serverFile(servicePlan.expression, services), services, servicePlan) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = addEndpointImports(serverEncodeDecode(svc, services), services, svc.GRPCEndpoints...) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(serverEncodeDecode(servicePlan.expression, services), services, servicePlan) } return fw } @@ -37,6 +34,7 @@ func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "server") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "server.go") imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -44,8 +42,8 @@ func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), {Path: "google.golang.org/grpc/codes"}, - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } for _, e := range data.Endpoints { if e.Request.StreamEnvelope != nil { @@ -53,6 +51,9 @@ func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File break } } + if serviceHasCallerSelectedViewedServerStream(data) { + imports = append(imports, &codegen.ImportSpec{Path: "google.golang.org/grpc/metadata"}) + } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC server", "server", imports), { @@ -133,6 +134,7 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "server") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "encode_decode.go") title := fmt.Sprintf("%s gRPC server encoders and decoders", svc.Name()) imports := []*codegen.ImportSpec{ @@ -144,11 +146,11 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } if serviceHasViewedResult(data) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } if responseMetadataNeedsFormat(data) { imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) @@ -159,16 +161,16 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code if e.Response.ServerConvert != nil { sections = append(sections, &codegen.SectionTemplate{ Name: "response-encoder", - Source: grpcTemplates.Read(grpcResponseEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcResponseEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: map[string]any{ - "typeConversionData": typeConversionData, + "typeStringExpressionData": typeStringExpressionData, "metadataEncodeDecodeData": metadataEncodeDecodeData, }, }) } if e.PayloadRef != "" { - fm := transTmplFuncs(svc, services) + fm := transTmplFuncs(data) fm["isEmpty"] = isEmpty sections = append(sections, &codegen.SectionTemplate{ Name: "request-decoder", @@ -182,36 +184,61 @@ func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *code return &codegen.File{Path: fpath, SectionTemplates: sections} } -// responseMetadataNeedsFormat reports whether a response header or trailer -// serializes a non-string scalar through fmt.Sprintf. +// requestMetadataNeedsFormat reports whether request metadata can contain a Go +// value whose concrete type is unknown until the client runs. +func requestMetadataNeedsFormat(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if metadataNeedsFormat(endpoint.Request.Metadata) { + return true + } + } + return false +} + +// responseMetadataNeedsFormat reports whether response metadata can contain a +// Go value whose concrete type is unknown until the server runs. func responseMetadataNeedsFormat(service *ServiceData) bool { for _, endpoint := range service.Endpoints { for _, group := range [][]*MetadataData{endpoint.Response.Headers, endpoint.Response.Trailers} { - for _, metadata := range group { - if !metadata.Slice && metadata.TypeName != "string" && metadata.Type.Name() != "bytes" { - return true - } + if metadataNeedsFormat(group) { + return true } } } return false } -func transTmplFuncs(s *expr.GRPCServiceExpr, services *ServicesData) map[string]any { +// metadataNeedsFormat reports whether one metadata field uses Goa's Any type. +// All other supported metadata types have an exact string conversion. +func metadataNeedsFormat(fields []*MetadataData) bool { + for _, field := range fields { + typeKind := field.Type.Kind() + if array := expr.AsArray(field.Type); array != nil { + typeKind = array.ElemType.Type.Kind() + } + if typeKind == expr.AnyKind { + return true + } + } + return false +} + +// transTmplFuncs returns the type formatter used by metadata templates for one +// saved service. +func transTmplFuncs(service *ServiceData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return service.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, } } -// typeConversionData produces the template data suitable for executing the -// "type_conversion" template. -func typeConversionData(dt expr.DataType, varName, target string) map[string]any { +// typeStringExpressionData describes one primitive value that generated code +// converts to a metadata string. +func typeStringExpressionData(dt expr.DataType, target string) map[string]any { return map[string]any{ - "Type": dt, - "VarName": varName, - "Target": target, + "Type": dt, + "Target": target, } } diff --git a/grpc/codegen/server_protobuf_method_name_test.go b/grpc/codegen/server_protobuf_method_name_test.go new file mode 100644 index 0000000000..09d0587b65 --- /dev/null +++ b/grpc/codegen/server_protobuf_method_name_test.go @@ -0,0 +1,97 @@ +// This file checks that Goa's gRPC server methods use the names written by +// protoc when two design method names produce the same Go spelling. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestServerUsesProtobufMethodNames checks both source orders. Each generated +// server method must match the protobuf method for the same endpoint. +func TestServerUsesProtobufMethodNames(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "underscored first" + if reverse { + name = "camel case first" + } + t.Run(name, func(t *testing.T) { + root := RunGRPCDSL(t, collidingProtobufMethodDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + services := plans[0].services + service := services.Get("Values") + sections := serverFiles(services)[0].Section("server-grpc-interface") + require.Len(t, sections, len(service.Endpoints)) + + foundDifferentName := false + for index, endpoint := range service.Endpoints { + foundDifferentName = foundDifferentName || endpoint.GRPCMethodName != endpoint.Method.VarName + code := codegen.SectionCode(t, sections[index]) + require.Contains(t, code, "func (s *"+endpoint.ServerStruct+") "+endpoint.GRPCMethodName+"(") + } + require.True(t, foundDifferentName, "test methods did not exercise different service and protobuf names") + compileProtobufMethodServer(t, plans[0], servicePlans) + }) + } +} + +// collidingProtobufMethodDSL defines two methods that produce the same initial +// Go name but use different request and response messages. +func collidingProtobufMethodDSL(reverse bool) func() { + return func() { + underscored := func() { + dsl.Method("read_value", func() { + dsl.Payload(func() { dsl.Field(1, "text", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "text", dsl.String) }) + dsl.GRPC(func() {}) + }) + } + camelCase := func() { + dsl.Method("readValue", func() { + dsl.Payload(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.Result(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.GRPC(func() {}) + }) + } + dsl.Service("Values", func() { + if reverse { + camelCase() + underscored() + return + } + underscored() + camelCase() + }) + } +} + +// compileProtobufMethodServer writes the service and transport files and asks +// Go to check that the server implements the generated protobuf interface. +func compileProtobufMethodServer(t *testing.T, plan *Plan, servicePlans []*service.Plan) { + t.Helper() + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ProtoFiles()...) + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + for _, file := range files { + _, err := file.Render(moduleDir) + require.NoError(t, err) + } + compileProtobufDescriptorModule(t, moduleDir) +} diff --git a/grpc/codegen/server_test.go b/grpc/codegen/server_test.go index fb48b17478..56f6283f91 100644 --- a/grpc/codegen/server_test.go +++ b/grpc/codegen/server_test.go @@ -33,7 +33,7 @@ func TestServerGRPCInterface(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles(services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("server-grpc-interface") require.NotEmpty(t, sections) @@ -61,7 +61,7 @@ func TestServerHandlerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles(services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("grpc-handler-init") require.NotEmpty(t, sections) @@ -82,6 +82,7 @@ func TestRequestDecoder(t *testing.T) { {"request-decoder-payload-primitive", testdata.ServerStreamingRPCDSL}, {"request-decoder-payload-primitive-with-streaming-payload", testdata.ClientStreamingRPCWithPayloadDSL}, {"request-decoder-payload-user-type-with-streaming-payload", testdata.BidirectionalStreamingRPCWithPayloadDSL}, + {"request-decoder-metadata-only-payload-with-streaming-payload", testdata.ClientStreamingRPCWithMetadataOnlyPayloadDSL}, {"request-decoder-payload-primitive-with-streaming-payload-legacy-compat", testdata.ClientStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-user-type-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-with-metadata-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithMetadataLegacyCompatDSL}, @@ -93,7 +94,7 @@ func TestRequestDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles(services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-decoder") require.NotEmpty(t, sections) @@ -121,7 +122,7 @@ func TestResponseEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles(services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-encoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/server_types_test.go b/grpc/codegen/server_types_test.go index dd1e908509..e11fa0487a 100644 --- a/grpc/codegen/server_types_test.go +++ b/grpc/codegen/server_types_test.go @@ -28,12 +28,15 @@ func TestServerTypeFiles(t *testing.T) { {"server-struct-meta-type", testdata.StructMetaTypeDSL}, {"server-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"server-default-fields", testdata.DefaultFieldsDSL}, + {"server-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"server-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"server-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerTypeFiles(services) + fs := serverTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 670fd54581..aabd8073c1 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -8,6 +8,7 @@ import ( "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -17,8 +18,17 @@ type ( // indexed by service name. ServicesData struct { *service.ServicesData - GRPCServices map[string]*ServiceData - cliPlan *grpcCLIPlan + GRPCServices map[string]*ServiceData + cliPlan *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + expressions []*expr.GRPCServiceExpr + servicePlans []*grpcServicePlan + serviceByExpr map[*expr.GRPCServiceExpr]*ServiceData + endpointPlans map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + metadataPlans map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + generation *codegen.Generation } // ServiceData contains the data used to render the code related to a @@ -26,14 +36,20 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data - // ClientPkgName is the frozen qualifier for the generated gRPC client - // package. + // ClientPkgName is the final alias for the generated gRPC client package. ClientPkgName string - // ServerPkgName is the frozen qualifier for the generated gRPC server - // package. + // ServerPkgName is the final alias for the generated gRPC server package. ServerPkgName string // PkgName is the name of the generated package in *.pb.go. PkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // ProtoImports is the list of proto package imports. ProtoImports []string // Name is the service name. @@ -44,14 +60,34 @@ type ( Endpoints []*EndpointData // Messages describes the message data for this service. Messages []*service.UserTypeData - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string - // ClientStruct is the name of the gRPC client struct, + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string - // ServerInit is the name of the constructor of the server struct. + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. ServerInit string - // ClientInit is the name of the constructor of the client struct. + // ServerInitDeclaration supplies the generated server constructor name. + ServerInitDeclaration *codegen.NameDeclaration + // ClientInit is the client constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientInitDeclaration.Name() after planning. ClientInit string + // ClientInitDeclaration supplies the generated client constructor name. + ClientInitDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -61,16 +97,25 @@ type ( // ClientInterfaceInit is the name of the client constructor function in // the generated pb.go package. ClientInterfaceInit string - // Scope is the name scope for protocol buffers + // UnimplementedServer is the generated server type embedded by Goa's + // server implementation. + UnimplementedServer string + // RegisterFunction is the generated function that registers the server. + RegisterFunction string + // Scope records and returns unique Go names for protobuf fields and types in + // this service package. Scope *codegen.NameScope - // protobuf owns declarations emitted in this service's actual protobuf - // output package. + // protobuf contains the messages and validation functions written for this + // service. protobuf *protobufPackageCatalog - // transformHelpers is the list of transform functions required by the - // constructors. - transformHelpers []*codegen.TransformFunctionData + // clientTransformHelpers contains recursive conversion functions written + // in the generated client package. + clientTransformHelpers []*codegen.TransformFunctionData + // serverTransformHelpers contains recursive conversion functions written + // in the generated server package. + serverTransformHelpers []*codegen.TransformFunctionData // validations contain the data to generate the validation functions to // validate the initialized type. validations []*ValidationData @@ -85,14 +130,40 @@ type ( PkgName string // ServicePkgName is the name of the service package name. ServicePkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // Method is the data for the underlying method expression. Method *service.MethodData + // ProtoMethodName is the method name written to the protobuf service. + ProtoMethodName string + // ClientMethodName is the final protobuf client method name kept for + // existing plugins. + // + // Deprecated: Use ProtoMethodName. + ClientMethodName string + // FullMethodName is the protobuf service and method name logged when the + // generated server starts. + FullMethodName string // PayloadType is the type of the payload. PayloadType expr.DataType // PayloadRef is the fully qualified reference to the method payload. PayloadRef string + // ClientPayloadRef is the payload reference in the generated client package. + ClientPayloadRef string + // ServerPayloadRef is the payload reference in the generated server package. + ServerPayloadRef string // ResultRef is the fully qualified reference to the method result. ResultRef string + // ClientResultRef is the result reference in the generated client package. + ClientResultRef string + // ServerResultRef is the result reference in the generated server package. + ServerResultRef string // ViewedResultRef is the fully qualified reference to the viewed result. ViewedResultRef string // Request is the gRPC request data. @@ -110,8 +181,13 @@ type ( // server side - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -120,15 +196,63 @@ type ( // client side - // ClientMethodName is the name of the gRPC method generated by protoc-gen-go. - ClientMethodName string - // ClientStruct is the name of the gRPC client struct, + // GRPCMethodName is the Go method name written by protoc-gen-go-grpc for + // both its client and server interfaces. + GRPCMethodName string + // ClientBuild is the remote call builder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientBuildDeclaration.Name() after planning. + ClientBuild string + // ClientBuildDeclaration supplies the generated remote call builder name. + ClientBuildDeclaration *codegen.NameDeclaration + // ClientEncode is the request encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientEncodeDeclaration.Name() after planning. + ClientEncode string + // ClientEncodeDeclaration supplies the generated request encoder name. + ClientEncodeDeclaration *codegen.NameDeclaration + // ClientDecode is the response decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientDecodeDeclaration.Name() after planning. + ClientDecode string + // ClientDecodeDeclaration supplies the generated response decoder name. + ClientDecodeDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration // ClientInterface is the name of the gRPC client interface implemented // by the service. ClientInterface string // ClientStream is the client stream data. ClientStream *StreamData + // ServerHandler is the handler constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerHandlerDeclaration.Name() after planning. + ServerHandler string + // ServerHandlerDeclaration supplies the generated handler constructor name. + ServerHandlerDeclaration *codegen.NameDeclaration + // ServerDecode is the request decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerDecodeDeclaration.Name() after planning. + ServerDecode string + // ServerDecodeDeclaration supplies the generated request decoder name. + ServerDecodeDeclaration *codegen.NameDeclaration + // ServerEncode is the response encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerEncodeDeclaration.Name() after planning. + ServerEncode string + // ServerEncodeDeclaration supplies the generated response encoder name. + ServerEncodeDeclaration *codegen.NameDeclaration } // MetadataData describes a gRPC metadata field. @@ -146,7 +270,7 @@ type ( FieldType expr.DataType // ServiceAttribute is the service field populated from this metadata. ServiceAttribute *expr.AttributeExpr - // WireAttribute is the detached native gRPC metadata value. + // WireAttribute is an independent copy of the native gRPC metadata value. WireAttribute *expr.AttributeExpr // VarName is the name of the Go variable used to read or // convert the metadata value. @@ -155,8 +279,7 @@ type ( WireVarName string // EncodeCode converts the service field to WireVarName. EncodeCode string - // DecodeCode converts VarName to the service constructor target. The - // constructor replaces metadataTargetPlaceholder with its result variable. + // DecodeCode converts VarName to the exact service constructor field. DecodeCode string // TypeName is the name of the type. TypeName string @@ -170,11 +293,24 @@ type ( StringSlice bool // Slice is true if the metadata value type is an array. Slice bool + // MapStringSlice reports whether the metadata value is a map from strings + // to string arrays. Valid current designs always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. + MapStringSlice bool + // Map reports whether the metadata value is a map. Valid current designs + // always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. + Map bool // Type describes the datatype of the variable value. Mainly // used for conversion. Type expr.DataType // Validate contains the validation code if any. Validate string + // CLIPlan describes how command-line text becomes this metadata value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // DefaultValue contains the default value if any. DefaultValue any // Example is an example value. @@ -196,15 +332,23 @@ type ( // RequestData describes a gRPC request. RequestData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // Description is the request description. Description string // Message is the gRPC request message used by the transport. For // streaming payload methods with an initial payload frame, this is the // synthesized stream envelope. Message *service.UserTypeData + // ClientMessageRef is the request message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the request message reference in the generated server package. + ServerMessageRef string // PayloadMessage is the gRPC message that carries the one-shot method // payload fields before any stream envelope wrapping. PayloadMessage *service.UserTypeData + // ServerPayloadMessageRef is the one-shot payload message reference in the server package. + ServerPayloadMessageRef string // StreamEnvelope describes the synthesized stream envelope when the // transport must carry both the one-shot payload and streaming payload // items through the same streamed protobuf message. @@ -228,6 +372,9 @@ type ( // CLIArgs is the list of arguments for the command-line client. // This is set only for the client side. CLIArgs []*InitArgData + // CLIInitCode builds the service payload from command-line values in the + // generated client package. + CLIInitCode string } // StreamEnvelopeData describes a synthesized streamed protobuf envelope. @@ -239,20 +386,33 @@ type ( // InitialWrapperRef is the fully qualified protobuf wrapper type for the // initial payload branch. InitialWrapperRef string + // ClientInitialWrapperRef is the initial payload wrapper in the client package. + ClientInitialWrapperRef string + // ServerInitialWrapperRef is the initial payload wrapper in the server package. + ServerInitialWrapperRef string // StreamItemFieldName is the name of the streaming payload item branch // field. StreamItemFieldName string // StreamItemWrapperRef is the fully qualified protobuf wrapper type for // the streaming payload item branch. StreamItemWrapperRef string + // ClientStreamItemWrapperRef is the stream item wrapper in the client package. + ClientStreamItemWrapperRef string + // ServerStreamItemWrapperRef is the stream item wrapper in the server package. + ServerStreamItemWrapperRef string } // LegacyDecodeData describes how generated servers decode the one-shot // method payload that legacy stream protocol clients send in gRPC // request metadata. LegacyDecodeData struct { - // FuncName is the name of the generated legacy request decoder. + // FuncName is the legacy decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use FuncDeclaration.Name() after planning. FuncName string + // FuncDeclaration supplies the generated legacy decoder name. + FuncDeclaration *codegen.NameDeclaration // Metadata lists the request metadata carrying the method payload // along with any explicitly mapped and security metadata. Metadata []*MetadataData @@ -265,12 +425,18 @@ type ( // ResponseData describes a gRPC success or error response. ResponseData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // StatusCode is the return code of the response. StatusCode string // Description is the response description. Description string // Message is the gRPC response message. Message *service.UserTypeData + // ClientMessageRef is the response message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the response message reference in the generated server package. + ServerMessageRef string // Headers is the response header metadata. Headers []*MetadataData // Trailers is the response trailer metadata. @@ -279,10 +445,16 @@ type ( // initialize the generated response type in *.pb.go from the // method result type or the projected result type. ServerConvert *ConvertData + // ServerConverts lists the server conversion for each result view. It + // is empty for results without views. + ServerConverts []*ViewConvertData // ClientConvert is the type data with constructor function to // initialize the method result type or the projected result type // from the generated response type in *.pb.go. ClientConvert *ConvertData + // ClientConverts lists the client conversion for each result view. It + // is empty for results without views. + ClientConverts []*ViewConvertData } // ConvertData contains the data to convert source type to a target type. @@ -291,9 +463,11 @@ type ( // For response type, it contains data to transform gRPC response type to the // corresponding result type (client) and vice versa (server). ConvertData struct { - // SrcName is the fully qualified name of the source type. + // SrcName is the fully qualified name of the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcName string - // SrcRef is the fully qualified reference to the source type. + // SrcRef is the fully qualified reference to the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcRef string // TgtName is the fully qualified name of the target type. TgtName string @@ -309,10 +483,21 @@ type ( Validation *ValidationData } - // ValidationData contains the data necessary to render the validation - // function. + // ViewConvertData identifies the conversion generated for one result view. + ViewConvertData struct { + // View is the result view handled by Convert. + View string + // Convert builds the protobuf value using only fields in View. + Convert *ConvertData + } + + // ValidationData contains one generated validation function. ValidationData struct { - // Name is the validation function name. + // Declaration is the function name used by its definition and callers. + Declaration *codegen.NameDeclaration + // Name is the final validation function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. Name string // Def is the validation function definition. Def string @@ -330,6 +515,9 @@ type ( // InitData contains the data required to render a constructor. InitData struct { + // Declaration is the constructor declaration stored in the generated + // package and used by every call. + Declaration *codegen.NameDeclaration // Name is the constructor function name. Name string // Description is the function description. @@ -380,6 +568,9 @@ type ( // Validate contains the validation code for the argument // value if any. Validate string + // CLIPlan describes how command-line text becomes this argument value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is a example value Example any } @@ -387,8 +578,13 @@ type ( // StreamData contains data to render the stream struct type that implements // the service stream interface. StreamData struct { - // VarName is the name of the struct type. + // VarName is the stream type name kept for existing plugins. Changing it + // does not rename generated code. + // + // Deprecated: Use Declaration.Name() after planning. VarName string + // Declaration supplies the generated stream type name. + Declaration *codegen.NameDeclaration // Type is the stream type (client or server). Type string // ServiceInterface is the service interface that the struct implements. @@ -412,10 +608,16 @@ type ( // constructor to convert the service send type to the type expected by // the gRPC send type (in *.pb.go) SendConvert *ConvertData + // SendConverts lists the server send conversion for each result view. + // It is empty for client streams and results without views. + SendConverts []*ViewConvertData // RecvConvert is the type received through the stream. It contains the // constructor to convert the gRPC type (in *.pb.go) to the service receive // type. RecvConvert *ConvertData + // RecvConverts lists the client receive conversion for each result view. + // It is empty for server streams and results without views. + RecvConverts []*ViewConvertData // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -441,9 +643,6 @@ const ( // pbPkgName is the directory name where the .proto file is generated and // compiled. pbPkgName = "pb" - // metadataTargetPlaceholder marks the constructor result until the same - // metadata record is attached to its concrete conversion function. - metadataTargetPlaceholder = "__goa_metadata_target__" // validateServer generates the validation code for request messages in the // server package. validateServer validateKind = iota + 1 @@ -452,33 +651,90 @@ const ( validateClient ) -// newServicesData creates the render data owned by one retained gRPC plan. +// newServicesData builds the values passed to gRPC client and server templates +// from the saved service data and gRPC plan. func newServicesData(services *service.ServicesData, plan *Plan) *ServicesData { if services.Root != plan.root { panic(fmt.Sprintf("gRPC service data does not belong to design %q", plan.root.API.Name)) } - return &ServicesData{ - ServicesData: services, - GRPCServices: make(map[string]*ServiceData), - cliPlan: plan.cli, + data := &ServicesData{ + ServicesData: services, + GRPCServices: make(map[string]*ServiceData), + cliPlan: plan.cli, + protobuf: plan.protobuf, + tools: plan.tools, + symbols: plan.symbols, + servicePlans: append([]*grpcServicePlan(nil), plan.servicesPlan...), + serviceByExpr: make(map[*expr.GRPCServiceExpr]*ServiceData, len(plan.servicesPlan)), + endpointPlans: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan), + metadataPlans: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + generation: plan.generation, + } + data.expressions = make([]*expr.GRPCServiceExpr, len(data.servicePlans)) + for index, servicePlan := range data.servicePlans { + data.expressions[index] = servicePlan.expression + for _, endpointPlan := range servicePlan.endpoints { + data.endpointPlans[endpointPlan.expression] = endpointPlan + for mapped, metadata := range endpointPlan.metadata { + data.metadataPlans[mapped] = metadata + } + } + serviceData := data.analyze(servicePlan) + data.GRPCServices[servicePlan.expression.Name()] = serviceData + data.serviceByExpr[servicePlan.source] = serviceData } + return data } -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// Get retrieves the transport data saved for the service with the given name. +// It returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *ServiceData { - if data, ok := d.GRPCServices[name]; ok { - return data - } - service := d.Root.API.GRPC.Service(name) - if service == nil { - return nil - } - d.GRPCServices[name] = d.analyze(service) return d.GRPCServices[name] } +// exampleServiceData copies the package qualifiers used by one executable. +// Server examples import the service, protobuf, and gRPC server packages; +// command-line examples import the service package only when they receive a +// result stream. +func (d *ServicesData) exampleServiceData(source *ServiceData, outputPackage string, server bool) *ServiceData { + data := *source + service := *source.Service + data.Service = &service + data.Endpoints = make([]*EndpointData, len(source.Endpoints)) + for index, endpoint := range source.Endpoints { + copy := *endpoint + data.Endpoints[index] = © + } + if server { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + protobufPath := path.Join(d.GenPkg(), "grpc", service.PathName, pbPkgName) + data.PkgName = d.PackageImport(outputPackage, protobufPath).Name + data.ServerPkgName = d.PackageImport(outputPackage, path.Join(d.GenPkg(), "grpc", service.PathName, "server")).Name + for _, endpoint := range data.Endpoints { + endpoint.PkgName = data.PkgName + endpoint.ServicePkgName = service.PkgName + } + return &data + } + if grpcServiceStreamsResult(d.servicePlan(service.Name).expression) { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + for _, endpoint := range data.Endpoints { + endpoint.ServicePkgName = service.PkgName + } + } + return &data +} + +// servicePlan returns the copied gRPC plan for service name. +func (d *ServicesData) servicePlan(name string) *grpcServicePlan { + for _, plan := range d.servicePlans { + if plan.expression.Name() == name { + return plan + } + } + panic(fmt.Sprintf("gRPC service plan %q is missing", name)) +} + // Endpoint returns the endpoint data for the endpoint with the given name, nil // if there isn't one. func (sd *ServiceData) Endpoint(name string) *EndpointData { @@ -546,36 +802,83 @@ func serviceHasViewedClientStream(service *ServiceData) bool { return false } +// serviceHasCallerSelectedViewedServerStream reports whether server.go sends +// the selected result view in the stream response metadata. +func serviceHasCallerSelectedViewedServerStream(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ServerStream != nil && + endpoint.ServerStream.SendConvert != nil && + endpoint.Method.ViewedResult != nil && + endpoint.Method.ViewedResult.ViewName == "" { + return true + } + } + return false +} + // analyze creates the data necessary to render the code of the given service. -func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { +func (d *ServicesData) analyze(servicePlan *grpcServicePlan) *ServiceData { + gs := servicePlan.expression svc := d.ServicesData.Get(gs.Name()) transportService := *svc - transportService.PkgName = d.ServiceImport(svc.Name).Name + transportService.ProtoImports = append([]*codegen.ImportSpec(nil), svc.ProtoImports...) + transportService.ProtoImports = append(transportService.ProtoImports, servicePlan.protoGoImports...) + clientPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "client") + serverPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "server") + clientServicePackage := d.ServiceImport(clientPackage, svc.Name).Name + serverServicePackage := d.ServiceImport(serverPackage, svc.Name).Name + transportService.PkgName = clientServicePackage svc = &transportService - scope := codegen.NewNameScope() protobufPath := path.Join(d.GenPkg(), "grpc", svc.PathName, pbPkgName) - pkg := d.PackageImport(protobufPath).Name - svcVarN := scope.HashedUnique(gs.ServiceExpr, codegen.Goify(svc.Name, true)) + clientProtobufPackage := d.PackageImport(clientPackage, protobufPath).Name + serverProtobufPackage := d.PackageImport(serverPackage, protobufPath).Name + planned := d.protobuf[gs] + if planned == nil { + panic(fmt.Sprintf("protobuf plan is missing for gRPC service %q", gs.Name())) + } + serviceDescriptor := planned.serviceFullName() + symbols := d.symbols[gs] + if symbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC service %q", gs.Name())) + } sd := &ServiceData{ - Service: svc, - ClientPkgName: d.PackageImport(path.Join(d.GenPkg(), "grpc", svc.PathName, "client")).Name, - ServerPkgName: d.PackageImport(path.Join(d.GenPkg(), "grpc", svc.PathName, "server")).Name, - Name: svcVarN, - Description: svc.Description, - PkgName: pkg, - ServerStruct: "Server", - ClientStruct: "Client", - ServerInit: "New", - ClientInit: "NewClient", - ServerInterface: svcVarN + "Server", - ClientInterface: svcVarN + "Client", - ClientInterfaceInit: fmt.Sprintf("%s.New%sClient", pkg, svcVarN), - Scope: scope, - } - sd.protobuf = newProtobufPackageCatalog(pkg) - sd.protobuf.reserveName(sd.Name) - protobufMessages := prepareProtobufPackage(gs, sd) + Service: svc, + Name: planned.serviceName, + Description: svc.Description, + PkgName: clientProtobufPackage, + ClientProtobufPkgName: clientProtobufPackage, + ServerProtobufPkgName: serverProtobufPackage, + ClientServicePkgName: clientServicePackage, + ServerServicePkgName: serverServicePackage, + ProtoImports: append([]string(nil), servicePlan.protoImports...), + ServerStruct: symbols.serverStruct.Name(), + ServerStructDeclaration: symbols.serverStruct, + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + ServerInit: symbols.serverInit.Name(), + ServerInitDeclaration: symbols.serverInit, + ClientInit: symbols.clientInit.Name(), + ClientInitDeclaration: symbols.clientInit, + ServerInterface: planned.name(serviceDescriptor, protocServiceServerName), + ClientInterface: planned.name(serviceDescriptor, protocServiceClientName), + ClientInterfaceInit: clientProtobufPackage + "." + planned.name(serviceDescriptor, protocServiceClientConstructorName), + UnimplementedServer: planned.name(serviceDescriptor, protocServiceUnimplementedServerName), + RegisterFunction: planned.name(serviceDescriptor, protocServiceRegisterName), + Scope: servicePlan.scope, + protobuf: planned.catalog, + } + sd.protobuf.packageName = clientProtobufPackage + finishProtobufPackage(sd) + protobufMessages := planned.messages for index, e := range gs.GRPCEndpoints { + endpointPlan := servicePlan.endpointByExpr[e] + if endpointPlan == nil { + panic(fmt.Sprintf("saved gRPC endpoint data is missing for %q", e.Name())) + } + endpointSymbols := symbols.endpoints[e] + if endpointSymbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC endpoint %q", e.Name())) + } hasRequestMessage := !isEmpty(e.Request.Type) messages := protobufMessages[index] requestMessage := messages.request @@ -583,27 +886,33 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { requestEnvelope := messages.requestEnvelope responseMessage := messages.response errorMessages := messages.errors - collect := func(attribute *expr.AttributeExpr) *service.UserTypeData { + collect := func(attribute *expr.AttributeExpr) *protobufMessageRecord { record := sd.protobuf.message(attribute) if record == nil || record.data == nil { panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", attribute.Type.Name())) // bug } - return record.data + return record } var ( - payloadRef string - resultRef string - viewedResultRef string + clientPayloadRef string + serverPayloadRef string + clientResultRef string + serverResultRef string + viewedResultRef string ) md := svc.Method(e.Name()) if e.MethodExpr.Payload.Type != expr.Empty { - svcctx := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Payload) - payloadRef = svcctx.Scope.Ref(e.MethodExpr.Payload, svcctx.Pkg(e.MethodExpr.Payload)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Payload) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Payload) + clientPayloadRef = clientContext.Scope.Ref(e.MethodExpr.Payload, clientContext.Pkg(e.MethodExpr.Payload)) + serverPayloadRef = serverContext.Scope.Ref(e.MethodExpr.Payload, serverContext.Pkg(e.MethodExpr.Payload)) } if e.MethodExpr.Result.Type != expr.Empty { - svcctx := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Result) - resultRef = svcctx.Scope.Ref(e.MethodExpr.Result, svcctx.Pkg(e.MethodExpr.Result)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Result) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Result) + clientResultRef = clientContext.Scope.Ref(e.MethodExpr.Result, clientContext.Pkg(e.MethodExpr.Result)) + serverResultRef = serverContext.Scope.Ref(e.MethodExpr.Result, serverContext.Pkg(e.MethodExpr.Result)) } if md.ViewedResult != nil { viewedResultRef = md.ViewedResult.FullRef @@ -612,58 +921,88 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { // build request data payloadIdentity := expr.MethodPayloadExampleIdentity(e.MethodExpr) resultIdentity := expr.MethodResultExampleIdentity(e.MethodExpr) - reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server", payloadIdentity) + reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server", "v", payloadIdentity) request := &RequestData{ Description: requestMessage.Description, Metadata: reqMD, ServerConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, true), ClientConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, false), } + if e.MethodExpr.Payload.Type != expr.Empty { + request.CLIInitCode = d.buildCLIRequestTransform(e, sd) + } if hasRequestMessage { - request.PayloadMessage = collect(requestMessage) + request.PayloadMessage = collect(requestMessage).data + request.ServerPayloadMessageRef = protoBufGoFullTypeRef(requestMessage, sd.ServerProtobufPkgName, sd) } if obj := expr.AsObject(requestMessage.Type); (obj != nil && len(*obj) > 0) || expr.IsUnion(requestMessage.Type) { // add the request message as the first argument to the CLI + typeName := protoBufGoFullTypeName(requestMessage, sd.PkgName, sd) request.CLIArgs = append(request.CLIArgs, &InitArgData{ Name: "message", Ref: "message", - TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd), + TypeName: typeName, TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), + CLIPlan: cli.NewFlagPlan(requestMessage, typeName, typeName, nil), Example: d.Example(requestMessage, payloadIdentity), }) } // pass the metadata as arguments to client CLI args - request.CLIArgs = append(request.CLIArgs, initArgsFromMetadata(reqMD, "")...) + request.CLIArgs = append(request.CLIArgs, argsFromMetadata(reqMD)...) + transportRequest := requestMessage switch { case requestEnvelope != nil: - request.Message = collect(requestEnvelope) - request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, request.Message, sd) - if e.LegacyStreamCompat() { + transportRequest = requestEnvelope + record := collect(requestEnvelope) + request.Message = record.data + request.ProtoMessageName = record.protoName + request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, sd) + if endpointPlan.legacyStream { request.LegacyDecode = d.buildLegacyDecodeData(e, sd) } case streamingRequest.Type != expr.Empty: - request.Message = collect(streamingRequest) + transportRequest = streamingRequest + record := collect(streamingRequest) + request.Message = record.data + request.ProtoMessageName = record.protoName default: - request.Message = collect(requestMessage) + record := collect(requestMessage) + request.Message = record.data + request.ProtoMessageName = record.protoName } + request.ClientMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ClientProtobufPkgName, sd) + request.ServerMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ServerProtobufPkgName, sd) // build response data serverResult, serverCtx := d.resultContext(e, sd, "server") clientResult, clientCtx := d.resultContext(e, sd, "client") - hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client", resultIdentity) - trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client", resultIdentity) + hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client", "result", resultIdentity) + trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client", "result", resultIdentity) + serverConverts := d.buildServerResponseConverts(responseMessage, serverResult, serverCtx, e, sd) + clientConverts := d.buildClientResponseConverts(responseMessage, clientResult, clientCtx, hdrs, trlrs, e, sd) + var viewedServerConverts, viewedClientConverts []*ViewConvertData + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + viewedServerConverts = serverConverts + viewedClientConverts = clientConverts + } response := &ResponseData{ - StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), - Description: e.Response.Description, - Headers: hdrs, - Trailers: trlrs, - ServerConvert: d.buildResponseConvertData(responseMessage, serverResult, serverCtx, hdrs, trlrs, e, sd, true), - ClientConvert: d.buildResponseConvertData(responseMessage, clientResult, clientCtx, hdrs, trlrs, e, sd, false), + StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), + Description: e.Response.Description, + Headers: hdrs, + Trailers: trlrs, + ServerConvert: primaryViewConvert(serverConverts), + ServerConverts: viewedServerConverts, + ClientConvert: primaryViewConvert(clientConverts), + ClientConverts: viewedClientConverts, } // If the endpoint is a streaming endpoint, no message is returned // by gRPC. Hence, no need to set response message. if responseMessage.Type != expr.Empty || !e.MethodExpr.IsStreaming() { - response.Message = collect(responseMessage) + record := collect(responseMessage) + response.Message = record.data + response.ProtoMessageName = record.protoName + response.ClientMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ClientProtobufPkgName, sd) + response.ServerMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ServerProtobufPkgName, sd) } // gather security requirements @@ -684,51 +1023,85 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } } ed := &EndpointData{ - ServiceName: svc.Name, - PkgName: sd.PkgName, - ServicePkgName: svc.PkgName, - Method: md, - PayloadType: e.MethodExpr.Payload.Type, - PayloadRef: payloadRef, - ResultRef: resultRef, - ViewedResultRef: viewedResultRef, - Request: request, - Response: response, - MessageSchemes: msgSch, - MetadataSchemes: metSch, - Errors: errors, - ServerStruct: sd.ServerStruct, - ServerInterface: sd.ServerInterface, - ClientMethodName: protoBufify(md.VarName, true, true), - ClientStruct: sd.ClientStruct, - ClientInterface: sd.ClientInterface, - } + ServiceName: svc.Name, + PkgName: sd.PkgName, + ServicePkgName: svc.PkgName, + ClientProtobufPkgName: sd.ClientProtobufPkgName, + ServerProtobufPkgName: sd.ServerProtobufPkgName, + ClientServicePkgName: sd.ClientServicePkgName, + ServerServicePkgName: sd.ServerServicePkgName, + Method: md, + ProtoMethodName: planned.methods[e], + ClientMethodName: planned.methods[e], + FullMethodName: planned.serviceFullName() + "/" + planned.methods[e], + PayloadType: e.MethodExpr.Payload.Type, + PayloadRef: clientPayloadRef, + ClientPayloadRef: clientPayloadRef, + ServerPayloadRef: serverPayloadRef, + ResultRef: clientResultRef, + ClientResultRef: clientResultRef, + ServerResultRef: serverResultRef, + ViewedResultRef: viewedResultRef, + Request: request, + Response: response, + MessageSchemes: msgSch, + MetadataSchemes: metSch, + Errors: errors, + ServerStruct: sd.ServerStruct, + ServerStructDeclaration: sd.ServerStructDeclaration, + ServerInterface: sd.ServerInterface, + GRPCMethodName: planned.name(serviceDescriptor+"."+planned.methods[e], protocMethodName), + ClientStruct: sd.ClientStruct, + ClientStructDeclaration: sd.ClientStructDeclaration, + ClientInterface: sd.ClientInterface, + } + ed.ClientBuild = endpointSymbols.clientBuild.Name() + ed.ClientBuildDeclaration = endpointSymbols.clientBuild + if endpointSymbols.clientEncode != nil { + ed.ClientEncode = endpointSymbols.clientEncode.Name() + ed.ClientEncodeDeclaration = endpointSymbols.clientEncode + } + if endpointSymbols.clientDecode != nil { + ed.ClientDecode = endpointSymbols.clientDecode.Name() + ed.ClientDecodeDeclaration = endpointSymbols.clientDecode + } + ed.ServerHandler = endpointSymbols.serverHandler.Name() + ed.ServerHandlerDeclaration = endpointSymbols.serverHandler + if endpointSymbols.serverDecode != nil { + ed.ServerDecode = endpointSymbols.serverDecode.Name() + ed.ServerDecodeDeclaration = endpointSymbols.serverDecode + } + ed.ServerEncode = endpointSymbols.serverEncode.Name() + ed.ServerEncodeDeclaration = endpointSymbols.serverEncode sd.Endpoints = append(sd.Endpoints, ed) if e.MethodExpr.IsStreaming() { ed.ServerStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, true) + ed.ServerStream.VarName = endpointSymbols.serverStream.Name() + ed.ServerStream.Declaration = endpointSymbols.serverStream ed.ClientStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, false) + ed.ClientStream.VarName = endpointSymbols.clientStream.Name() + ed.ClientStream.Declaration = endpointSymbols.clientStream } } return sd } -// prepareProtobufPackage shapes every endpoint message, collects the complete -// package declaration set, and freezes messages and validators before any -// conversion or template data resolves their names. -func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) []*protobufEndpointMessages { +// collectProtobufPackage copies each method message and records every message +// and oneof before generated Go names are fixed. +func collectProtobufPackage(serviceExpr *expr.GRPCServiceExpr, catalog *protobufPackageCatalog) ([]*protobufEndpointMessages, error) { prepared := make([]*protobufEndpointMessages, len(serviceExpr.GRPCEndpoints)) for index, endpoint := range serviceExpr.GRPCEndpoints { useStreamEnvelope := usesStreamEnvelope(endpoint) request := makeProtoBufMessage( endpoint.Request, - protoBufify(endpoint.Name()+"_request", true, true), + codegen.ProtobufName(endpoint.Name()+"_request"), expr.GRPCRequestMessageExampleIdentity(endpoint.MethodExpr), ) streamingRequest := endpoint.StreamingRequest if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { - name := protoBufify(endpoint.Name()+"_streaming_request", true, true) + name := codegen.ProtobufName(endpoint.Name() + "_streaming_request") if useStreamEnvelope { - name = protoBufify(endpoint.Name()+"_stream_item", true, true) + name = codegen.ProtobufName(endpoint.Name() + "_stream_item") } streamingRequest = makeProtoBufMessage( endpoint.StreamingRequest, @@ -741,7 +1114,7 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) requestEnvelope = makeProtoBufStreamEnvelope( request, streamingRequest, - protoBufify(endpoint.Name()+"_streaming_request", true, true), + codegen.ProtobufName(endpoint.Name()+"_streaming_request"), expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), ) } @@ -751,17 +1124,17 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) } response := makeProtoBufMessage( endpoint.Response.Message, - protoBufify(endpoint.Name()+"_response", true, true), + codegen.ProtobufName(endpoint.Name()+"_response"), responseOwner, ) errors := make(map[string]*expr.AttributeExpr, len(endpoint.GRPCErrors)) for _, grpcError := range endpoint.GRPCErrors { - if grpcError.Type == expr.ErrorResult || !expr.IsObject(grpcError.Type) { + if expr.IsErrorResult(grpcError.Type) || !expr.IsObject(grpcError.Type) { continue } errors[grpcError.Name] = makeProtoBufMessage( grpcError.Response.Message, - protoBufify(endpoint.Name()+"_"+grpcError.Name+"_error", true, true), + codegen.ProtobufName(endpoint.Name()+"_"+grpcError.Name+"_error"), expr.GRPCErrorMessageExampleIdentity(endpoint.MethodExpr, grpcError.ErrorExpr), ) } @@ -774,26 +1147,17 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) } } - imported := make(map[string]struct{}) - collect := func(attribute *expr.AttributeExpr, source protobufMessageSource) { - for _, protobufImport := range sd.protobuf.collectMessage(attribute, source, sd) { - if _, ok := imported[protobufImport]; ok { - continue - } - imported[protobufImport] = struct{}{} - sd.ProtoImports = append(sd.ProtoImports, protobufImport) - } - } + collect := catalog.collectMessage for index, endpoint := range serviceExpr.GRPCEndpoints { messages := prepared[index] requestSource := protobufRootMessageSource(endpoint.Request, endpoint, nil, protobufRequestMessage) streamingSource := protobufRootMessageSource(endpoint.StreamingRequest, endpoint, nil, protobufStreamingRequestMessage) responseSource := protobufRootMessageSource(endpoint.Response.Message, endpoint, nil, protobufResponseMessage) - sd.protobuf.bindRootSource(messages.request, requestSource) + catalog.bindRootSource(messages.request, requestSource) if messages.streamingRequest.Type != expr.Empty { - sd.protobuf.bindRootSource(messages.streamingRequest, streamingSource) + catalog.bindRootSource(messages.streamingRequest, streamingSource) } - sd.protobuf.bindRootSource(messages.response, responseSource) + catalog.bindRootSource(messages.response, responseSource) for _, grpcError := range endpoint.GRPCErrors { message := messages.errors[grpcError.Name] if message == nil { @@ -805,56 +1169,55 @@ func prepareProtobufPackage(serviceExpr *expr.GRPCServiceExpr, sd *ServiceData) grpcError, protobufErrorMessage, ) - sd.protobuf.bindRootSource(message, errorSource) - collect(message, errorSource) + catalog.bindRootSource(message, errorSource) + if err := collect(message, errorSource); err != nil { + return nil, err + } } requestNeeded := !isEmpty(endpoint.Request.Type) || (messages.requestEnvelope == nil && messages.streamingRequest.Type == expr.Empty) if requestNeeded { - collect(messages.request, requestSource) + if err := collect(messages.request, requestSource); err != nil { + return nil, err + } } if messages.requestEnvelope != nil { envelopeSource := protobufMessageSource{synthetic: protobufSyntheticMessage{ endpoint: endpoint, role: protobufStreamEnvelopeMessage, }} - sd.protobuf.bindRootSource(messages.requestEnvelope, envelopeSource) - collect(messages.requestEnvelope, envelopeSource) + catalog.bindRootSource(messages.requestEnvelope, envelopeSource) + if err := collect(messages.requestEnvelope, envelopeSource); err != nil { + return nil, err + } } if messages.streamingRequest.Type != expr.Empty { - collect(messages.streamingRequest, streamingSource) + if err := collect(messages.streamingRequest, streamingSource); err != nil { + return nil, err + } } if messages.response.Type != expr.Empty || !endpoint.MethodExpr.IsStreaming() { - collect(messages.response, responseSource) - } - } - sd.Messages = sd.protobuf.freezeMessages(sd) - - for index, endpoint := range serviceExpr.GRPCEndpoints { - messages := prepared[index] - if sd.protobuf.message(messages.request) != nil { - sd.protobuf.collectValidation(messages.request, validateServer, "message", "message") - } - if sd.protobuf.message(messages.response) != nil { - sd.protobuf.collectValidation(messages.response, validateClient, "message", "message") - } - for _, grpcError := range endpoint.GRPCErrors { - if message := messages.errors[grpcError.Name]; message != nil { - sd.protobuf.collectValidation(message, validateClient, "errmsg", "errmsg") + if err := collect(messages.response, responseSource); err != nil { + return nil, err } } - if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { - sd.protobuf.collectValidation(messages.streamingRequest, validateServer, "stream", "stream") - } } + return prepared, nil +} + +// finishProtobufPackage reads the final Go names and builds the message and +// validation data used by generated clients and servers. +func finishProtobufPackage(sd *ServiceData) { + sd.protobuf.freezeMessages(sd) + sd.Messages = sd.protobuf.protoMessageData() + sd.validations = sd.protobuf.freezeValidations(sd) - return prepared } -// protobufRootMessageSource identifies a root message by the authored service -// declaration whose value it carries. Endpoint roles identify only messages -// whose service value is inline or compiler-created. The shaped wire attribute -// is a fallback because explicit Message DSL may itself name a declaration. +// protobufRootMessageSource connects a root message to the authored service +// declaration whose value it carries. The endpoint value is used only when +// the service value is inline or created by Goa. An explicit Message DSL may +// provide the declaration instead. func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRPCEndpointExpr, grpcError *expr.GRPCErrorExpr, role protobufSyntheticRole) protobufMessageSource { var serviceAttribute *expr.AttributeExpr switch role { @@ -884,8 +1247,8 @@ func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRP }} } -// addValidation returns the frozen validation helper for the given protobuf -// message on the generated server or client side. +// addValidation returns the validation function chosen for the given protobuf +// message in the generated server or client package. // // req if true indicates that the validation is generated for validating // request (server-side) messages. @@ -935,38 +1298,38 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE return nil } - svc := sd.Service - method := svc.Method(e.Name()) side := "client" + protobufPackage := sd.ClientProtobufPkgName if svr { + protobufPackage = sd.ServerProtobufPkgName side = "server" } svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side - data := d.buildInitData(request, payload, "message", "v", svcCtx, method.Payload, false, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr)) - data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(request, payload, "message", "v", svcCtx, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcRequestInit})) // pass the metadata as arguments to payload constructor in server - data.Args = append(data.Args, initArgsFromMetadata(md, data.ReturnVarName)...) - return &ConvertData{ - SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd), - SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd), - TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), - TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), - Init: data, - Validation: addValidation(request, sd, true), + data.Args = append(data.Args, initArgsFromMetadata(md)...) + conversion := &ConvertData{ + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), + Init: data, } + if !e.MethodExpr.IsPayloadStreaming() || !isEmpty(e.Request.Type) { + conversion.SrcName = protoBufGoFullTypeName(request, protobufPackage, sd) + conversion.SrcRef = protoBufGoFullTypeRef(request, protobufPackage, sd) + conversion.Validation = addValidation(request, sd, true) + } + return conversion } // client side - data := d.buildInitData(payload, request, "payload", "message", svcCtx, method.Payload, true, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr)) - data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcRequestInit})) return &ConvertData{ SrcName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), - TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd), - TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd), + TgtName: protoBufGoFullTypeName(request, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(request, sd.ClientProtobufPkgName, sd), Init: data, } } @@ -978,35 +1341,23 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE // to metadata is carried under its own name and non-object payloads travel // under the reserved "goa_payload" key. func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *ServiceData) *LegacyDecodeData { - svc := sd.Service payload := e.MethodExpr.Payload - legacyMD := expr.DupMappedAtt(e.Metadata) - mdObj := expr.AsObject(legacyMD.Type) - if pobj := expr.AsObject(payload.Type); pobj != nil { - for _, nat := range *pobj { - if mdObj.Attribute(nat.Name) == nil { - mdObj.Set(nat.Name, expr.DupAtt(nat.Attribute)) - } - if payload.IsRequired(nat.Name) { - legacyMD.Validation.AddRequired(nat.Name) - } - } - } else { - mdObj.Set("goa_payload", expr.DupAtt(payload)) - legacyMD.Validation.AddRequired("goa_payload") + endpointPlan := d.endpointPlans[e] + if endpointPlan == nil || endpointPlan.legacyMetadata == nil { + panic(fmt.Sprintf("saved legacy metadata is missing for gRPC endpoint %q", e.Name())) } owner := expr.MethodPayloadExampleIdentity(e.MethodExpr) - md := d.extractMetadata(legacyMD, payload, sd, "server", owner) + md := d.extractMetadata(endpointPlan.legacyMetadata, payload, sd, "server", "v", owner) + declaration := d.symbols[e.Service].endpoints[e].legacyDecode data := &LegacyDecodeData{ - FuncName: fmt.Sprintf("decode%sLegacyRequest", codegen.Goify(e.Name(), true)), - Metadata: md, + FuncName: declaration.Name(), + FuncDeclaration: declaration, + Metadata: md, } if expr.IsObject(payload.Type) { svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) - init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, sd.Service.Method(e.Name()).Payload, false, false, sd, owner) - init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) - init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) - init.Args = append(init.Args, initArgsFromMetadata(md, init.ReturnVarName)...) + init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcLegacyRequestInit})) + init.Args = append(init.Args, initArgsFromMetadata(md)...) data.ServerConvert = &ConvertData{ TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), @@ -1016,53 +1367,81 @@ func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *Servi return data } -// buildResponseConvertData builds the convert data for the server and client -// responses. -// - server side - converts method result type to generated gRPC response -// type in *.pb.go -// - client side - converts generated gRPC response type in *.pb.go and -// response metadata to method result type. -// -// svr param indicates that the convert data is generated for server side. -func (d *ServicesData) buildResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData, svr bool) *ConvertData { - if !svr && (e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type)) { - return nil - } - svc := sd.Service - method := svc.Method(e.Name()) - resultName := method.Result - if _, ok := result.Type.(expr.UserType); ok { - resultName = codegen.Goify(result.Type.Name(), true) - } - if svr { - // server side - data := d.buildInitData(result, response, "result", "message", svcCtx, resultName, true, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr)) - data.Description = fmt.Sprintf("%s builds the gRPC response type from the result of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) - return &ConvertData{ - SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(response, sd.PkgName, sd), - TgtRef: protoBufGoFullTypeRef(response, sd.PkgName, sd), - Init: data, +// buildServerResponseConverts builds one protobuf conversion for each result +// view the server may send. Results without views have one unnamed conversion. +func (d *ServicesData) buildServerResponseConverts(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } } + key := grpcInitKey{role: grpcResponseInit, view: view} + converts = append(converts, &ViewConvertData{ + View: view, + Convert: d.buildServerResponseConvertData(response, source, svcCtx, e, sd, key), + }) } + return converts +} - // client side - data := d.buildInitData(response, result, "message", "result", svcCtx, resultName, false, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr)) - data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) - // pass the headers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(hdrs, data.ReturnVarName)...) - // pass the trailers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(trlrs, data.ReturnVarName)...) +// buildServerResponseConvertData builds one protobuf response conversion from +// the fields selected during planning. +func (d *ServicesData) buildServerResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData, key grpcInitKey) *ConvertData { + data := d.buildInitData(result, response, "result", "message", svcCtx, true, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)) return &ConvertData{ - SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd), - SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd), - TgtName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - TgtRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - Init: data, - Validation: addValidation(response, sd, false), + SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), + SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: data, + } +} + +// buildClientResponseConverts builds one service conversion for each result +// view the client may receive. Results without views have one unnamed +// conversion. +func (d *ServicesData) buildClientResponseConverts(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + if e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type) { + return nil + } + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + } + key := grpcInitKey{role: grpcResponseInit, view: view} + data := d.buildInitData(response, target, "message", "result", svcCtx, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)) + data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) + data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: svcCtx.Scope.Name(target, svcCtx.Pkg(target), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(target, svcCtx.Pkg(target)), + Init: data, + Validation: addValidation(response, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) } + return converts } // buildInitData builds the transformation code to convert source to target. @@ -1073,42 +1452,34 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // transformation // svcCtx is the attribute context for service type // proto if true indicates the target type is a protocol buffer type -func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, serviceTypeName string, proto, usesrc bool, sd *ServiceData, owner expr.ExampleIdentity) *InitData { - pbCtx := protoBufTypeContext(sd.PkgName, sd, false) - name := "New" +func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto bool, sd *ServiceData, owner expr.ExampleIdentity, conversion *grpcConversion) *InitData { + protobufPackage := sd.ClientProtobufPkgName + if conversion.side == grpcServerPackage { + protobufPackage = sd.ServerProtobufPkgName + } + pbCtx := protoBufTypeContext(protobufPackage, sd, false) srcCtx := pbCtx tgtCtx := svcCtx if proto { srcCtx = svcCtx tgtCtx = pbCtx - name += "Proto" - } - var sourceTypeName, targetTypeName func() string - if proto { - sourceTypeName = func() string { return serviceTypeName } - targetTypeName = func() string { return protoBufGoTypeName(target, sd) } - } else { - sourceTypeName = func() string { return protoBufGoTypeName(source, sd) } - targetTypeName = func() string { return serviceTypeName } } isStruct := expr.IsObject(target.Type) || expr.IsUnion(target.Type) - if _, ok := source.Type.(expr.UserType); ok && usesrc { - name += sourceTypeName() - } - n := serviceTypeName - if isStruct { - n = targetTypeName() - } else { - // If target is array, map, or primitive the name will be suffixed with - // the definition (e.g int, []string, map[int]string) which is incorrect. - n = sourceTypeName() + if !conversion.bound { + if err := conversion.transform.BindContexts(srcCtx, tgtCtx); err != nil { + panic(err) // bug + } + conversion.bound = true } - name += n - code, helpers, err := protoBufTransform(source, target, sourceVar, targetVar, srcCtx, tgtCtx, proto, true) + code, helpers, err := conversion.transform.Render(sourceVar, targetVar, true) if err != nil { panic(err) // bug } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + if conversion.side == grpcServerPackage { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } var args []*InitArgData if (!proto && !isEmpty(source.Type)) || (proto && !isEmpty(target.Type)) { args = []*InitArgData{{ @@ -1119,10 +1490,17 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV Example: d.Example(source, owner), }} } + sourceRef := "metadata values" + if !isEmpty(source.Type) { + sourceRef = srcCtx.Scope.Ref(source, srcCtx.Pkg(source)) + } + targetRef := tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)) return &InitData{ - Name: name, + Declaration: conversion.declaration, + Name: conversion.declaration.Name(), + Description: fmt.Sprintf("%s builds %s from %s.", conversion.declaration.Name(), targetRef, sourceRef), ReturnVarName: targetVar, - ReturnTypeRef: tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)), + ReturnTypeRef: targetRef, ReturnIsStruct: isStruct, ReturnTypePkg: tgtCtx.Pkg(target), Code: code, @@ -1130,6 +1508,42 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV } } +// buildCLIRequestTransform renders the planned protobuf-to-payload conversion +// in the client package where command-line payload builders use it. +func (d *ServicesData) buildCLIRequestTransform(endpoint *expr.GRPCEndpointExpr, sd *ServiceData) string { + conversion := d.symbols[endpoint.Service].endpoints[endpoint].cliPayload + pbCtx := protoBufTypeContext(sd.ClientProtobufPkgName, sd, false) + svcCtx := d.serviceTypeContext(sd, "client").Enter(endpoint.MethodExpr.Payload) + if !conversion.bound { + if err := conversion.transform.BindContexts(pbCtx, svcCtx); err != nil { + panic(err) // bug + } + conversion.bound = true + } + code, helpers, err := conversion.transform.Render("message", "v", true) + if err != nil { + panic(err) // bug + } + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + return code +} + +// initDeclaration returns the constructor and conversion requested for one +// endpoint value. Planning records both before generated package names are +// fixed. +func (d *ServicesData) initDeclaration(endpoint *expr.GRPCEndpointExpr, server bool, key grpcInitKey) *grpcConversion { + symbols := d.symbols[endpoint.Service].endpoints[endpoint] + declarations := symbols.clientInits + if server { + declarations = symbols.serverInits + } + init := declarations[key] + if init == nil { + panic(fmt.Sprintf("constructor name is missing for gRPC endpoint %q", endpoint.Name())) + } + return init +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response message for each error response are // inferred from the method's error expression if not specified explicitly. @@ -1161,45 +1575,33 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRPCEndpointExpr, message *expr.AttributeExpr, sd *ServiceData, svr bool) *ConvertData { // No need to build transformation functions for default error or non-object // types. - if ge.Type == expr.ErrorResult || !expr.IsObject(ge.Type) { + if expr.IsErrorResult(ge.Type) || !expr.IsObject(ge.Type) { return nil } - svc := sd.Service side := "client" if svr { side = "server" } svcCtx := d.serviceTypeContext(sd, side).Enter(ge.AttributeExpr) - errorTypeName := "" - for _, serviceError := range sd.Service.Method(e.Name()).Errors { - if serviceError.ErrName == ge.Name { - errorTypeName = serviceError.TypeName - break - } - } if svr { // server side owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) - data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, errorTypeName, true, false, sd, owner) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the gRPC error response type from the error of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ SrcName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), - TgtName: protoBufGoFullTypeName(message, sd.PkgName, sd), - TgtRef: protoBufGoFullTypeRef(message, sd.PkgName, sd), + TgtName: protoBufGoFullTypeName(message, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(message, sd.ServerProtobufPkgName, sd), Init: data, } } // client side owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) - data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, errorTypeName, false, false, sd, owner) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the error type of the %q endpoint of the %q service from the gRPC error response type.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, sd, owner, d.initDeclaration(e, false, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ - SrcName: protoBufGoFullTypeName(message, sd.PkgName, sd), - SrcRef: protoBufGoFullTypeRef(message, sd.PkgName, sd), + SrcName: protoBufGoFullTypeName(message, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(message, sd.ClientProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), Init: data, @@ -1207,6 +1609,71 @@ func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRP } } +// buildServerStreamSendConverts builds one protobuf conversion for each result +// view the server may send through a stream. +func (d *ServicesData) buildServerStreamSendConverts(e *expr.GRPCEndpointExpr, response, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + sourceVar := "result" + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + sourceVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + convert := &ConvertData{ + SrcName: resultCtx.Scope.Name(source, resultCtx.Pkg(source), resultCtx.Pointer, resultCtx.UseDefault), + SrcRef: resultCtx.Scope.Ref(source, resultCtx.Pkg(source)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: d.buildInitData(source, response, sourceVar, "v", resultCtx, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + +// buildClientStreamRecvConverts builds one service conversion for each result +// view the client may receive through a stream. +func (d *ServicesData) buildClientStreamRecvConverts(e *expr.GRPCEndpointExpr, response, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + targetVar := "result" + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + targetVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: resultCtx.Scope.Name(target, resultCtx.Pkg(target), resultCtx.Pointer, resultCtx.UseDefault), + TgtRef: resultCtx.Scope.Ref(target, resultCtx.Pkg(target)), + Init: d.buildInitData(response, target, "v", targetVar, resultCtx, false, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)), + Validation: addValidation(response, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + // buildStreamData builds the StreamData for the server and client streams. // // streamingRequest and responseMessage are the protobuf shaped copies of the @@ -1224,51 +1691,39 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendWithContextDesc string sendRef string sendConvert *ConvertData + sendConverts []*ViewConvertData recvName string recvDesc string recvWithContextName string recvWithContextDesc string recvRef string recvConvert *ConvertData + recvConverts []*ViewConvertData mustClose bool typ string ) - svc := sd.Service ed := sd.Endpoint(e.Name()) md := ed.Method - streamingPayloadName := md.StreamingPayload - resultName := md.StreamingResult - if resultName == "" { - resultName = md.Result - } side := "client" if svr { side = "server" } svcCtx := d.serviceTypeContext(sd, side).Enter(e.MethodExpr.StreamingPayload) result, resCtx := d.resultContext(e, sd, side) - if _, ok := result.Type.(expr.UserType); ok { - resultName = codegen.Goify(result.Type.Name(), true) - } - resVar := "result" - if md.ViewedResult != nil { - resVar = "vresult" - } if svr { typ = "server" varn = md.ServerStream.VarName - intName = fmt.Sprintf("%s.%s_%sServer", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ServerProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodServerStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ServerServicePkgName, md.ServerStream.Interface) if e.MethodExpr.Result.Type != expr.Empty { sendName = md.ServerStream.SendName sendRef = ed.ResultRef sendWithContextName = md.ServerStream.SendWithContextName - sendConvert = &ConvertData{ - SrcName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - SrcRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd), - TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), - Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, resultName, true, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr)), + sendConverts = d.buildServerStreamSendConverts(e, responseMessage, result, resCtx, sd) + sendConvert = primaryViewConvert(sendConverts) + if md.ViewedResult == nil { + sendConverts = nil } } if e.MethodExpr.StreamingPayload.Type != expr.Empty { @@ -1276,11 +1731,11 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques recvWithContextName = md.ServerStream.RecvWithContextName recvRef = svcCtx.Scope.Ref(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload)) recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd), - SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), + SrcName: protoBufGoFullTypeName(streamingRequest, sd.ServerProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.ServerProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: recvRef, - Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, streamingPayloadName, false, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)), + Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcStreamingRequestInit})), Validation: addValidation(streamingRequest, sd, true), } } @@ -1288,8 +1743,9 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques } else { typ = "client" varn = md.ClientStream.VarName - intName = fmt.Sprintf("%s.%s_%sClient", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ClientStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ClientProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodClientStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ClientServicePkgName, md.ClientStream.Interface) if e.MethodExpr.StreamingPayload.Type != expr.Empty { sendName = md.ClientStream.SendName sendWithContextName = md.ClientStream.SendWithContextName @@ -1297,22 +1753,19 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendConvert = &ConvertData{ SrcName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: sendRef, - TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd), - TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd), - Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, streamingPayloadName, true, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)), + TgtName: protoBufGoFullTypeName(streamingRequest, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.ClientProtobufPkgName, sd), + Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcStreamingRequestInit})), } } if e.MethodExpr.Result.Type != expr.Empty { recvName = md.ClientStream.RecvName recvWithContextName = md.ClientStream.RecvWithContextName recvRef = ed.ResultRef - recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd), - SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd), - TgtName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - TgtRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, resultName, false, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr)), - Validation: addValidation(responseMessage, sd, false), + recvConverts = d.buildClientStreamRecvConverts(e, responseMessage, result, resCtx, sd) + recvConvert = primaryViewConvert(recvConverts) + if md.ViewedResult == nil { + recvConverts = nil } } mustClose = md.ClientStream.MustClose @@ -1337,38 +1790,59 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SendWithContextDesc: sendWithContextDesc, SendRef: sendRef, SendConvert: sendConvert, + SendConverts: sendConverts, RecvName: recvName, RecvDesc: recvDesc, RecvWithContextName: recvWithContextName, RecvWithContextDesc: recvWithContextDesc, RecvRef: recvRef, RecvConvert: recvConvert, + RecvConverts: recvConverts, MustClose: mustClose, } } +// primaryViewConvert returns the conversion kept in the original single-value +// data field. A caller-selected result uses its default view. A fixed result +// has only its selected view. +func primaryViewConvert(converts []*ViewConvertData) *ConvertData { + if len(converts) == 0 { + return nil + } + if len(converts) == 1 { + return converts[0].Convert + } + for _, convert := range converts { + if convert.View == expr.DefaultView { + return convert.Convert + } + } + panic("caller-selected gRPC result views do not include the default view") // bug +} + // extractMetadata collects the request/response metadata from the given // metadata attribute and service type (payload/result). -func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side string, owner expr.ExampleIdentity) []*MetadataData { - var metadata []*MetadataData - codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck - wire := nativeMetadataAttribute(c) +func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side, decodeTarget string, owner expr.ExampleIdentity) []*MetadataData { + plans, ok := d.metadataPlans[a] + if !ok { + panic("saved gRPC metadata fields are missing") + } + metadata := make([]*MetadataData, 0, len(plans)) + for _, plan := range plans { + wire := plan.wire arr := expr.AsArray(wire.Type) - wireCtx := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()).Enter(wire) - serviceField := service - ft := service.Type - varn := codegen.Goify(name, false) - fieldName := codegen.Goify(name, true) - var pointer bool - if !expr.IsObject(service.Type) { - fieldName = "" - } else { - pointer = service.IsPrimitivePointer(name, true) - serviceField = service.Find(name) - ft = serviceField.Type - } + mp := expr.AsMap(wire.Type) + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(wire) + var cliValidation func(string) string + if plan.validation != "" { + cliValidation = grpcCLIValidationRenderer(wire, wireCtx, plan.name) + } + varn := codegen.Goify(plan.name, false) + fieldName := plan.fieldName + typeName := wireCtx.Scope.Name(wire, wireCtx.Pkg(wire), false, true) typeRef := wireCtx.Scope.Ref(wire, wireCtx.Pkg(wire)) - if pointer { + valueTypeRef := typeRef + if plan.pointer { typeRef = "*" + typeRef } serviceVar := "payload" @@ -1378,102 +1852,116 @@ func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *exp encodeSide = "server" } fieldRef := serviceVar - targetRef := metadataTargetPlaceholder + targetRef := decodeTarget if fieldName != "" { fieldRef += "." + fieldName targetRef += "." + fieldName } wireVar := varn + "Wire" - encodeCode := d.metadataTransform(wire, serviceField, fieldRef, wireVar, sd, encodeSide, pointer, true) - decodeCode := d.metadataTransform(wire, serviceField, varn, targetRef, sd, side, pointer, false) + encodeCode := d.metadataTransform(plan, fieldRef, wireVar, sd, encodeSide, true) + decodeCode := d.metadataTransform(plan, varn, targetRef, sd, side, false) metadata = append(metadata, &MetadataData{ - Name: elem, - AttributeName: name, + Name: plan.element, + AttributeName: plan.name, Description: wire.Description, FieldName: fieldName, - FieldType: ft, - ServiceAttribute: serviceField, + FieldType: plan.fieldType, + ServiceAttribute: plan.serviceField, WireAttribute: wire, VarName: varn, WireVarName: wireVar, EncodeCode: encodeCode, DecodeCode: decodeCode, - Required: required, + Required: plan.required, Type: wire.Type, - TypeName: wireCtx.Scope.Name(wire, wireCtx.Pkg(wire), false, true), + TypeName: typeName, TypeRef: typeRef, - Pointer: pointer, + Pointer: plan.pointer, Slice: arr != nil, StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, - Validate: codegen.AttributeValidationCode(wire, nil, wireCtx, required, false, varn, name), - DefaultValue: wire.DefaultValue, - Example: d.FieldExample(wire, service, name, owner), + Map: mp != nil, + MapStringSlice: mp != nil && + mp.KeyType.Type.Kind() == expr.StringKind && + mp.ElemType.Type.Kind() == expr.ArrayKind && + expr.AsArray(mp.ElemType.Type).ElemType.Type.Kind() == expr.StringKind, + Validate: plan.validation, + CLIPlan: cli.NewFlagPlan(wire, typeName, valueTypeRef, cliValidation), + DefaultValue: wire.DefaultValue, + Example: d.FieldExample(wire, service, plan.name, owner), }) - return nil - }) + } return metadata } -// metadataTransform generates the canonical conversion between a detached -// metadata value and its service field in the package that renders the code. -func (d *ServicesData) metadataTransform(wire, serviceField *expr.AttributeExpr, sourceVar, targetVar string, sd *ServiceData, side string, pointer, encode bool) string { - wireCtx := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()).Enter(wire) - serviceCtx := d.serviceTypeContext(sd, side).Enter(serviceField) - source, target := wire, serviceField +// metadataTransform writes the conversion between one generated metadata +// value and its service field using the imports of the generated file. +func (d *ServicesData) metadataTransform(plan *grpcMetadataPlan, sourceVar, targetVar string, sd *ServiceData, side string, encode bool) string { + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(plan.wire) + serviceCtx := d.serviceTypeContext(sd, side).Enter(plan.serviceField) sourceCtx, targetCtx := wireCtx, serviceCtx + transform := plan.decode if encode { - source, target = serviceField, wire sourceCtx, targetCtx = serviceCtx, wireCtx + transform = plan.encode } - if pointer { - sourceVar = "*" + sourceVar + if err := transform.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + valueVar := sourceVar + if plan.pointer { + valueVar = "*" + sourceVar } if encode { - code, helpers, err := codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "", true) + code, helpers, err := transform.Render(valueVar, targetVar, true) if err != nil { panic(err) } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + sd.appendMetadataHelpers(side, helpers) return code } - if !pointer { - code, helpers, err := codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "", false) + if !plan.pointer { + code, helpers, err := transform.Render(valueVar, targetVar, false) if err != nil { panic(err) } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + sd.appendMetadataHelpers(side, helpers) return code } - converted := codegen.Goify(strings.TrimPrefix(sourceVar, "*"), false) + "Service" - code, helpers, err := codegen.GoTransform(source, target, sourceVar, converted, sourceCtx, targetCtx, "", true) + converted := codegen.Goify(sourceVar, false) + "Service" + code, helpers, err := transform.Render(valueVar, converted, true) if err != nil { panic(err) } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) - return "if " + strings.TrimPrefix(sourceVar, "*") + " != nil {\n" + code + "\n" + targetVar + " = &" + converted + "\n}\n" + sd.appendMetadataHelpers(side, helpers) + return "if " + sourceVar + " != nil {\n" + code + "\n" + targetVar + " = &" + converted + "\n}\n" } -// initArgsFromMetadata converts the given metadata into constructor arguments -// so the metadata values can be passed to the generated init functions. -func initArgsFromMetadata(md []*MetadataData, targetVar string) []*InitArgData { +// appendMetadataHelpers writes recursive metadata conversions on the same +// client or server side as the metadata codec that calls them. +func (sd *ServiceData) appendMetadataHelpers(side string, helpers []*codegen.TransformFunctionData) { + if side == "server" { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } +} + +// argsFromMetadata builds arguments that expose decoded metadata values. +func argsFromMetadata(md []*MetadataData) []*InitArgData { args := make([]*InitArgData, len(md)) for i, m := range md { - initCode := "" - if targetVar != "" { - initCode = strings.ReplaceAll(m.DecodeCode, metadataTargetPlaceholder, targetVar) - } args[i] = &InitArgData{ Name: m.VarName, Ref: m.VarName, FieldName: m.FieldName, FieldType: m.FieldType, - InitCode: initCode, TypeName: m.TypeName, TypeRef: m.TypeRef, Type: m.Type, Pointer: m.Pointer, Required: m.Required, Validate: m.Validate, + CLIPlan: m.CLIPlan, Example: m.Example, DefaultValue: m.DefaultValue, } @@ -1481,6 +1969,27 @@ func initArgsFromMetadata(md []*MetadataData, targetVar string) []*InitArgData { return args } +// grpcCLIValidationRenderer writes checks for the concrete value parsed from +// command-line metadata. Metadata fields use pointers to track presence, but +// the CLI has already proved presence and passes the parsed value itself. +func grpcCLIValidationRenderer(attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, false, target, name) + } +} + +// initArgsFromMetadata adds the exact conversion that populates the service +// constructor result from each metadata argument. +func initArgsFromMetadata(md []*MetadataData) []*InitArgData { + args := argsFromMetadata(md) + for index, metadata := range md { + args[index].InitCode = metadata.DecodeCode + } + return args +} + // usesStreamEnvelope reports whether the transport needs a typed stream // envelope to carry both the one-shot method payload and streaming payload // items. @@ -1519,25 +2028,30 @@ func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname strin // buildStreamEnvelopeData computes the generated Go names for the protobuf // oneof field and wrapper types of the synthesized stream envelope. -func buildStreamEnvelopeData(envelope *expr.AttributeExpr, message *service.UserTypeData, sd *ServiceData) *StreamEnvelopeData { +func buildStreamEnvelopeData(envelope *expr.AttributeExpr, sd *ServiceData) *StreamEnvelopeData { body := envelope.Find("body") union := expr.AsUnion(body.Type) - scope := &protoBufScope{service: sd} + scope := &protoBufScope{service: sd, pkg: sd.ClientProtobufPkgName} + serverScope := &protoBufScope{service: sd, pkg: sd.ServerProtobufPkgName} fieldName := scope.Field(body, union.TypeName, true) initialFieldName := scope.Field(union.Values[0].Attribute, union.Values[0].Name, true) streamItemFieldName := scope.Field(union.Values[1].Attribute, union.Values[1].Name, true) return &StreamEnvelopeData{ - FieldName: fieldName, - InitialFieldName: initialFieldName, - InitialWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, initialFieldName), - StreamItemFieldName: streamItemFieldName, - StreamItemWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, streamItemFieldName), + FieldName: fieldName, + InitialFieldName: initialFieldName, + InitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ClientInitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ServerInitialWrapperRef: serverScope.OneofWrapper(union.Values[0].Attribute), + StreamItemFieldName: streamItemFieldName, + StreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ClientStreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ServerStreamItemWrapperRef: serverScope.OneofWrapper(union.Values[1].Attribute), } } -// nativeMetadataAttribute returns a detached primitive or primitive-array -// value for gRPC metadata. Named service declarations are recursively removed -// while their validation and default contracts remain on the wire copy. +// nativeMetadataAttribute copies a gRPC metadata value as a primitive or array +// of primitives. It removes named service types but keeps their default values +// and validation rules on the copy used by the transport. func nativeMetadataAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { if userType, ok := source.Type.(expr.UserType); ok { result := nativeMetadataAttribute(userType.Attribute()) @@ -1617,8 +2131,8 @@ func (d *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen } } -// resultContext returns the method result and its frozen service or view -// declaration context for side. +// resultContext returns the method result and the final service or view type +// names used by the generated client or server package. func (d *ServicesData) resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData, side string) (*expr.AttributeExpr, *codegen.AttributeContext) { md := sd.Service.Method(e.Name()) if md.ViewedResult != nil { diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go index e5c8f4cfdd..acf6eb405d 100644 --- a/grpc/codegen/service_data_traversal_test.go +++ b/grpc/codegen/service_data_traversal_test.go @@ -21,7 +21,7 @@ func TestCollectMessagesDistinguishesEqualNameAndUIDOrigins(t *testing.T) { }} sd := grpcTraversalServiceData() - messages := freezeTraversalMessages(sd, root) + messages := freezeTraversalMessages(t, sd, root) require.Len(t, messages, 2) require.NotEqual(t, messages[0].VarName, messages[1].VarName) require.NotEqual(t, messages[0].Ref, messages[1].Ref) @@ -40,7 +40,7 @@ func TestCollectMessagesDistinguishesOneOriginWithDifferentWireShape(t *testing. }} sd := grpcTraversalServiceData() - messages := freezeTraversalMessages(sd, root) + messages := freezeTraversalMessages(t, sd, root) require.Len(t, messages, 2) require.NotEqual(t, messages[0].VarName, messages[1].VarName) require.Contains(t, messages[0].Def, "string value = 1") @@ -56,7 +56,7 @@ func TestCollectMessagesReusesIdenticalDeclaration(t *testing.T) { {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, }} - messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) require.Len(t, messages, 1) } @@ -75,7 +75,7 @@ func TestCollectMessagesDistinguishesProtoOverrides(t *testing.T) { {Name: "second", Attribute: second}, }} - messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) require.Len(t, messages, 2) require.Equal(t, "FirstWire", messages[0].VarName) require.Equal(t, "SecondWire", messages[1].VarName) @@ -95,7 +95,7 @@ func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentSchemas(t *t {Name: "second", Attribute: second}, }} - messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) require.Len(t, messages, 2) require.Equal(t, "SharedWire", messages[0].VarName) require.Equal(t, "SharedWire2", messages[1].VarName) @@ -117,7 +117,7 @@ func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentOrigins(t *t {Name: "second", Attribute: second}, }} - messages := freezeTraversalMessages(grpcTraversalServiceData(), root) + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) require.Len(t, messages, 2) require.Equal(t, "SharedWire", messages[0].VarName) require.Equal(t, "SharedWire2", messages[1].VarName) @@ -145,8 +145,9 @@ func TestCollectMessagesUsesUnaryResultSourceForMixedResults(t *testing.T) { } sd := grpcTraversalServiceData() sd.protobuf = newProtobufPackageCatalog(sd.PkgName) - sd.protobuf.collectMessage(firstWire, protobufRootMessageSource(firstWire, firstEndpoint, nil, protobufResponseMessage), sd) - sd.protobuf.collectMessage(secondWire, protobufRootMessageSource(secondWire, secondEndpoint, nil, protobufResponseMessage), sd) + require.NoError(t, sd.protobuf.collectMessage(firstWire, protobufRootMessageSource(firstWire, firstEndpoint, nil, protobufResponseMessage))) + require.NoError(t, sd.protobuf.collectMessage(secondWire, protobufRootMessageSource(secondWire, secondEndpoint, nil, protobufResponseMessage))) + planTestProtobufCatalog(t, sd) messages := sd.protobuf.freezeMessages(sd) require.Len(t, messages, 2) @@ -165,7 +166,7 @@ func TestCollectMessagesStopsAtRecursiveCopy(t *testing.T) { }, }) - messages := freezeTraversalMessages(grpcTraversalServiceData(), &expr.AttributeExpr{Type: expr.Dup(message)}) + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), &expr.AttributeExpr{Type: expr.Dup(message)}) require.Len(t, messages, 1) require.Contains(t, messages[0].Def, "Recursive next = 2") } @@ -183,13 +184,93 @@ func TestProtoBufMessageNameIgnoresLateScopeAllocations(t *testing.T) { message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") attribute := &expr.AttributeExpr{Type: message} sd := grpcTraversalServiceData() - messages := freezeTraversalMessages(sd, attribute) + messages := freezeTraversalMessages(t, sd, attribute) require.Len(t, messages, 1) sd.Scope.HashedUnique(grpcMessageTraversalType("Other", "other", expr.Int, "1"), "Shared") require.Equal(t, messages[0].VarName, protoBufMessageName(attribute, sd)) } +// TestProtobufCopiesRequireRegistration checks that a copied protobuf value +// uses names only after the copy is connected to the original value. +func TestProtobufCopiesRequireRegistration(t *testing.T) { + minimum := 2 + state := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + { + Name: "active", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }, + }, + }, + }} + message := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "state", Attribute: state}, + }}, + TypeName: "Message", + UID: "message", + } + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + copy := expr.DupAtt(attribute) + copyState := expr.AsObject(copy.Type.(expr.UserType)).Attribute("state") + branch := state.Type.(*expr.Union).Values[0].Attribute + copyBranch := copyState.Type.(*expr.Union).Values[0].Attribute + require.Nil(t, sd.protobuf.messageRecord(copy)) + require.Panics(t, func() { + sd.protobuf.unionName(copyState) + }) + _, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.False(t, ok) + + sd.protobuf.plan.bindAttributeCopy(attribute, copy) + require.Same(t, sd.protobuf.messageRecord(attribute), sd.protobuf.messageRecord(copy)) + require.Equal(t, sd.protobuf.unionName(state), sd.protobuf.unionName(copyState)) + require.Nil(t, sd.protobuf.validationRecord(copy, validateServer)) + originalWrapper, ok := sd.protobuf.plan.wrapperName(branch) + require.True(t, ok) + copyWrapper, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.True(t, ok) + require.Equal(t, originalWrapper, copyWrapper) +} + +// TestProtobufValidationScopeKeepsMessageNameSeparate checks that a validation +// function collision cannot change the protobuf message name used in its body. +func TestProtobufValidationScopeKeepsMessageNameSeparate(t *testing.T) { + minimum := 2 + message := grpcValidationTraversalType("Message", "message", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + record := sd.protobuf.validationRecord(attribute, validateServer) + require.NotNil(t, record) + record.message.name = "RetainedMessage2" + scope := &protobufValidationScope{ + protoBufScope: &protoBufScope{service: sd}, + catalog: sd.protobuf, + side: validateServer, + message: record.message, + parent: message, + } + + require.Equal(t, record.message.name, scope.Name(attribute, "", false, false)) +} + func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") first := expr.Dup(original).(expr.UserType) @@ -205,9 +286,11 @@ func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { {Name: "first", Attribute: firstAttribute}, {Name: "second", Attribute: secondAttribute}, }} - freezeTraversalMessages(sd, root) - sd.protobuf.collectValidation(firstAttribute, validateServer, "message", "message") - sd.protobuf.collectValidation(secondAttribute, validateServer, "message", "message") + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(firstAttribute, validateServer, source, "message", "message") + sd.protobuf.collectValidation(secondAttribute, validateServer, source.child("second"), "message", "message") + planTraversalValidations(t, sd) sd.validations = sd.protobuf.freezeValidations(sd) firstValidation := addValidation(firstAttribute, sd, true) @@ -215,7 +298,7 @@ func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { require.NotNil(t, firstValidation) require.NotNil(t, secondValidation) require.Len(t, sd.validations, 2) - require.NotEqual(t, firstValidation.Name, secondValidation.Name) + require.NotEqual(t, firstValidation.Declaration.Name(), secondValidation.Declaration.Name()) require.Contains(t, firstValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 2, true)`) require.Contains(t, secondValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 5, true)`) } @@ -226,9 +309,13 @@ func TestAddValidationDistinguishesGeneratedSide(t *testing.T) { expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} attribute := &expr.AttributeExpr{Type: message} sd := grpcTraversalServiceData() - freezeTraversalMessages(sd, attribute) - sd.protobuf.collectValidation(attribute, validateServer, "message", "message") - sd.protobuf.collectValidation(attribute, validateClient, "message", "message") + freezeTraversalMessages(t, sd, attribute) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(attribute, validateServer, source, "message", "message") + response := source + response.role = protobufResponseValidation + sd.protobuf.collectValidation(attribute, validateClient, response, "message", "message") + planTraversalValidations(t, sd) sd.validations = sd.protobuf.freezeValidations(sd) server := addValidation(attribute, sd, true) @@ -251,9 +338,11 @@ func TestAddValidationReusesIdenticalRulesOnOneSide(t *testing.T) { {Name: "first", Attribute: first}, {Name: "second", Attribute: second}, }} - freezeTraversalMessages(sd, root) - sd.protobuf.collectValidation(first, validateServer, "message", "message") - sd.protobuf.collectValidation(second, validateServer, "message", "message") + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(first, validateServer, source, "message", "message") + sd.protobuf.collectValidation(second, validateServer, source.child("second"), "message", "message") + planTraversalValidations(t, sd) sd.validations = sd.protobuf.freezeValidations(sd) require.Len(t, sd.validations, 1) @@ -277,16 +366,66 @@ func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { }} sd := &ServiceData{PkgName: "pb", Scope: codegen.NewNameScope()} - freezeTraversalMessages(sd, root) - sd.protobuf.collectValidation(root, validateServer, "message", "message") + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) sd.validations = sd.protobuf.freezeValidations(sd) - var names []string + names := make([]string, 0, len(sd.validations)) for _, validation := range sd.validations { names = append(names, validation.SrcName) } require.ElementsMatch(t, []string{"First", "Second"}, names) } +// grpcTraversalValidationSource describes the request used by focused +// validation tests. +func grpcTraversalValidationSource() protobufValidationSource { + return protobufValidationSource{ + api: "TestAPI", + service: "TestService", + method: "Call", + role: protobufRequestValidation, + } +} + +// planTraversalValidations chooses the function names used by these focused +// message and validation tests before building the function bodies. +func planTraversalValidations(t *testing.T, sd *ServiceData) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/grpc/test/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/grpc/test/server") + require.NoError(t, err) + for _, record := range sd.protobuf.validators { + pkg := client + side := grpcClientPackage + if record.side == validateServer { + pkg = server + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: record.source.api, + service: record.source.service, + method: record.source.method, + subject: record.source.error, + path: record.source.path, + operation: int(record.source.role), + } + record.declaration = codegen.NewPreferredName( + codegen.NameFunction, + "Validate"+record.message.plannedName, + codegen.ExportedName, + grpcSymbolOrder(id), + ) + require.NoError(t, pkg.DeclareName(record.declaration)) + } + require.NoError(t, generation.Freeze()) +} + // grpcValidationTraversalType builds an authored message declaration with one // constrained field so validation discovery must emit a helper for it. func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *expr.UserTypeExpr { @@ -331,9 +470,51 @@ func grpcTraversalServiceData() *ServiceData { // freezeTraversalMessages collects and freezes every message reachable from // root in the focused test protobuf package. -func freezeTraversalMessages(sd *ServiceData, root *expr.AttributeExpr) []*service.UserTypeData { +func freezeTraversalMessages(t *testing.T, sd *ServiceData, root *expr.AttributeExpr) []*service.UserTypeData { sd.protobuf = newProtobufPackageCatalog(sd.PkgName) - sd.protobuf.collectMessage(root, protobufMessageSource{}, sd) + require.NoError(t, sd.protobuf.collectMessage(root, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) sd.Messages = sd.protobuf.freezeMessages(sd) return sd.Messages } + +// planTestProtobufCatalog chooses names for the messages and validation +// functions created directly by these focused tests. +func planTestProtobufCatalog(t *testing.T, sd *ServiceData) { + t.Helper() + require.NotEmpty(t, sd.protobuf.messages) + message := sd.protobuf.messages[0].uses[0] + serviceExpr := &expr.ServiceExpr{Name: "GoaCatalogTestService"} + grpcService := &expr.GRPCServiceExpr{ServiceExpr: serviceExpr} + method := &expr.MethodExpr{ + Name: "Call", + Service: serviceExpr, + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.Empty}, + } + endpoint := &expr.GRPCEndpointExpr{MethodExpr: method, Service: grpcService} + grpcService.GRPCEndpoints = []*expr.GRPCEndpointExpr{endpoint} + plan := &protobufServicePlan{ + expression: grpcService, + catalog: sd.protobuf, + messages: []*protobufEndpointMessages{{request: message, response: message}}, + protoPackage: "goa_catalog_test", + methods: map[*expr.GRPCEndpointExpr]string{}, + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + sd.protobuf.plan = plan + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/grpc/test/pb") + require.NoError(t, err) + require.NoError(t, plan.chooseNames(pkg, make(map[string]struct{}))) + require.NoError(t, generation.Freeze()) +} diff --git a/grpc/codegen/service_imports.go b/grpc/codegen/service_imports.go index b849f96373..8f7c06a4a2 100644 --- a/grpc/codegen/service_imports.go +++ b/grpc/codegen/service_imports.go @@ -10,14 +10,19 @@ import ( "goa.design/goa/v3/expr" ) -// addEndpointImports adds the named service-type references used by endpoints -// to file's header. The output package is computed from the generated path. -// Current gRPC server, client, codec, type, and CLI files each render every -// endpoint; callers pass that complete endpoint list explicitly. -func addEndpointImports(file *codegen.File, services *ServicesData, endpoints ...*expr.GRPCEndpointExpr) *codegen.File { +// addEndpointImports adds the packages recorded for one service to a generated +// file and omits the package that contains the file itself. +func addEndpointImports(file *codegen.File, services *ServicesData, service *grpcServicePlan) *codegen.File { outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, grpcEndpointAttributes(endpoints...)...)...) + owner := services.generation.Package(outputPackage) + imports := make([]*codegen.ImportSpec, 0, len(service.imports)) + for _, importPath := range service.imports { + if importPath != outputPackage { + imports = append(imports, owner.Import(importPath)) + } + } + codegen.AddImport(file.SectionTemplates[0], imports...) return file } diff --git a/grpc/codegen/service_metadata_reference_test.go b/grpc/codegen/service_metadata_reference_test.go index 4fb8c2fa08..4413af8566 100644 --- a/grpc/codegen/service_metadata_reference_test.go +++ b/grpc/codegen/service_metadata_reference_test.go @@ -32,10 +32,12 @@ func TestMetadataConversionUsesDetachedWireAndFrozenServiceDeclaration(t *testin metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata require.Len(t, metadata, 1) + require.False(t, metadata[0].Map) + require.False(t, metadata[0].MapStringSlice) require.Equal(t, "string", metadata[0].TypeRef) require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") require.Contains(t, metadata[0].EncodeCode, "string(payload.Value)") - require.Contains(t, initArgsFromMetadata(metadata, "v")[0].InitCode, "shared.Value(value)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(value)") } func TestMetadataConversionRecursivelyDetachesNamedArrayElements(t *testing.T) { @@ -71,7 +73,7 @@ func TestMetadataConversionRecursivelyDetachesNamedArrayElements(t *testing.T) { require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") require.NotContains(t, wireArray.ElemType.Meta, "struct:pkg:path") require.Contains(t, metadata[0].EncodeCode, "int(val)") - require.Contains(t, initArgsFromMetadata(metadata, "v")[0].InitCode, "shared.Value(val)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(val)") require.Contains(t, serviceField.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") require.Contains(t, expr.AsArray(serviceField.Type).ElemType.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") } diff --git a/grpc/codegen/service_plan.go b/grpc/codegen/service_plan.go new file mode 100644 index 0000000000..f11edf855b --- /dev/null +++ b/grpc/codegen/service_plan.go @@ -0,0 +1,594 @@ +// This file copies each gRPC service and endpoint while NewPlans can still +// read the evaluated design. Link and the file builders read these copies. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // grpcServicePlan stores one copied gRPC service and the values selected for + // its generated files. + grpcServicePlan struct { + source *expr.GRPCServiceExpr + expression *expr.GRPCServiceExpr + packages *grpcServicePackage + endpoints []*grpcEndpointPlan + endpointByExpr map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + imports []string + protoImports []string + protoGoImports []*codegen.ImportSpec + scope *codegen.NameScope + usesAny bool + usesAnyInErrors bool + } + + // grpcEndpointPlan stores one copied endpoint and the metadata conversions + // that must be prepared before generated names are available. + grpcEndpointPlan struct { + expression *expr.GRPCEndpointExpr + legacyStream bool + legacyMetadata *expr.MappedAttributeExpr + metadata map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + } + + // grpcMetadataPlan stores one metadata field, its Go field, and the code that + // converts the value in both directions. + grpcMetadataPlan struct { + name string + element string + required bool + fieldName string + fieldType expr.DataType + pointer bool + serviceField *expr.AttributeExpr + wire *expr.AttributeExpr + scope *codegen.NameScope + validation string + encode *codegen.TransformPlan + decode *codegen.TransformPlan + } +) + +// collectGRPCServicePlans copies the services selected for one gRPC plan and +// records the imports and metadata conversions used by their generated files. +func collectGRPCServicePlans(generation *codegen.Generation, plan *Plan) ([]*grpcServicePlan, error) { + services := make([]*grpcServicePlan, len(plan.expressions)) + for index, source := range plan.expressions { + service, err := copyGRPCService(source) + if err != nil { + return nil, err + } + service.scope = codegen.NewNameScope() + service.usesAny = usesAnyType(service.expression.GRPCEndpoints, false) + service.usesAnyInErrors = usesAnyType(service.expression.GRPCEndpoints, true) + service.imports = grpcServiceImportPaths(generation, service.expression) + service.packages = plan.packages[source] + + plannedProtobuf := plan.protobuf[source] + plannedTools := plan.tools[source] + plannedSymbols := plan.symbols[source] + service.protoImports, service.protoGoImports = collectGRPCProtobufImports(plannedProtobuf) + plan.protobuf[service.expression] = plannedProtobuf + plan.tools[service.expression] = plannedTools + plan.symbols[service.expression] = plannedSymbols + for endpointIndex, endpoint := range service.endpoints { + sourceEndpoint := source.GRPCEndpoints[endpointIndex] + if plannedSymbols != nil { + plannedSymbols.endpoints[endpoint.expression] = plannedSymbols.endpoints[sourceEndpoint] + } + if plannedProtobuf != nil { + plannedProtobuf.methods[endpoint.expression] = plannedProtobuf.methods[sourceEndpoint] + } + if declaration := plan.cli.builders[sourceEndpoint]; declaration != nil { + plan.cli.builders[endpoint.expression] = declaration + } + if err := planEndpointMetadata(endpoint); err != nil { + return nil, fmt.Errorf("plan gRPC metadata for %q.%q: %w", service.expression.Name(), endpoint.expression.Name(), err) + } + } + if err := replaceGRPCTransforms(plan.service, source, service, plannedProtobuf, plannedSymbols); err != nil { + return nil, fmt.Errorf("copy gRPC conversions for service %q: %w", service.expression.Name(), err) + } + services[index] = service + } + return services, nil +} + +// collectGRPCProtobufImports records the protobuf schema files and Go packages +// selected by every saved protobuf message. +func collectGRPCProtobufImports(protobuf *protobufServicePlan) ([]string, []*codegen.ImportSpec) { + if protobuf == nil { + return nil, nil + } + var attributes []*expr.AttributeExpr + for _, messages := range protobuf.messages { + attributes = append(attributes, messages.request, messages.streamingRequest, messages.requestEnvelope, messages.response) + errorNames := make([]string, 0, len(messages.errors)) + for name := range messages.errors { + errorNames = append(errorNames, name) + } + sort.Strings(errorNames) + for _, name := range errorNames { + attributes = append(attributes, messages.errors[name]) + } + } + var protoImports []string + var goImports []*codegen.ImportSpec + seenProto := make(map[string]struct{}) + seenGo := make(map[string]struct{}) + seenTypes := make(map[expr.UserType]struct{}) + var walk func(*expr.AttributeExpr) + walk = func(attribute *expr.AttributeExpr) { + if attribute == nil { + return + } + if field := attribute.Meta["struct:field:proto"]; len(field) > 1 { + if _, ok := seenProto[field[1]]; !ok { + seenProto[field[1]] = struct{}{} + protoImports = append(protoImports, field[1]) + } + if len(field) > 3 { + if _, ok := seenGo[field[3]]; !ok { + seenGo[field[3]] = struct{}{} + goImports = append(goImports, codegen.NewImport(path.Base(field[3]), field[3])) + } + } + } + if attribute.Type.Kind() == expr.AnyKind { + const structProto = "google/protobuf/struct.proto" + if _, ok := seenProto[structProto]; !ok { + seenProto[structProto] = struct{}{} + protoImports = append(protoImports, structProto) + } + return + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if _, ok := seenTypes[actual]; ok { + return + } + seenTypes[actual] = struct{}{} + walk(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + walk(named.Attribute) + } + case *expr.Array: + walk(actual.ElemType) + case *expr.Map: + walk(actual.KeyType) + walk(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + walk(named.Attribute) + } + } + } + for _, attribute := range attributes { + walk(attribute) + } + return protoImports, goImports +} + +// replaceGRPCTransforms rebuilds every conversion from the copied method. This +// prevents later changes to the original design from changing generated code. +func replaceGRPCTransforms( + servicePlan *service.Plan, + source *expr.GRPCServiceExpr, + grpcService *grpcServicePlan, + protobuf *protobufServicePlan, + symbols *grpcSymbols, +) error { + if protobuf == nil || symbols == nil { + return fmt.Errorf("protobuf messages or Go names are missing") + } + replaced := make(map[*grpcConversion]struct{}) + replace := func(conversion *grpcConversion, source, target *expr.AttributeExpr, proto bool) error { + if conversion == nil { + return nil + } + if _, ok := replaced[conversion]; ok { + return nil + } + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return err + } + oldHelpers := conversion.transform.Helpers() + newHelpers := transform.Helpers() + if len(oldHelpers) != len(newHelpers) { + return fmt.Errorf("saved conversion helper count changed from %d to %d", len(oldHelpers), len(newHelpers)) + } + for index, helper := range newHelpers { + if err := transform.BindHelperDeclaration(helper.ID, oldHelpers[index].Declaration); err != nil { + return err + } + } + conversion.transform = transform + conversion.bound = false + replaced[conversion] = struct{}{} + return nil + } + for index, endpointPlan := range grpcService.endpoints { + endpoint := endpointPlan.expression + sourceEndpoint := source.GRPCEndpoints[index] + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + if _, viewed := sourceEndpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + projected, err := servicePlan.ProjectedResult(sourceEndpoint.MethodExpr) + if err != nil { + return err + } + result = expr.DupAtt(projected) + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcRequestInit}], messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + if err := replace(endpointSymbols.cliPayload, messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcRequestInit}], endpoint.MethodExpr.Payload, messages.request, true); err != nil { + return err + } + } + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcResponseInit}], result, messages.response, true); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcResponseInit}], messages.response, result, false); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := replace(endpointSymbols.serverInits[key], messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := replace(endpointSymbols.serverInits[key], result, messages.response, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], messages.response, result, false); err != nil { + return err + } + } + if endpointPlan.legacyStream && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := replace(endpointSymbols.serverInits[key], &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := replace(endpointSymbols.serverInits[key], grpcError.AttributeExpr, message, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], message, grpcError.AttributeExpr, false); err != nil { + return err + } + } + } + return nil +} + +// copyGRPCService makes a private copy of the service values read by gRPC +// planning and rendering. +func copyGRPCService(source *expr.GRPCServiceExpr) (*grpcServicePlan, error) { + serviceExpr := &expr.ServiceExpr{ + Name: source.ServiceExpr.Name, + Description: source.ServiceExpr.Description, + Meta: copyGRPCMeta(source.ServiceExpr.Meta), + } + serviceExpr.Errors = make([]*expr.ErrorExpr, len(source.ServiceExpr.Errors)) + for index, sourceError := range source.ServiceExpr.Errors { + serviceExpr.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + service := &expr.GRPCServiceExpr{ + ServiceExpr: serviceExpr, + ParentName: source.ParentName, + ProtoPkg: source.ProtoPkg, + Meta: copyGRPCMeta(source.Meta), + } + result := &grpcServicePlan{ + source: source, + expression: service, + endpoints: make([]*grpcEndpointPlan, len(source.GRPCEndpoints)), + endpointByExpr: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan, len(source.GRPCEndpoints)), + } + for index, sourceEndpoint := range source.GRPCEndpoints { + method := copyGRPCMethod(sourceEndpoint.MethodExpr, serviceExpr) + serviceExpr.Methods = append(serviceExpr.Methods, method) + endpoint := &expr.GRPCEndpointExpr{ + MethodExpr: method, + Service: service, + Request: expr.DupAtt(sourceEndpoint.Request), + StreamingRequest: expr.DupAtt(sourceEndpoint.StreamingRequest), + Response: copyGRPCResponse(sourceEndpoint.Response), + Metadata: expr.DupMappedAtt(sourceEndpoint.Metadata), + Requirements: copyGRPCRequirements(sourceEndpoint.Requirements), + Meta: copyGRPCMeta(sourceEndpoint.Meta), + } + endpoint.Response.Parent = endpoint + endpoint.GRPCErrors = make([]*expr.GRPCErrorExpr, len(sourceEndpoint.GRPCErrors)) + for errorIndex, sourceError := range sourceEndpoint.GRPCErrors { + methodError := method.Error(sourceError.Name) + if methodError == nil { + return nil, fmt.Errorf("gRPC error %q is not defined by method %q", sourceError.Name, method.Name) + } + response := copyGRPCResponse(sourceError.Response) + response.Parent = endpoint + endpoint.GRPCErrors[errorIndex] = &expr.GRPCErrorExpr{ + ErrorExpr: methodError, + Name: sourceError.Name, + Response: response, + } + } + endpointPlan := &grpcEndpointPlan{ + expression: endpoint, + legacyStream: sourceEndpoint.LegacyStreamCompat(), + metadata: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + } + service.GRPCEndpoints = append(service.GRPCEndpoints, endpoint) + result.endpoints[index] = endpointPlan + result.endpointByExpr[endpoint] = endpointPlan + } + return result, nil +} + +// copyGRPCMethod copies the method fields read by the gRPC transport. +func copyGRPCMethod(source *expr.MethodExpr, service *expr.ServiceExpr) *expr.MethodExpr { + method := &expr.MethodExpr{ + Name: source.Name, + Description: source.Description, + Payload: expr.DupAtt(source.Payload), + Result: expr.DupAtt(source.Result), + Requirements: copyGRPCRequirements(source.Requirements), + Service: service, + Meta: copyGRPCMeta(source.Meta), + Idempotent: source.Idempotent, + Stream: source.Stream, + StreamingPayload: expr.DupAtt(source.StreamingPayload), + } + switch { + case source.StreamingResult == nil: + method.StreamingResult = nil + case source.StreamingResult == source.Result: + method.StreamingResult = method.Result + default: + method.StreamingResult = expr.DupAtt(source.StreamingResult) + } + method.Errors = make([]*expr.ErrorExpr, len(source.Errors)) + for index, sourceError := range source.Errors { + method.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + return method +} + +// copyGRPCErrorAttribute preserves Goa's built-in error type while copying +// fields from a custom error type. +func copyGRPCErrorAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + result := expr.DupAtt(source) + if expr.IsErrorResult(source.Type) { + result.Type = expr.ErrorResult + } + return result +} + +// copyGRPCResponse copies one success or error response. +func copyGRPCResponse(source *expr.GRPCResponseExpr) *expr.GRPCResponseExpr { + return &expr.GRPCResponseExpr{ + StatusCode: source.StatusCode, + Description: source.Description, + Message: expr.DupAtt(source.Message), + Headers: expr.DupMappedAtt(source.Headers), + Trailers: expr.DupMappedAtt(source.Trailers), + Meta: copyGRPCMeta(source.Meta), + } +} + +// copyGRPCRequirements copies security lists so later list edits cannot change +// generated metadata handling. +func copyGRPCRequirements(source []*expr.SecurityExpr) []*expr.SecurityExpr { + result := make([]*expr.SecurityExpr, len(source)) + for index, requirement := range source { + copy := expr.DupRequirement(requirement) + copy.Scopes = append([]string(nil), requirement.Scopes...) + for schemeIndex, scheme := range copy.Schemes { + scheme.Scopes = append([]*expr.ScopeExpr(nil), scheme.Scopes...) + scheme.Flows = append([]*expr.FlowExpr(nil), scheme.Flows...) + scheme.Meta = copyGRPCMeta(scheme.Meta) + copy.Schemes[schemeIndex] = scheme + } + result[index] = copy + } + return result +} + +// copyGRPCMeta copies every value list in one Meta map. +func copyGRPCMeta(source expr.MetaExpr) expr.MetaExpr { + if source == nil { + return nil + } + result := make(expr.MetaExpr, len(source)) + for name, values := range source { + result[name] = append([]string(nil), values...) + } + return result +} + +// planEndpointMetadata prepares every request, response, and legacy request +// metadata field used by one endpoint. +func planEndpointMetadata(endpoint *grpcEndpointPlan) error { + expression := endpoint.expression + groups := []struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{ + {expression.Metadata, expression.MethodExpr.Payload}, + {expression.Response.Headers, expression.MethodExpr.Result}, + {expression.Response.Trailers, expression.MethodExpr.Result}, + } + if endpoint.legacyStream { + endpoint.legacyMetadata = legacyRequestMetadata(expression) + groups = append(groups, struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{endpoint.legacyMetadata, expression.MethodExpr.Payload}) + } + for _, group := range groups { + plans, err := planMetadataFields(group.mapped, group.service) + if err != nil { + return err + } + endpoint.metadata[group.mapped] = plans + } + return nil +} + +// planMetadataFields prepares the Go value and both conversions for every field +// in one metadata group. +func planMetadataFields(mapped *expr.MappedAttributeExpr, service *expr.AttributeExpr) ([]*grpcMetadataPlan, error) { + var result []*grpcMetadataPlan + err := codegen.WalkMappedAttr(mapped, func(name, element string, required bool, attribute *expr.AttributeExpr) error { + wire := nativeMetadataAttribute(attribute) + scope := codegen.NewNameScope() + wireContext := codegen.NewAttributeContext(false, false, true, "", scope).Enter(wire) + serviceField := service + fieldType := service.Type + fieldName := codegen.Goify(name, true) + var pointer bool + if !expr.IsObject(service.Type) { + fieldName = "" + } else { + pointer = service.IsPrimitivePointer(name, true) + serviceField = service.Find(name) + fieldType = serviceField.Type + } + encode, err := codegen.NewTransformPlan(serviceField, wire, "", nil) + if err != nil { + return err + } + decode, err := codegen.NewTransformPlan(wire, serviceField, "", nil) + if err != nil { + return err + } + if len(encode.Helpers()) > 0 || len(decode.Helpers()) > 0 { + return fmt.Errorf("metadata field %q needs a separate conversion function", name) + } + result = append(result, &grpcMetadataPlan{ + name: name, + element: element, + required: required, + fieldName: fieldName, + fieldType: fieldType, + pointer: pointer, + serviceField: serviceField, + wire: wire, + scope: scope, + validation: codegen.AttributeValidationCode(wire, nil, wireContext, required, false, codegen.Goify(name, false), name), + encode: encode, + decode: decode, + }) + return nil + }) + return result, err +} + +// legacyRequestMetadata builds the metadata fields used by clients that send +// the first streamed payload through metadata. +func legacyRequestMetadata(endpoint *expr.GRPCEndpointExpr) *expr.MappedAttributeExpr { + payload := endpoint.MethodExpr.Payload + legacy := expr.DupMappedAtt(endpoint.Metadata) + metadataObject := expr.AsObject(legacy.Type) + if payloadObject := expr.AsObject(payload.Type); payloadObject != nil { + for _, named := range *payloadObject { + if metadataObject.Attribute(named.Name) == nil { + metadataObject.Set(named.Name, expr.DupAtt(named.Attribute)) + } + if payload.IsRequired(named.Name) { + legacy.Validation.AddRequired(named.Name) + } + } + } else { + metadataObject.Set("goa_payload", expr.DupAtt(payload)) + legacy.Validation.AddRequired("goa_payload") + } + return legacy +} + +// grpcServiceImportPaths records every package used by service values in gRPC +// client, server, type, and command-line files. +func grpcServiceImportPaths(generation *codegen.Generation, service *expr.GRPCServiceExpr) []string { + paths := make(map[string]struct{}) + seen := make(map[expr.UserType]struct{}) + for _, attribute := range grpcEndpointAttributes(service.GRPCEndpoints...) { + collectGRPCAttributeImportPaths(generation, attribute, paths, seen) + } + result := make([]string, 0, len(paths)) + for importPath := range paths { + result = append(result, importPath) + } + sort.Strings(result) + return result +} + +// collectGRPCAttributeImportPaths walks one service value and records named +// generated packages and explicit field packages. +func collectGRPCAttributeImportPaths(generation *codegen.Generation, attribute *expr.AttributeExpr, paths map[string]struct{}, seen map[expr.UserType]struct{}) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + if _, spec := codegen.GetMetaType(attribute); spec != nil { + paths[spec.Path] = struct{}{} + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + paths[path.Join(generation.GenPkg(), location.RelImportPath)] = struct{}{} + } + if _, ok := seen[actual]; ok { + return + } + seen[actual] = struct{}{} + collectGRPCAttributeImportPaths(generation, actual.Attribute(), paths, seen) + case *expr.Object: + for _, named := range *actual { + collectGRPCAttributeImportPaths(generation, named.Attribute, paths, seen) + } + case *expr.Array: + collectGRPCAttributeImportPaths(generation, actual.ElemType, paths, seen) + case *expr.Map: + collectGRPCAttributeImportPaths(generation, actual.KeyType, paths, seen) + collectGRPCAttributeImportPaths(generation, actual.ElemType, paths, seen) + case *expr.Union: + for _, named := range actual.Values { + collectGRPCAttributeImportPaths(generation, named.Attribute, paths, seen) + } + } +} diff --git a/grpc/codegen/service_plan_imports_test.go b/grpc/codegen/service_plan_imports_test.go new file mode 100644 index 0000000000..d841bd9eaf --- /dev/null +++ b/grpc/codegen/service_plan_imports_test.go @@ -0,0 +1,69 @@ +// This file checks that copied gRPC values keep every package needed by their +// fields, even when the copies came from the same Goa type. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestCollectGRPCProtobufImportsVisitsEachCopiedType checks that one copied +// type cannot hide an external protobuf field used by another copy. +func TestCollectGRPCProtobufImportsVisitsEachCopiedType(t *testing.T) { + first, second := grpcImportCopies() + protobufField := expr.AsObject(second).Attribute("value") + protobufField.Meta["struct:field:proto"] = []string{ + "google.protobuf.Timestamp", + "google/protobuf/timestamp.proto", + "Timestamp", + "google.golang.org/protobuf/types/known/timestamppb", + } + + for _, roots := range [][2]expr.UserType{{first, second}, {second, first}} { + plan := &protobufServicePlan{messages: []*protobufEndpointMessages{{ + request: &expr.AttributeExpr{Type: roots[0]}, + response: &expr.AttributeExpr{Type: roots[1]}, + }}} + protoImports, goImports := collectGRPCProtobufImports(plan) + + require.Contains(t, protoImports, "google/protobuf/timestamp.proto") + require.Contains(t, goImports, codegen.NewImport( + "timestamppb", + "google.golang.org/protobuf/types/known/timestamppb", + )) + } +} + +// TestGRPCServiceImportPathsVisitsEachCopiedType checks that one copied type +// cannot hide a Go field package used by another copy. +func TestGRPCServiceImportPathsVisitsEachCopiedType(t *testing.T) { + first, second := grpcImportCopies() + goField := expr.AsObject(second).Attribute("value") + goField.Meta["struct:field:type"] = []string{"time.Time", "time"} + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + + for _, roots := range [][2]expr.UserType{{first, second}, {second, first}} { + service := &expr.GRPCServiceExpr{ + ServiceExpr: &expr.ServiceExpr{Name: "Imports"}, + GRPCEndpoints: []*expr.GRPCEndpointExpr{{MethodExpr: &expr.MethodExpr{ + Payload: &expr.AttributeExpr{Type: roots[0]}, + Result: &expr.AttributeExpr{Type: roots[1]}, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.Empty}, + }}}, + } + + require.Contains(t, grpcServiceImportPaths(generation, service), "time") + } +} + +// grpcImportCopies returns two independent copies of one Goa type. +func grpcImportCopies() (expr.UserType, expr.UserType) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + return expr.Dup(original).(expr.UserType), expr.Dup(original).(expr.UserType) +} diff --git a/grpc/codegen/streaming_errors_test.go b/grpc/codegen/streaming_errors_test.go index 6f4189ac06..b42f48e1c1 100644 --- a/grpc/codegen/streaming_errors_test.go +++ b/grpc/codegen/streaming_errors_test.go @@ -16,12 +16,12 @@ func TestStreamingWithErrors(t *testing.T) { cases := []struct { name string dsl func() - testFunc func(t *testing.T, code string) + testFunc func(t *testing.T, code string, services *ServicesData) }{ { name: "server streaming with custom errors", dsl: testdata.ServerStreamingWithCustomErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, services *ServicesData) { // Verify error decoding is present assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from stream") @@ -36,17 +36,20 @@ func TestStreamingWithErrors(t *testing.T) { assert.Contains(t, code, "case *goapb.ErrorResponse:", "should handle generic goa errors") - // Verify proper error construction - assert.Contains(t, code, "NewServerStreamCustomErrorError(message", - "should construct custom error") - assert.Contains(t, code, "NewServerStreamValidationErrorError(message", - "should construct validation error") + // Each custom error uses the constructor chosen for the client package. + endpoint := services.Get("StreamingErrorService").Endpoint("ServerStream") + for _, errorData := range endpoint.Errors { + if errorData.Response.ClientConvert != nil { + name := errorData.Response.ClientConvert.Init.Declaration.Name() + assert.Contains(t, code, name+"(message", "should construct "+errorData.Name) + } + } }, }, { name: "bidirectional streaming with errors", dsl: testdata.BidirectionalStreamingRPCWithErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, _ *ServicesData) { // Bidirectional streaming with simple errors should still decode assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from bidirectional stream") @@ -60,7 +63,7 @@ func TestStreamingWithErrors(t *testing.T) { t.Run(c.name, func(t *testing.T) { root := RunGRPCDSL(t, c.dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles(services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Get recv method implementations @@ -75,7 +78,7 @@ func TestStreamingWithErrors(t *testing.T) { code := codeBuilder.String() // Run test-specific assertions - c.testFunc(t, code) + c.testFunc(t, code, services) }) } } @@ -94,7 +97,7 @@ func TestStreamingErrorsWithValidation(t *testing.T) { require.Greater(t, len(method.Errors), 0, "method should have errors defined") // Generate client code - clientfs := ClientFiles(services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Check recv implementations @@ -148,7 +151,7 @@ func TestStreamingErrorComparison(t *testing.T) { root := RunGRPCDSL(t, dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles(services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0, "should have client files") // Find unary and streaming code in different sections diff --git a/grpc/codegen/streaming_test.go b/grpc/codegen/streaming_test.go index 32cb22741f..2fdf7cd0bf 100644 --- a/grpc/codegen/streaming_test.go +++ b/grpc/codegen/streaming_test.go @@ -107,11 +107,11 @@ func TestStreaming(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - serverfs := ServerFiles(services) + serverfs := serverFiles(services) if len(serverfs) < 2 { t.Fatalf("got %d server files, expected 2", len(serverfs)) } - clientfs := ClientFiles(services) + clientfs := clientFiles(services) if len(clientfs) < 2 { t.Fatalf("got %d client files, expected 2", len(clientfs)) } @@ -154,11 +154,11 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { root := RunGRPCDSL(t, testdata.ClientStreamingRPCWithUnionPayloadDSL) services := CreateGRPCServices(root) - clientfs := ClientFiles(services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) - serverfs := ServerFiles(services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - protofs := ProtoFiles(services) + protofs := protoFiles(services) require.Len(t, protofs, 1) requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) @@ -185,16 +185,16 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { assert.Contains(t, proto, "MethodClientStreamingRPCWithUnionPayloadStreamItem stream_item") fpath := codegen.CreateTempFile(t, proto) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, fpath)) } func TestStreamingPayloadLegacyCompat(t *testing.T) { root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) services := CreateGRPCServices(root) - serverfs := ServerFiles(services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - clientfs := ClientFiles(services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) // The server stream tracks the protocol spoken by the client. @@ -216,14 +216,16 @@ func TestStreamingPayloadLegacyCompat(t *testing.T) { requestDecoder := codegen.SectionsCode(t, serverfs[1].Section("request-decoder")) assert.Contains(t, requestDecoder, "LegacyRequest(ctx, md)") assert.Contains(t, requestDecoder, `md.Get("a")`) - assert.Contains(t, requestDecoder, "PayloadFromMetadata(") + service := services.Get("ServiceBidirectionalStreamingRPCWithPayloadLegacyCompat") + legacyConstructor := service.Endpoints[0].Request.LegacyDecode.ServerConvert.Init.Declaration.Name() + assert.Contains(t, requestDecoder, legacyConstructor+"(") // Generated clients declare the envelope protocol in request metadata. requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) assert.Contains(t, requestEncoder, "goagrpc.StreamProtocolMetadataKey") // The wire contract for envelope clients is unchanged. - protofs := ProtoFiles(services) + protofs := protoFiles(services) require.Len(t, protofs, 1) proto := sectionCode(t, protofs[0].SectionTemplates[1:]...) assert.Contains(t, proto, "oneof body") diff --git a/grpc/codegen/symbols.go b/grpc/codegen/symbols.go new file mode 100644 index 0000000000..c60ff813ad --- /dev/null +++ b/grpc/codegen/symbols.go @@ -0,0 +1,884 @@ +// This file chooses every Go name written into generated gRPC client and server +// packages. A definition and every call to it share one stored name. +package codegen + +import ( + "cmp" + "path" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // grpcSymbols contains the names written for one gRPC service. + grpcSymbols struct { + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverStruct *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + endpoints map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols + } + + // grpcEndpointSymbols contains the names written for one gRPC endpoint. + grpcEndpointSymbols struct { + clientStream *codegen.NameDeclaration + clientBuild *codegen.NameDeclaration + clientEncode *codegen.NameDeclaration + clientDecode *codegen.NameDeclaration + serverStream *codegen.NameDeclaration + serverHandler *codegen.NameDeclaration + serverDecode *codegen.NameDeclaration + serverEncode *codegen.NameDeclaration + legacyDecode *codegen.NameDeclaration + cliPayload *grpcConversion + clientInits map[grpcInitKey]*grpcConversion + serverInits map[grpcInitKey]*grpcConversion + } + + // grpcConversion contains one top-level conversion function and the extra + // conversion functions it calls for nested values. + grpcConversion struct { + declaration *codegen.NameDeclaration + transform *codegen.TransformPlan + pkg *codegen.GeneratedPackage + order grpcSymbolOrder + preferredName string + fullName string + serviceName string + messageName string + releasedNames []string + releasedResponseNames []string + side grpcPackageSide + bound bool + } + + // grpcTransform contains one private function name requested by a retained + // conversion plan. + grpcTransform struct { + plan *codegen.TransformPlan + helper codegen.TransformHelper + pkg *codegen.GeneratedPackage + order grpcSymbolOrder + preferredName string + fullName string + } + + // grpcConversionKey contains the package and types that decide one + // conversion function. endpoint is set when metadata changes its arguments. + grpcConversionKey struct { + pkg *codegen.GeneratedPackage + message *protobufMessageRecord + service expr.UserType + endpoint *expr.GRPCEndpointExpr + view string + proto bool + } + + // grpcSymbolID records which package, service, method, error, and field + // produced one Go name. These values decide collision order but do not appear + // in the name. + grpcSymbolID struct { + side grpcPackageSide + role grpcSymbolRole + api string + service string + method string + subject string + view string + path string + source string + target string + operation int + occurrence int + } + + // grpcSymbolOrder decides which item keeps an unsuffixed Go name when several + // items request the same name. + grpcSymbolOrder grpcSymbolID + + // grpcPackageSide identifies the generated package that contains a name. + grpcPackageSide uint8 + + // grpcSymbolRole identifies what a generated name defines. + grpcSymbolRole uint8 + + // grpcInitRole identifies one conversion constructor used by an endpoint. + grpcInitRole uint8 + + // grpcInitKey says which endpoint value uses a conversion. + grpcInitKey struct { + role grpcInitRole + subject string + view string + } +) + +const ( + grpcClientPackage grpcPackageSide = iota + 1 + grpcServerPackage +) + +const ( + grpcClientStructRole grpcSymbolRole = iota + 1 + grpcClientInitRole + grpcServerStructRole + grpcServerInitRole + grpcClientStreamRole + grpcClientBuildRole + grpcClientEncodeRole + grpcClientDecodeRole + grpcServerStreamRole + grpcServerHandlerRole + grpcServerDecodeRole + grpcServerEncodeRole + grpcLegacyDecodeRole + grpcConversionInitRole + grpcValidationRole + grpcTransformHelperRole +) + +const ( + grpcRequestInit grpcInitRole = iota + 1 + grpcResponseInit + grpcStreamingRequestInit + grpcStreamingResponseInit + grpcLegacyRequestInit + grpcErrorInit +) + +// collectGRPCSymbols requests the client and server names that can be chosen +// directly from the service and endpoint designs. +func collectGRPCSymbols(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, pathName string) (*grpcSymbols, error) { + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + if err != nil { + return nil, err + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, id grpcSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, visibility, grpcSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := grpcSymbolID{api: input.Root.API.Name, service: service.Name()} + symbols := &grpcSymbols{endpoints: make(map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols)} + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.client(grpcClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.client(grpcClientInitRole)); err != nil { + return nil, err + } + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.server(grpcServerStructRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.server(grpcServerInitRole)); err != nil { + return nil, err + } + for _, endpoint := range service.GRPCEndpoints { + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.Name()) + endpointSymbols := &grpcEndpointSymbols{ + clientInits: make(map[grpcInitKey]*grpcConversion), + serverInits: make(map[grpcInitKey]*grpcConversion), + } + endpointSymbols.clientBuild, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Func", codegen.ExportedName, id.client(grpcClientBuildRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.clientEncode, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.client(grpcClientEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverDecode, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.server(grpcServerDecodeRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty || endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientDecode, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.client(grpcClientDecodeRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.serverEncode, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.server(grpcServerEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverHandler, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.server(grpcServerHandlerRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.client(grpcClientStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.server(grpcServerStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.LegacyStreamCompat() { + preferred := "decode" + names.Method + "LegacyRequest" + endpointSymbols.legacyDecode, err = declare(serverPackage, codegen.NameFunction, preferred, codegen.UnexportedName, id.server(grpcLegacyDecodeRole)) + if err != nil { + return nil, err + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// planGRPCTransforms records each conversion and requests the names of any +// nested conversion functions it will call. It does not read those names yet. +func planGRPCTransforms( + generation *codegen.Generation, + input PlanInput, + service *expr.GRPCServiceExpr, + protobuf *protobufServicePlan, + symbols *grpcSymbols, + conversions map[grpcConversionKey]*grpcConversion, + helpers *[]*grpcTransform, + pathName string, +) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + _, viewed := result.Type.(*expr.ResultTypeExpr) + if viewed { + projected, err := input.Service.ProjectedResult(endpoint.MethodExpr) + if err != nil { + return err + } + result = projected + } + conversionFor := func(side grpcPackageSide, source, target *expr.AttributeExpr, proto, endpointSpecific bool, view string) (*grpcConversion, error) { + pkg := clientPackage + if side == grpcServerPackage { + pkg = serverPackage + } + protobufAttribute := source + serviceAttribute := target + if proto { + protobufAttribute = target + serviceAttribute = source + } + message := protobuf.catalog.messageRecord(protobufAttribute) + var serviceType expr.UserType + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + serviceType = userType.Origin() + } + conversionKey := grpcConversionKey{ + pkg: pkg, + message: message, + service: serviceType, + view: view, + proto: proto, + } + if endpointSpecific { + conversionKey.endpoint = endpoint + } + conversion := conversions[conversionKey] + preferred, fullName, serviceName, messageName := grpcConversionNames(serviceAttribute, message, proto) + viewKey := grpcInitKey{view: view} + preferred = grpcViewedConversionName(endpoint.MethodExpr, viewKey, preferred) + fullName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, fullName) + id := grpcSymbolID{ + side: side, + role: grpcConversionInitRole, + api: input.Root.API.Name, + service: service.Name(), + subject: serviceName, + view: conversionKey.view, + path: messageName, + operation: grpcConversionDirection(proto), + } + if endpointSpecific { + id.method = endpoint.Name() + } + if conversion == nil { + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return nil, err + } + for _, helper := range transform.Helpers() { + helperID := id + helperID.role = grpcTransformHelperRole + helperID.source = grpcTransformTypeName(helper.Source) + helperID.target = grpcTransformTypeName(helper.Target) + helperID.occurrence = helper.Occurrence + methodName := "" + if endpointSpecific { + methodName = endpoint.Name() + } + preferredName, fullName := grpcTransformHelperNames(helper, proto, serviceName, messageName, methodName) + preferredName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, preferredName) + fullName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, fullName) + *helpers = append(*helpers, &grpcTransform{ + plan: transform, + helper: helper, + pkg: pkg, + order: grpcSymbolOrder(helperID), + preferredName: preferredName, + fullName: fullName, + }) + } + conversion = &grpcConversion{ + transform: transform, + pkg: pkg, + order: grpcSymbolOrder(id), + preferredName: preferred, + fullName: fullName, + serviceName: serviceName, + messageName: messageName, + side: side, + } + conversions[conversionKey] = conversion + } + return conversion, nil + } + plan := func(side grpcPackageSide, key grpcInitKey, source, target *expr.AttributeExpr, proto, endpointSpecific bool) error { + conversion, err := conversionFor(side, source, target, proto, endpointSpecific, key.view) + if err != nil { + return err + } + releasedName := releasedGRPCConversionName(endpoint, key, source, target, proto, conversion) + conversion.releasedNames = append(conversion.releasedNames, releasedName) + if key.role == grpcResponseInit && !slices.Contains(conversion.releasedResponseNames, releasedName) { + conversion.releasedResponseNames = append(conversion.releasedResponseNames, releasedName) + } + inits := endpointSymbols.clientInits + if side == grpcServerPackage { + inits = endpointSymbols.serverInits + } + inits[key] = conversion + return nil + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := plan(grpcServerPackage, grpcInitKey{role: grpcRequestInit}, messages.request, endpoint.MethodExpr.Payload, false, !endpoint.Metadata.IsEmpty()); err != nil { + return err + } + cliConversion, err := conversionFor(grpcClientPackage, messages.request, endpoint.MethodExpr.Payload, false, false, "") + if err != nil { + return err + } + endpointSymbols.cliPayload = cliConversion + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := plan(grpcClientPackage, grpcInitKey{role: grpcRequestInit}, endpoint.MethodExpr.Payload, messages.request, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit, view: view}, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit}, result, messages.response, true, false); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + responseMetadata := !endpoint.Response.Headers.IsEmpty() || !endpoint.Response.Trailers.IsEmpty() + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit, view: view}, messages.response, viewResult, false, responseMetadata); err != nil { + return err + } + } + } else if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit}, messages.response, result, false, responseMetadata); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := plan(grpcServerPackage, key, messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true, false); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcServerPackage, key, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcServerPackage, key, result, messages.response, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcClientPackage, key, messages.response, viewResult, false, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcClientPackage, key, messages.response, result, false, false); err != nil { + return err + } + } + } + if endpoint.LegacyStreamCompat() && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := plan(grpcServerPackage, key, &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false, true); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := plan(grpcServerPackage, key, grpcError.AttributeExpr, message, true, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, message, grpcError.AttributeExpr, false, false); err != nil { + return err + } + } + } + return nil +} + +// grpcResultViews returns the views the server may send for method. A view set +// in the design is the only possible value. Otherwise callers may select any +// view declared by the result type. +func grpcResultViews(method *expr.MethodExpr) []string { + if method.Result.Meta != nil { + if view, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + return []string{view} + } + } + resultType := method.Result.Type.(*expr.ResultTypeExpr) + views := make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + return views +} + +// grpcResultForView keeps the generated projected Go type but limits the +// conversion plan to fields included in view. +func grpcResultForView(result *expr.AttributeExpr, view string) (*expr.AttributeExpr, error) { + resultType := result.Type.(*expr.ResultTypeExpr) + selected, err := expr.Project(resultType, view) + if err != nil { + return nil, err + } + selectedResult := expr.DupAtt(result) + selectedResult.Type = selected + return grpcViewAttribute(result, selectedResult, make(map[expr.UserType]expr.UserType)), nil +} + +// grpcViewAttribute copies only the selected fields while reusing the Go types +// already generated for the complete result. +func grpcViewAttribute(full, selected *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) *expr.AttributeExpr { + filtered := expr.DupAtt(selected) + switch selectedType := selected.Type.(type) { + case expr.UserType: + if existing, ok := seen[selectedType]; ok { + filtered.Type = existing + return filtered + } + fullType := full.Type.(expr.UserType) + copy := fullType.Dup(expr.DupAtt(selectedType.Attribute())) + seen[selectedType] = copy + copy.SetAttribute(grpcViewAttribute(fullType.Attribute(), selectedType.Attribute(), seen)) + filtered.Type = copy + case *expr.Array: + fullType := full.Type.(*expr.Array) + filtered.Type = &expr.Array{ + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + NonNullableElems: selectedType.NonNullableElems, + } + case *expr.Map: + fullType := full.Type.(*expr.Map) + filtered.Type = &expr.Map{ + KeyType: grpcViewAttribute(fullType.KeyType, selectedType.KeyType, seen), + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + } + case *expr.Object: + fullType := full.Type.(*expr.Object) + object := make(expr.Object, 0, len(*selectedType)) + for _, field := range *selectedType { + object = append(object, &expr.NamedAttributeExpr{ + Name: field.Name, + Attribute: grpcViewAttribute(fullType.Attribute(field.Name), field.Attribute, seen), + }) + } + filtered.Type = &object + case *expr.Union: + fullType := full.Type.(*expr.Union) + union := &expr.Union{ + TypeName: selectedType.TypeName, + TypeKey: selectedType.TypeKey, + ValueKey: selectedType.ValueKey, + Values: make([]*expr.NamedAttributeExpr, 0, len(selectedType.Values)), + } + for index, branch := range selectedType.Values { + union.Values = append(union.Values, &expr.NamedAttributeExpr{ + Name: branch.Name, + Attribute: grpcViewAttribute(fullType.Values[index].Attribute, branch.Attribute, seen), + }) + } + filtered.Type = union + } + return filtered +} + +// declareGRPCTransforms keeps a released response name when one method owns +// it. Conversions shared by several methods use names based on their types. +func declareGRPCTransforms(conversions map[grpcConversionKey]*grpcConversion, helpers []*grpcTransform) error { + type nameKey struct { + pkg *codegen.GeneratedPackage + name string + } + counts := make(map[nameKey]int) + for _, conversion := range conversions { + if len(conversion.releasedNames) > 1 { + counts[nameKey{pkg: conversion.pkg, name: conversion.preferredName}]++ + } + } + for _, conversion := range conversions { + if len(conversion.releasedNames) == 0 { + continue + } + name := conversion.releasedNames[0] + useResponseName := len(conversion.releasedResponseNames) == 1 + useTypeName := !useResponseName && len(conversion.releasedNames) > 1 + if useResponseName { + name = conversion.releasedResponseNames[0] + } else if useTypeName { + name = conversion.preferredName + } + if useTypeName && counts[nameKey{pkg: conversion.pkg, name: name}] > 1 { + name = conversion.fullName + } + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.ExportedName, conversion.order) + if err := conversion.pkg.DeclareName(declaration); err != nil { + return err + } + conversion.declaration = declaration + } + helpersByName := make(map[nameKey]int) + for _, helper := range helpers { + helpersByName[nameKey{pkg: helper.pkg, name: helper.preferredName}]++ + } + for _, helper := range helpers { + name := helper.preferredName + if helpersByName[nameKey{pkg: helper.pkg, name: name}] > 1 { + name = helper.fullName + } + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.UnexportedName, helper.order) + if err := helper.pkg.DeclareName(declaration); err != nil { + return err + } + if err := helper.plan.BindHelperDeclaration(helper.helper.ID, declaration); err != nil { + return err + } + } + return nil +} + +// releasedGRPCConversionName returns the constructor name generated before +// conversions shared by several methods were combined. +func releasedGRPCConversionName(endpoint *expr.GRPCEndpointExpr, key grpcInitKey, source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + method := codegen.Goify(endpoint.Name(), true) + switch key.role { + case grpcRequestInit: + if !proto { + return "New" + method + "Payload" + } + return "NewProto" + conversion.messageName + case grpcResponseInit: + if !proto { + return grpcViewedConversionName(endpoint.MethodExpr, key, "New"+method+"Result") + } + name := conversion.messageName + bodyIsStruct := expr.IsUnion(target.Type) + if object := expr.AsObject(target.Type); object != nil { + bodyIsStruct = len(*object) > 0 + } + if !bodyIsStruct && key.view == "" { + name = conversion.serviceName + } + return grpcViewedConversionName(endpoint.MethodExpr, key, "NewProto"+name) + case grpcStreamingRequestInit, grpcStreamingResponseInit: + name := releasedGRPCStreamConversionName(source, target, proto, conversion) + return grpcViewedConversionName(endpoint.MethodExpr, key, name) + case grpcLegacyRequestInit: + return "New" + method + "PayloadFromMetadata" + case grpcErrorInit: + return "New" + method + codegen.Goify(key.subject, true) + "Error" + default: + panic("unknown gRPC conversion role") + } +} + +// grpcViewedConversionName keeps the existing constructor name for the only +// view selected by a design. When callers choose a view, additional +// constructors include the view name. +func grpcViewedConversionName(method *expr.MethodExpr, key grpcInitKey, name string) string { + if key.view == "" || key.view == expr.DefaultView { + return name + } + if method.Result.Meta != nil { + if _, fixed := method.Result.Meta.Last(expr.ViewMetaKey); fixed { + return name + } + } + return name + codegen.Goify(key.view, true) +} + +// releasedGRPCStreamConversionName returns the name used by released Goa +// versions for a conversion of one streamed value. +func releasedGRPCStreamConversionName(source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + name := "New" + if proto { + name += "Proto" + } + if _, ok := source.Type.(expr.UserType); ok { + if proto { + name += conversion.serviceName + } else { + name += conversion.messageName + } + } + targetName := conversion.serviceName + if proto { + targetName = conversion.messageName + } + if !expr.IsObject(target.Type) && !expr.IsUnion(target.Type) { + targetName = conversion.messageName + if proto { + targetName = conversion.serviceName + } + } + return name + targetName +} + +// grpcConversionNames returns the short and complete constructor names and +// the type names used to order colliding requests. +func grpcConversionNames(serviceAttribute *expr.AttributeExpr, message *protobufMessageRecord, proto bool) (string, string, string, string) { + var messageName string + if message != nil { + messageName = message.plannedName + } + serviceName := messageName + if userType, ok := serviceAttribute.Type.(expr.UserType); ok && serviceAttribute.Type != expr.Empty { + serviceName = codegen.Goify(userType.Name(), true) + } + if serviceName == "" { + serviceName = codegen.Goify(serviceAttribute.Type.Name(), true) + } + name := "New" + serviceName + fullName := "New" + serviceName + "FromProto" + messageName + if message == nil { + fullName = "New" + serviceName + "FromMetadata" + } + if proto { + name = "NewProto" + serviceName + fullName = "NewProto" + messageName + "From" + serviceName + } + return name, fullName, serviceName, messageName +} + +// grpcTransformHelperNames returns the short nested-type name and the complete +// name that also identifies the outer conversion. +func grpcTransformHelperNames(helper codegen.TransformHelper, proto bool, serviceName, messageName, methodName string) (string, string) { + source := grpcTransformTypeName(helper.Source) + target := grpcTransformTypeName(helper.Target) + if proto { + return codegen.Goify("transform"+source+"ToProto"+target, false), + codegen.Goify("transform"+methodName+serviceName+source+"ToProto"+messageName+target, false) + } + return codegen.Goify("transformProto"+source+"To"+target, false), + codegen.Goify("transform"+methodName+"Proto"+messageName+source+"To"+serviceName+target, false) +} + +// grpcTransformTypeName returns the declared type name used in a private +// conversion function signature. +func grpcTransformTypeName(attribute *expr.AttributeExpr) string { + if userType, ok := attribute.Type.(expr.UserType); ok { + return codegen.Goify(userType.Name(), true) + } + return codegen.Goify(attribute.Type.Name(), true) +} + +// grpcConversionDirection returns the fixed number used to order conversions +// to and from protobuf messages. +func grpcConversionDirection(proto bool) int { + if proto { + return 1 + } + return 2 +} + +// planGRPCValidations records each validation function in the client or server +// package that writes it. +func planGRPCValidations(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, protobuf *protobufServicePlan, pathName string) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + source := protobufValidationSource{ + api: input.Root.API.Name, + service: service.Name(), + method: endpoint.Name(), + } + if protobuf.catalog.messageRecord(messages.request) != nil { + request := source + request.role = protobufRequestValidation + protobuf.catalog.collectValidation(messages.request, validateServer, request, "message", "message") + } + if protobuf.catalog.messageRecord(messages.response) != nil { + response := source + response.role = protobufResponseValidation + protobuf.catalog.collectValidation(messages.response, validateClient, response, "message", "message") + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + errorSource := source + errorSource.error = grpcError.Name + errorSource.role = protobufErrorValidation + protobuf.catalog.collectValidation(message, validateClient, errorSource, "errmsg", "errmsg") + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + stream := source + stream.role = protobufStreamingRequestValidation + protobuf.catalog.collectValidation(messages.streamingRequest, validateServer, stream, "stream", "stream") + } + } + for _, validator := range protobuf.catalog.validators { + pkg := clientPackage + side := grpcClientPackage + if validator.side == validateServer { + pkg = serverPackage + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: validator.source.api, + service: validator.source.service, + method: validator.source.method, + subject: validator.source.error, + path: validator.source.path, + operation: int(validator.source.role), + } + validator.declaration = codegen.NewPreferredName( + codegen.NameFunction, + "Validate"+validator.message.plannedName, + codegen.ExportedName, + grpcSymbolOrder(id), + ) + if err := pkg.DeclareName(validator.declaration); err != nil { + return err + } + } + return nil +} + +// newGRPCTransformPlan copies the protobuf input or output type, then records +// every nested conversion function that the generated code will call. +func newGRPCTransformPlan(source, target *expr.AttributeExpr, proto bool, protobuf *protobufServicePlan) (*codegen.TransformPlan, error) { + prefix := "protobuf" + if proto { + original := target + target = expr.DupAtt(target) + protobuf.bindAttributeCopy(original, target) + removeMeta(target) + prefix = "svc" + } else { + original := source + source = expr.DupAtt(source) + protobuf.bindAttributeCopy(original, source) + removeMeta(source) + } + return codegen.NewTransformPlan(source, target, prefix, protoHooks(proto)) +} + +// ComparePackageName orders generated declarations by package, purpose, API, +// service, method, error, and field. +func (left grpcSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(grpcSymbolOrder) + return cmp.Or( + cmp.Compare(left.side, right.side), + cmp.Compare(left.role, right.role), + strings.Compare(left.api, right.api), + strings.Compare(left.service, right.service), + strings.Compare(left.method, right.method), + strings.Compare(left.subject, right.subject), + strings.Compare(left.view, right.view), + strings.Compare(left.path, right.path), + strings.Compare(left.source, right.source), + strings.Compare(left.target, right.target), + cmp.Compare(left.operation, right.operation), + cmp.Compare(left.occurrence, right.occurrence), + ) +} + +// withMethod returns the declaration details for one method in the same +// service. +func (id grpcSymbolID) withMethod(method string) grpcSymbolID { + id.method = method + return id +} + +// client selects the generated client package and the kind of declaration. +func (id grpcSymbolID) client(role grpcSymbolRole) grpcSymbolID { + id.side = grpcClientPackage + id.role = role + return id +} + +// server selects the generated server package and the kind of declaration. +func (id grpcSymbolID) server(role grpcSymbolRole) grpcSymbolID { + id.side = grpcServerPackage + id.role = role + return id +} diff --git a/grpc/codegen/templates.go b/grpc/codegen/templates.go index ff9574d22f..e17610a963 100644 --- a/grpc/codegen/templates.go +++ b/grpc/codegen/templates.go @@ -63,8 +63,8 @@ const ( // Partial template constants const ( - grpcConvertTypeToStringP = "convert_type_to_string" - grpcConvertStringToTypeP = "convert_string_to_type" + grpcConvertStringToTypeP = "convert_string_to_type" + grpcTypeToStringExpressionP = "type_to_string_expression" ) // Common template constants diff --git a/grpc/codegen/templates/client_endpoint_init.go.tpl b/grpc/codegen/templates/client_endpoint_init.go.tpl index 9d4df36299..06d1425b4f 100644 --- a/grpc/codegen/templates/client_endpoint_init.go.tpl +++ b/grpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,15 +1,15 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) }} -{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ClientInterface | comment }} -func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { +{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .ClientProtobufPkgName .ClientInterface | comment }} +func (c *{{ .ClientStructDeclaration.Name }}) {{ .Method.VarName }}() goa.Endpoint { {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} inv := goagrpc.NewInvoker( - Build{{ .Method.VarName }}Func(c.grpccli, c.opts...), - {{ if .PayloadRef }}Encode{{ .Method.VarName }}Request{{ else }}nil{{ end }}, - {{ if or .ResultRef .ClientStream }}Decode{{ .Method.VarName }}Response{{ else }}nil{{ end }}) + {{ .ClientBuildDeclaration.Name }}(c.grpccli, c.opts...), + {{ if .PayloadRef }}{{ .ClientEncodeDeclaration.Name }}{{ else }}nil{{ end }}, + {{ if or .ResultRef .ClientStream }}{{ .ClientDecodeDeclaration.Name }}{{ else }}nil{{ end }}) res, err := inv.Invoke(ctx, v) if err != nil { {{- if .Errors }} @@ -19,11 +19,11 @@ func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - return nil, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return nil, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: diff --git a/grpc/codegen/templates/client_init.go.tpl b/grpc/codegen/templates/client_init.go.tpl index 0f4ad98adf..bc7ff086cf 100644 --- a/grpc/codegen/templates/client_init.go.tpl +++ b/grpc/codegen/templates/client_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "New%s instantiates gRPC client for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStruct }} { - return &{{ .ClientStruct }}{ +{{ printf "%s instantiates gRPC client for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStructDeclaration.Name }} { + return &{{ .ClientStructDeclaration.Name }}{ grpccli: {{ .ClientInterfaceInit }}(cc), opts: opts, } diff --git a/grpc/codegen/templates/client_struct.go.tpl b/grpc/codegen/templates/client_struct.go.tpl index e167dd4183..e15620871d 100644 --- a/grpc/codegen/templates/client_struct.go.tpl +++ b/grpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the service endpoint gRPC clients." .ClientStruct | comment }} -type {{ .ClientStruct }} struct { - grpccli {{ .PkgName }}.{{ .ClientInterface }} +{{ printf "%s lists the service endpoint gRPC clients." .ClientStructDeclaration.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { + grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }} opts []grpc.CallOption } diff --git a/grpc/codegen/templates/do_grpc_cli.go.tpl b/grpc/codegen/templates/do_grpc_cli.go.tpl index d7928c976a..02997fe62f 100644 --- a/grpc/codegen/templates/do_grpc_cli.go.tpl +++ b/grpc/codegen/templates/do_grpc_cli.go.tpl @@ -1,28 +1,79 @@ -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) - } + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors := {{ $.InterceptorsPkg }}.New{{ .Service.StructName }}ClientInterceptors() {{- end }} {{- end }} - return {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err = {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} conn, {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors, {{- end }} -{{- end }} + {{- end }} ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -{{ if eq .DefaultTransport.Type "grpc" }} -func grpcUsageCommands() []string { - return {{ .CLIPkg }}.{{ .Parser.UsageCommands.Name }}() +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if streamsOutput .Method }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic("parsed gRPC command has no generated result writer") } +{{ if eq .DefaultTransport.Type "grpc" }} func grpcUsageExamples() string { return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } diff --git a/grpc/codegen/templates/grpc_handler_init.go.tpl b/grpc/codegen/templates/grpc_handler_init.go.tpl index 8c962c6298..1f951530ed 100644 --- a/grpc/codegen/templates/grpc_handler_init.go.tpl +++ b/grpc/codegen/templates/grpc_handler_init.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sHandler creates a gRPC handler which serves the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func New{{ .Method.VarName }}Handler(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { +{{ printf "%s creates a gRPC handler which serves the %q service %q endpoint." .ServerHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerHandlerDeclaration.Name }}(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { if h == nil { - h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}Decode{{ .Method.VarName }}Request{{ else }}nil{{ end }}{{ if not .ServerStream }}, Encode{{ .Method.VarName }}Response{{ end }}) + h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}{{ .ServerDecodeDeclaration.Name }}{{ else }}nil{{ end }}{{ if not .ServerStream }}, {{ .ServerEncodeDeclaration.Name }}{{ end }}) } return h } diff --git a/grpc/codegen/templates/grpc_service.go.tpl b/grpc/codegen/templates/grpc_service.go.tpl index 4dbff130b6..b6af05f61d 100644 --- a/grpc/codegen/templates/grpc_service.go.tpl +++ b/grpc/codegen/templates/grpc_service.go.tpl @@ -5,7 +5,7 @@ service {{ .Name }} { {{ if .Method.Description }}{{ .Method.Description | comment }}{{ end }} {{- $serverStream := or (eq .Method.StreamKind 3) (eq .Method.StreamKind 4) }} {{- $clientStream := or (eq .Method.StreamKind 2) (eq .Method.StreamKind 4) }} - rpc {{ .Method.VarName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.Message.VarName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.Message.VarName }}){{ if .Method.Idempotent }} { + rpc {{ .ProtoMethodName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.ProtoMessageName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.ProtoMessageName }}){{ if .Method.Idempotent }} { option idempotency_level = IDEMPOTENT; }{{ else }};{{ end }} {{- end }} diff --git a/grpc/codegen/templates/parse_endpoint.go.tpl b/grpc/codegen/templates/parse_endpoint.go.tpl index a96f273b59..f5776baf0c 100644 --- a/grpc/codegen/templates/parse_endpoint.go.tpl +++ b/grpc/codegen/templates/parse_endpoint.go.tpl @@ -1,35 +1,35 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. func {{ .Declaration.Name }}( - cc *grpc.ClientConn, + {{ .Variables.Connection }} *grpc.ClientConn, {{- range .Commands }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} - opts ...grpc.CallOption, + {{ .Variables.Options }} ...grpc.CallOption, ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(cc, opts...) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Connection }}, {{ $.Variables.Options }}...) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}() + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}() {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName}}.{{ .BuildFunction.Declaration.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName}}.{{ .BuildFunction.Name }}({{ range .ActualPointerVars }}*{{ . }}, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} @@ -38,9 +38,9 @@ func {{ .Declaration.Name }}( {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl b/grpc/codegen/templates/partial/convert_type_to_string.go.tpl deleted file mode 100644 index bb731296da..0000000000 --- a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl +++ /dev/null @@ -1,25 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- end }} diff --git a/grpc/codegen/templates/partial/string_conversion.go.tpl b/grpc/codegen/templates/partial/string_conversion.go.tpl deleted file mode 100644 index bb731296da..0000000000 --- a/grpc/codegen/templates/partial/string_conversion.go.tpl +++ /dev/null @@ -1,25 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- end }} diff --git a/grpc/codegen/templates/partial/type_to_string_expression.go.tpl b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl new file mode 100644 index 0000000000..c25f17139e --- /dev/null +++ b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl @@ -0,0 +1,25 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ .Target }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ .Target }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ .Target }}, 10) +{{- else if eq .Type.Name "uint" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ .Target }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ .Target }}, 'f', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ .Target }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/grpc/codegen/templates/remote_method_builder.go.tpl b/grpc/codegen/templates/remote_method_builder.go.tpl index c96d079b03..2c9b2891f4 100644 --- a/grpc/codegen/templates/remote_method_builder.go.tpl +++ b/grpc/codegen/templates/remote_method_builder.go.tpl @@ -1,25 +1,25 @@ -{{ printf "Build%sFunc builds the remote method to invoke for %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Build{{ .Method.VarName }}Func(grpccli {{ .PkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { +{{ printf "%s builds the remote method to invoke for %q service %q endpoint." .ClientBuildDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientBuildDeclaration.Name }}(grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { for _, opt := range cliopts { opts = append(opts, opt) } {{- if .Request.StreamEnvelope }} - stream, err := grpccli.{{ .ClientMethodName }}(ctx, opts...) + stream, err := grpccli.{{ .GRPCMethodName }}(ctx, opts...) if err != nil { return nil, err } if reqpb != nil { - if err := stream.Send(reqpb.({{ .Request.Message.Ref }})); err != nil { + if err := stream.Send(reqpb.({{ .Request.ClientMessageRef }})); err != nil { return nil, err } } return stream, nil {{- else }} if reqpb != nil { - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) } - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) {{- end }} } } diff --git a/grpc/codegen/templates/request_decoder.go.tpl b/grpc/codegen/templates/request_decoder.go.tpl index 9e3bb95486..6da77e5e1a 100644 --- a/grpc/codegen/templates/request_decoder.go.tpl +++ b/grpc/codegen/templates/request_decoder.go.tpl @@ -1,14 +1,14 @@ -{{ printf "Decode%sRequest decodes requests sent to %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint." .ServerDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerDecodeDeclaration.Name }}(ctx context.Context, v any, md metadata.MD) (any, error) { {{- if .Request.LegacyDecode }} if !goagrpc.UsesStreamEnvelope(ctx) { - return {{ .Request.LegacyDecode.FuncName }}(ctx, md) + return {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx, md) } {{- end }} {{- template "partial_metadata_decode" .Request.Metadata }} {{- if .Request.PayloadMessage }} var ( - message {{ .Request.PayloadMessage.Ref }} + message {{ .Request.ServerPayloadMessageRef }} ok bool ) { @@ -16,37 +16,37 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. if v == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } - var envelope {{ .Request.Message.Ref }} - if envelope, ok = v.({{ .Request.Message.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.Message.Ref }}", v) + var envelope {{ .Request.ServerMessageRef }} + if envelope, ok = v.({{ .Request.ServerMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerMessageRef }}", v) } switch body := envelope.{{ .Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerInitialWrapperRef }}: if body.{{ .Request.StreamEnvelope.InitialFieldName }} == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } message = body.{{ .Request.StreamEnvelope.InitialFieldName }} - case *{{ .Request.StreamEnvelope.StreamItemWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerStreamItemWrapperRef }}: return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") default: return nil, goa.MissingFieldError("initial_payload", "stream") } {{- else }} - if message, ok = v.({{ .Request.PayloadMessage.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.PayloadMessage.Ref }}", v) + if message, ok = v.({{ .Request.ServerPayloadMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerPayloadMessageRef }}", v) } {{- end }} {{- if .Request.ServerConvert.Validation }} - if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Name }}(message); err != nil { + if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} } {{- end }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.ServerConvert }} - payload = {{ .Request.ServerConvert.Init.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.ServerConvert.Init.Declaration.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.Metadata 0).VarName }} {{- end }} @@ -56,13 +56,13 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. } {{- if .Request.LegacyDecode }} -{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncName .ServiceName .Method.Name | comment }} -func {{ .Request.LegacyDecode.FuncName }}(ctx context.Context, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx context.Context, md metadata.MD) (any, error) { {{- template "partial_metadata_decode" .Request.LegacyDecode.Metadata }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.LegacyDecode.ServerConvert }} - payload = {{ .Request.LegacyDecode.ServerConvert.Init.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.LegacyDecode.ServerConvert.Init.Declaration.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.LegacyDecode.Metadata 0).VarName }} {{- end }} diff --git a/grpc/codegen/templates/request_encoder.go.tpl b/grpc/codegen/templates/request_encoder.go.tpl index 3031f8c2a4..32bbfc74f5 100644 --- a/grpc/codegen/templates/request_encoder.go.tpl +++ b/grpc/codegen/templates/request_encoder.go.tpl @@ -1,8 +1,8 @@ -{{ printf "Encode%sRequest encodes requests sent to %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.({{ .PayloadRef }}) +{{ printf "%s encodes requests sent to %s %s endpoint." .ClientEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientEncodeDeclaration.Name }}(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.({{ .ClientPayloadRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .PayloadRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ClientPayloadRef }}", v) } {{- range .Request.Metadata }} {{- if .Pointer }} @@ -15,7 +15,7 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata } {{- else if .Slice }} for _, value := range {{ .WireVarName }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Type.ElemType.Type "valueStr" "value") }} + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type.ElemType.Type "value") }} (*md).Append({{ printf "%q" .Name }}, valueStr) } {{- else }} @@ -24,13 +24,7 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ .WireVarName }}) } else { {{- end }} - (*md).Append({{ printf "%q" .Name }}, - {{- if eq .Type.Name "bytes" }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", - {{- end }} - {{ .WireVarName }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) }}) - {{- end }}) + (*md).Append({{ printf "%q" .Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type .WireVarName) }}) {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} } {{- end }} @@ -44,14 +38,14 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata {{- end }} {{- if .Request.ClientConvert }} {{- if .Request.StreamEnvelope }} - message := {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) - return &{{ .PkgName }}.{{ .Request.Message.VarName }}{ - {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.InitialWrapperRef }}{ + message := {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return &{{ .ClientProtobufPkgName }}.{{ .Request.Message.VarName }}{ + {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.ClientInitialWrapperRef }}{ {{ .Request.StreamEnvelope.InitialFieldName }}: message, }, }, nil {{- else }} - return {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} {{- else }} return nil, nil diff --git a/grpc/codegen/templates/response_decoder.go.tpl b/grpc/codegen/templates/response_decoder.go.tpl index 8bbbe1293e..6241663aee 100644 --- a/grpc/codegen/templates/response_decoder.go.tpl +++ b/grpc/codegen/templates/response_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "Decode%sResponse decodes responses from the %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { +{{ printf "%s decodes responses from the %s %s endpoint." .ClientDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientDecodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { {{- if or .Response.Headers .Response.Trailers }} var ( {{- range .Response.Headers }} @@ -28,7 +28,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m return nil, err } {{- end }} -{{- if .ViewedResultRef }} +{{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) (not .ClientStream) }} var view string { if vals := hdr.Get("goa-view"); len(vals) > 0 { @@ -37,9 +37,9 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- end }} {{- if .ClientStream }} - return &{{ .ClientStream.VarName }}{ + return &{{ .ClientStream.Declaration.Name }}{ stream: v.({{ .ClientStream.Interface }}), - {{- if .ViewedResultRef }} + {{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) (not .ClientStream) }} view: view, {{- end }} }, nil @@ -49,17 +49,27 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Response.ClientConvert.SrcRef }}", v) } {{- if and .Response.ClientConvert.Validation (not .ViewedResultRef) }} - if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - res := {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .Response.ClientConverts) 1 }} + var res {{ .Response.ClientConvert.TgtRef }} + switch view { + {{- range .Response.ClientConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + res = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + res := {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} {{- if .ViewedResultRef }} - vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: view} + vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}view{{ end }}} if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Method.ViewedResult.ViewsPkg }}.Validate{{ .Method.Result }}(vres); err != nil { return nil, err } - return {{ .ServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Declaration.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil + return {{ .ClientServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Declaration.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil {{- else }} return res, nil {{- end }} diff --git a/grpc/codegen/templates/response_encoder.go.tpl b/grpc/codegen/templates/response_encoder.go.tpl index f0e7f0baec..076bf94c60 100644 --- a/grpc/codegen/templates/response_encoder.go.tpl +++ b/grpc/codegen/templates/response_encoder.go.tpl @@ -1,19 +1,35 @@ -{{ printf "Encode%sResponse encodes responses from the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { +{{ printf "%s encodes responses from the %q service %q endpoint." .ServerEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerEncodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { {{- if .ViewedResultRef }} vres, ok := v.({{ .ViewedResultRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ViewedResultRef }}", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) -{{- else if .ResultRef }} - result, ok := v.({{ .ResultRef }}) +{{- else if .ServerResultRef }} + result, ok := v.({{ .ServerResultRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ResultRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ServerResultRef }}", v) } {{- end }} - resp := {{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- if gt (len .Response.ServerConverts) 1 }} + var resp {{ .Response.ServerConvert.TgtRef }} + switch vres.View { + {{- range .Response.ServerConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + resp = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + {{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) }} + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{ {{ range .Response.ServerConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } +{{- else }} +resp := {{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- end }} +{{- if .ViewedResultRef }} + (*hdr).Append("goa-view", {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}vres.View{{ end }}) +{{- end }} {{- range .Response.Headers }} {{ template "metadata_encoder" (metadataEncodeDecodeData . "(*hdr)") }} {{- end }} @@ -32,17 +48,11 @@ func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr * {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ .Metadata.WireVarName }}...) {{- else if .Metadata.Slice }} for _, value := range {{ .Metadata.WireVarName }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Metadata.Type.ElemType.Type "valueStr" "value") }} + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type.ElemType.Type "value") }} {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, valueStr) } {{- else }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, - {{- if eq .Metadata.Type.Name "bytes" }} string( - {{- else if not (eq .Metadata.TypeName "string") }} fmt.Sprintf("%v", - {{- end }} - {{ .Metadata.WireVarName }} - {{- if or (eq .Metadata.Type.Name "bytes") (not (eq .Metadata.TypeName "string")) }}) - {{- end }}) + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type .Metadata.WireVarName) }}) {{- end }} {{- if .Metadata.Pointer }} } diff --git a/grpc/codegen/templates/server_grpc_init.go.tpl b/grpc/codegen/templates/server_grpc_init.go.tpl index 6a5dcaf5f8..f40633514a 100644 --- a/grpc/codegen/templates/server_grpc_init.go.tpl +++ b/grpc/codegen/templates/server_grpc_init.go.tpl @@ -5,15 +5,15 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{ .ServerPkgName }}.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { {{- range .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .ServerPkgName }}.New(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/server_grpc_interface.go.tpl b/grpc/codegen/templates/server_grpc_interface.go.tpl index 77cb305553..76b7ca3ec2 100644 --- a/grpc/codegen/templates/server_grpc_interface.go.tpl +++ b/grpc/codegen/templates/server_grpc_interface.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s implements the %q method in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ServerInterface | comment }} -func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( +{{ printf "%s implements the %q method in %s.%s interface." .GRPCMethodName .GRPCMethodName .ServerProtobufPkgName .ServerInterface | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .GRPCMethodName }}( {{- if not .ServerStream }}ctx context.Context, {{ end }} - {{- if not .Method.StreamingPayload }}message {{ .Request.Message.Ref }}{{ if .ServerStream }}, {{ end }}{{ end }} - {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.Message.Ref }}, error{{ if .Response.Message }}){{ end }}{{ end }} { + {{- if not .Method.StreamingPayload }}message {{ .Request.ServerMessageRef }}{{ if .ServerStream }}, {{ end }}{{ end }} + {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.ServerMessageRef }}, error{{ if .Response.Message }}){{ end }}{{ end }} { {{- if .ServerStream }} ctx := stream.Context() {{- end }} @@ -40,10 +40,10 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( {{if .PayloadRef }}p{{ else }}_{{ end }}, err := s.{{ .Method.VarName }}H.Decode(ctx, {{ if .Method.StreamingPayload }}nil{{ else }}message{{ end }}) {{- end }} {{- template "handle_error" . }} - ep := &{{ .ServicePkgName }}.{{ .Method.VarName }}EndpointInput{ - Stream: &{{ .ServerStream.VarName }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, + ep := &{{ .ServerServicePkgName }}.{{ .Method.EndpointInputDeclaration.Name }}{ + Stream: &{{ .ServerStream.Declaration.Name }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, {{- if .PayloadRef }} - Payload: p.({{ .PayloadRef }}), + Payload: p.({{ .ServerPayloadRef }}), {{- end }} } err = s.{{ .Method.VarName }}H.Handle(ctx, ep) @@ -66,7 +66,7 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( var er {{ .Response.ServerConvert.SrcRef }} errors.As(err, &er) {{- end }} - return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) + return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) {{- end }} } } diff --git a/grpc/codegen/templates/server_grpc_register.go.tpl b/grpc/codegen/templates/server_grpc_register.go.tpl index 5a0d4df0bd..e8e34823f3 100644 --- a/grpc/codegen/templates/server_grpc_register.go.tpl +++ b/grpc/codegen/templates/server_grpc_register.go.tpl @@ -17,14 +17,14 @@ // Register the servers. {{- range .Services }} - {{ .PkgName }}.Register{{ goify .Service.VarName true }}Server(srv, {{ .Service.VarName }}Server) + {{ .ServerProtobufPkgName }}.{{ .RegisterFunction }}(srv, {{ .Service.VarName }}Server) {{- end }} - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc + "/" + m.Name) - } - } + {{- range .Services }} + {{- range .Endpoints }} + log.Printf(ctx, "serving gRPC method %s", {{ printf "%q" .FullMethodName }}) + {{- end }} + {{- end }} // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/templates/server_grpc_start.go.tpl b/grpc/codegen/templates/server_grpc_start.go.tpl index c963aed74b..93c10af375 100644 --- a/grpc/codegen/templates/server_grpc_start.go.tpl +++ b/grpc/codegen/templates/server_grpc_start.go.tpl @@ -1,2 +1,2 @@ {{ comment "handleGRPCServer starts configures and starts a gRPC server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { diff --git a/grpc/codegen/templates/server_init.go.tpl b/grpc/codegen/templates/server_init.go.tpl index 7b01402a56..2a7e0309b8 100644 --- a/grpc/codegen/templates/server_init.go.tpl +++ b/grpc/codegen/templates/server_init.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}(e *{{ .Service.PkgName }}.Endpoints{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStruct }} { - return &{{ .ServerStruct }}{ +{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}(e *{{ .ServerServicePkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStructDeclaration.Name }} { + return &{{ .ServerStructDeclaration.Name }}{ {{- range .Endpoints }} - {{ .Method.VarName }}H: New{{ .Method.VarName }}Handler(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), + {{ .Method.VarName }}H: {{ .ServerHandlerDeclaration.Name }}(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), {{- end }} } } diff --git a/grpc/codegen/templates/server_struct_type.go.tpl b/grpc/codegen/templates/server_struct_type.go.tpl index 3aa54d829e..bd377e96d4 100644 --- a/grpc/codegen/templates/server_struct_type.go.tpl +++ b/grpc/codegen/templates/server_struct_type.go.tpl @@ -1,7 +1,7 @@ -{{ printf "%s implements the %s.%s interface." .ServerStruct .PkgName .ServerInterface | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s implements the %s.%s interface." .ServerStructDeclaration.Name .ServerProtobufPkgName .ServerInterface | comment }} +type {{ .ServerStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ .Method.VarName }}H {{ if .ServerStream }}goagrpc.StreamHandler{{ else }}goagrpc.UnaryHandler{{ end }} {{- end }} - {{ .PkgName }}.Unimplemented{{ .ServerInterface }} + {{ .ServerProtobufPkgName }}.{{ .UnimplementedServer }} } diff --git a/grpc/codegen/templates/stream_close.go.tpl b/grpc/codegen/templates/stream_close.go.tpl index bb35413c28..6f7323f3f1 100644 --- a/grpc/codegen/templates/stream_close.go.tpl +++ b/grpc/codegen/templates/stream_close.go.tpl @@ -1,5 +1,5 @@ -func (s *{{ .VarName }}) Close() error { +func (s *{{ .Declaration.Name }}) Close() error { {{- if eq .Type "client" }} {{- if .Endpoint.Method.Result }} {{ comment "Close the send direction of the stream" }} diff --git a/grpc/codegen/templates/stream_recv.go.tpl b/grpc/codegen/templates/stream_recv.go.tpl index 5a0edb6241..c4ed4502c0 100644 --- a/grpc/codegen/templates/stream_recv.go.tpl +++ b/grpc/codegen/templates/stream_recv.go.tpl @@ -1,5 +1,5 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { var res {{ .RecvRef }} {{- if and (eq .Type "server") .Endpoint.Request.LegacyDecode }} if s.legacy { @@ -8,11 +8,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err } {{- if .RecvConvert.Validation }} - if err := {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err := {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil } {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} @@ -28,11 +28,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return res, err } {{- end }} - return res, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return res, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: @@ -44,11 +44,25 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err {{- end }} } + {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") (not .Endpoint.Method.ViewedResult.ViewName) }} + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} - body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}) + body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.ServerStreamItemWrapperRef }}) if !ok { switch message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Endpoint.Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Endpoint.Request.StreamEnvelope.ServerInitialWrapperRef }}: return res, goa.InvalidFieldTypeError("body", "initial_payload", "stream_item") default: return res, goa.MissingFieldError("stream_item", "stream") @@ -60,23 +74,33 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { v := body.{{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }} {{- end }} {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") }} - proj := {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .RecvConverts) 1 }} + var proj {{ .RecvConvert.TgtRef }} + switch s.view { + {{- range .RecvConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + proj = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + proj := {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} vres := {{ if not .Endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ .Endpoint.Method.ViewedResult.FullName }}{Projected: proj, View: {{ if .Endpoint.Method.ViewedResult.ViewName }}"{{ .Endpoint.Method.ViewedResult.ViewName }}"{{ else }}s.view{{ end }} } if err := {{ .Endpoint.Method.ViewedResult.ViewsPkg }}.Validate{{ .Endpoint.Method.Result }}(vres); err != nil { return nil, err } - return {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(vres), nil + return {{ .Endpoint.ClientServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(vres), nil {{- else }} {{- if .RecvConvert.Validation }} - if err = {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err = {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { return s.{{ .RecvName }}() } diff --git a/grpc/codegen/templates/stream_send.go.tpl b/grpc/codegen/templates/stream_send.go.tpl index 6fac1dcca4..4347ddaeea 100644 --- a/grpc/codegen/templates/stream_send.go.tpl +++ b/grpc/codegen/templates/stream_send.go.tpl @@ -1,16 +1,47 @@ {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendName }}(res {{ .SendRef }}) error { {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") }} + {{- if not .Endpoint.Method.ViewedResult.ViewName }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + {{- end }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- else }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, s.view) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} {{- end }} - v := {{ .SendConvert.Init.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- if gt (len .SendConverts) 1 }} + var v {{ .SendConvert.TgtRef }} + switch view { + {{- range .SendConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + v = {{ .Convert.Init.Declaration.Name }}(vres.Projected) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .SendConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } + {{- else }} + v := {{ .SendConvert.Init.Declaration.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + {{- end }} {{- if and (eq .Type "client") .Endpoint.Request.StreamEnvelope }} - return s.stream.{{ .SendName }}(&{{ .Endpoint.PkgName }}.{{ .Endpoint.Request.Message.VarName }}{ - {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}{ + return s.stream.{{ .SendName }}(&{{ .Endpoint.ClientProtobufPkgName }}.{{ .Endpoint.Request.Message.VarName }}{ + {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.ClientStreamItemWrapperRef }}{ {{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }}: v, }, }) @@ -20,6 +51,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { return s.{{ .SendName }}(res) } diff --git a/grpc/codegen/templates/stream_set_view.go.tpl b/grpc/codegen/templates/stream_set_view.go.tpl index 3ac250a764..97cef29e6c 100644 --- a/grpc/codegen/templates/stream_set_view.go.tpl +++ b/grpc/codegen/templates/stream_set_view.go.tpl @@ -1,4 +1,7 @@ {{ printf "SetView sets the view." | comment }} -func (s *{{ .VarName }}) SetView(view string) { +func (s *{{ .Declaration.Name }}) SetView(view string) { s.view = view + {{- if eq .Type "client" }} + s.viewSet = true + {{- end }} } diff --git a/grpc/codegen/templates/stream_struct_type.go.tpl b/grpc/codegen/templates/stream_struct_type.go.tpl index a9a72f9270..13e75ed252 100644 --- a/grpc/codegen/templates/stream_struct_type.go.tpl +++ b/grpc/codegen/templates/stream_struct_type.go.tpl @@ -1,12 +1,18 @@ -{{ printf "%s implements the %s interface." .VarName .ServiceInterface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .Declaration.Name .ServiceInterface | comment }} +type {{ .Declaration.Name }} struct { stream {{ .Interface }} {{- if and (eq .Type "server") .Endpoint.Request.LegacyDecode }} // legacy indicates that the client speaks the legacy stream protocol // which sends raw stream item frames instead of typed envelopes. legacy bool {{- end }} -{{- if .Endpoint.Method.ViewedResult }} +{{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named in the response header. Later sends must use the same view." }} + sentView string + {{- else }} + viewSet bool + {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/transform_helper.go.tpl b/grpc/codegen/templates/transform_helper.go.tpl index 5b2c37b65a..51132bddd9 100644 --- a/grpc/codegen/templates/transform_helper.go.tpl +++ b/grpc/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{ printf "%s builds a value of type %s from a value of type %s." .Declaration.Name .ResultTypeRef .ParamTypeRef | comment }} +func {{ .Declaration.Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/grpc/codegen/templates/type_init.go.tpl b/grpc/codegen/templates/type_init.go.tpl index 1c29aaf422..e5f2752498 100644 --- a/grpc/codegen/templates/type_init.go.tpl +++ b/grpc/codegen/templates/type_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} {{- if .ReturnIsStruct }} {{- range .Args }} diff --git a/grpc/codegen/templates/validate.go.tpl b/grpc/codegen/templates/validate.go.tpl index 93fb841e5e..ec4a2d25ba 100644 --- a/grpc/codegen/templates/validate.go.tpl +++ b/grpc/codegen/templates/validate.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s runs the validations defined on %s." .Name .SrcName | comment }} -func {{ .Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { +{{ printf "%s runs the validations defined on %s." .Declaration.Name .SrcName | comment }} +func {{ .Declaration.Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { {{ .Def }} return } diff --git a/grpc/codegen/testdata/client-bidirectional-streaming.golden b/grpc/codegen/testdata/client-bidirectional-streaming.golden new file mode 100644 index 0000000000..c28ba6a08f --- /dev/null +++ b/grpc/codegen/testdata/client-bidirectional-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-bidirectional-streaming-rpc": + switch flag.Arg(1) { + case "method-bidirectional-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceBidirectionalStreamingRPC\" method \"MethodBidirectionalStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-client-streaming.golden b/grpc/codegen/testdata/client-client-streaming.golden new file mode 100644 index 0000000000..392997e0ab --- /dev/null +++ b/grpc/codegen/testdata/client-client-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-client-streaming-rpc": + switch flag.Arg(1) { + case "method-client-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceClientStreamingRPC\" method \"MethodClientStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index 9d066dbfda..a10d66044e 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -1,28 +1,43 @@ import ( + "context" + "errors" + "flag" "fmt" - "os" + "io" cli "generated.local/gen/grpc/cli/test" interceptors "generated.local/interceptors" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } + defer func() { + err = errors.Join(err, conn.Close()) + }() serviceWithInterceptorsInterceptors := interceptors.NewServiceWithInterceptorsClientInterceptors() - return cli.ParseEndpoint( + endpoint, payload, err := cli.ParseEndpoint( conn, serviceWithInterceptorsInterceptors, ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -func grpcUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service-with-interceptors": + switch flag.Arg(1) { + case "method-a": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "method-b": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } func grpcUsageExamples() string { diff --git a/grpc/codegen/testdata/client-no-server-pkgpath.golden b/grpc/codegen/testdata/client-no-server-pkgpath.golden index d1033172ff..5b2939cf9f 100644 --- a/grpc/codegen/testdata/client-no-server-pkgpath.golden +++ b/grpc/codegen/testdata/client-no-server-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/test_api" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-no-server.golden b/grpc/codegen/testdata/client-no-server.golden index f7973537b0..51834398dd 100644 --- a/grpc/codegen/testdata/client-no-server.golden +++ b/grpc/codegen/testdata/client-no-server.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - "os" + "io" cli "generated.local/gen/grpc/cli/test_api" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden index 5324d085af..5d3eec4ec5 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden index 8199afaeef..bf204eb478 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" - "os" + "io" cli "generated.local/gen/grpc/cli/single_host" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden index 5324d085af..2e9a1f2266 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset.golden b/grpc/codegen/testdata/client-server-hosting-service-subset.golden index 8199afaeef..85b41f0223 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - "os" + "io" cli "generated.local/gen/grpc/cli/single_host" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-streaming.golden b/grpc/codegen/testdata/client-server-streaming.golden new file mode 100644 index 0000000000..b9f7b82826 --- /dev/null +++ b/grpc/codegen/testdata/client-server-streaming.golden @@ -0,0 +1,46 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + serviceserverstreamingrpc "generated.local/gen/service_server_streaming_rpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service-server-streaming-rpc": + switch flag.Arg(1) { + case "method-server-streaming-rpc": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(serviceserverstreamingrpc.MethodServerStreamingRPCClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/dsls.go b/grpc/codegen/testdata/dsls.go index b23b7020c0..1f98181460 100644 --- a/grpc/codegen/testdata/dsls.go +++ b/grpc/codegen/testdata/dsls.go @@ -203,10 +203,7 @@ var ServerStreamingResultWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "DoubleField", Float64) - }) - View("default", func() { - Attribute("IntField") - Attribute("DoubleField") + Required("IntField", "DoubleField") }) View("tiny", func() { Attribute("IntField") @@ -245,6 +242,32 @@ var ServerStreamingResultCollectionWithExplicitViewDSL = func() { }) } +var ClientStreamingResultCollectionWithExplicitViewDSL = func() { + var RT = ResultType("application/vnd.client-streaming-result", func() { + TypeName("ResultType") + Attributes(func() { + Field(1, "IntField", Int) + Field(2, "DoubleField", Float64) + }) + View("default", func() { + Attribute("IntField") + Attribute("DoubleField") + }) + View("tiny", func() { + Attribute("IntField") + }) + }) + Service("ServiceClientStreamingResultTypeCollectionWithExplicitView", func() { + Method("MethodClientStreamingResultTypeCollectionWithExplicitView", func() { + StreamingPayload(String) + Result(CollectionOf(RT), func() { + View("tiny") + }) + GRPC(func() {}) + }) + }) +} + var ClientStreamingRPCDSL = func() { Service("ServiceClientStreamingRPC", func() { Method("MethodClientStreamingRPC", func() { @@ -266,6 +289,24 @@ var ClientStreamingRPCWithPayloadDSL = func() { }) } +var ClientStreamingRPCWithMetadataOnlyPayloadDSL = func() { + Service("ServiceClientStreamingRPCWithMetadataOnlyPayload", func() { + Method("MethodClientStreamingRPCWithMetadataOnlyPayload", func() { + Payload(func() { + Field(1, "token", String) + Required("token") + }) + StreamingPayload(String) + Result(String) + GRPC(func() { + Metadata(func() { + Attribute("token") + }) + }) + }) + }) +} + var ClientStreamingRPCWithPayloadLegacyCompatDSL = func() { Service("ServiceClientStreamingRPCWithPayloadLegacyCompat", func() { Method("MethodClientStreamingRPCWithPayloadLegacyCompat", func() { @@ -543,10 +584,7 @@ var MessageResultTypeWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) - }) - View("default", func() { - Attribute("IntField") - Attribute("StringField") + Required("IntField", "StringField") }) View("tiny", func() { Attribute("IntField") @@ -566,6 +604,7 @@ var MessageResultTypeWithExplicitViewDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) + Required("IntField", "StringField") }) View("default", func() { Attribute("IntField") @@ -1181,12 +1220,16 @@ var InterceptorsDSL = func() { ClientInterceptor(LogInterceptor) Method("MethodA", func() { ClientInterceptor(MetricsInterceptor) - Payload(String) + Payload(String, func() { + Example("hello") + }) Result(String) GRPC(func() {}) }) Method("MethodB", func() { - Payload(Int) + Payload(Int, func() { + Example(42) + }) Result(Int) GRPC(func() {}) }) diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 2a57c53057..127b44f16b 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -27,7 +27,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"Voluptatem officia aut.\"\n }'" + "\n" + + return os.Args[0] + " " + "service-with-interceptors method-a --message \"hello\"" + "\n" + "" } @@ -161,7 +161,7 @@ func serviceWithInterceptorsMethodAUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"Voluptatem officia aut.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message \"hello\"") } func serviceWithInterceptorsMethodBUsage() { @@ -179,5 +179,5 @@ func serviceWithInterceptorsMethodBUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 3200818835133106279\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message 42") } diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden index deb3926605..405c7cb3af 100644 --- a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden @@ -28,11 +28,11 @@ func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payload if err != nil { return nil, fmt.Errorf("invalid value for metadataInt, must be INT") } - if *metadataInt < 0 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 0, true)) + if val < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 0, true)) } - if *metadataInt > 100 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 100, false)) + if val > 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 100, false)) } if err != nil { return nil, err @@ -43,11 +43,11 @@ func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payload { if payloadWithValidationMethodAMetadataString != "" { metadataString = &payloadWithValidationMethodAMetadataString - if utf8.RuneCountInString(*metadataString) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 5, true)) + if utf8.RuneCountInString(payloadWithValidationMethodAMetadataString) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(payloadWithValidationMethodAMetadataString), 5, true)) } - if utf8.RuneCountInString(*metadataString) > 10 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 10, false)) + if utf8.RuneCountInString(payloadWithValidationMethodAMetadataString) > 10 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(payloadWithValidationMethodAMetadataString), 10, false)) } if err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden index 1129af7b45..e98095127a 100644 --- a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden @@ -1,14 +1,14 @@ -// NewProtoMethodResultWithAliasValidationRequest builds the gRPC request type -// from the payload of the "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service. +// NewProtoMethodResultWithAliasValidationRequest builds +// *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest +// from metadata values. func NewProtoMethodResultWithAliasValidationRequest() *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest { message := &service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest{} return message } -// NewMethodResultWithAliasValidationResult builds the result type of the -// "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service from the gRPC response type. +// NewMethodResultWithAliasValidationResult builds +// serviceresultwithaliasvalidation.UUID from +// *service_result_with_alias_validationpb.UUID. func NewMethodResultWithAliasValidationResult(message *service_result_with_alias_validationpb.UUID) serviceresultwithaliasvalidation.UUID { result := serviceresultwithaliasvalidation.UUID(message.Field) return result diff --git a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden index 4bbda5f295..b58fed76f1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden @@ -1,3 +1,6 @@ +// NewMethodBidirectionalStreamingRPCSameTypeResponseUserType builds +// *servicebidirectionalstreamingrpcsametype.UserType from +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse. func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse) *servicebidirectionalstreamingrpcsametype.UserType { result := &servicebidirectionalstreamingrpcsametype.UserType{ B: v.B, @@ -9,6 +12,10 @@ func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidir return result } +// NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest +// builds +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest +// from *servicebidirectionalstreamingrpcsametype.UserType. func NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest(spayload *servicebidirectionalstreamingrpcsametype.UserType) *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest { v := &service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest{ B: spayload.B, diff --git a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden index 07f660310c..7fe13d9a38 100644 --- a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodRequest builds *default_fieldspb.MethodRequest from +// *defaultfields.MethodPayload. func NewProtoMethodRequest(payload *defaultfields.MethodPayload) *default_fieldspb.MethodRequest { message := &default_fieldspb.MethodRequest{ Req: payload.Req, diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden index f5b2947f9d..01c1cf58ff 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden @@ -1,6 +1,6 @@ -// NewProtoMethodMessageUserTypeWithAliasRequest builds the gRPC request type -// from the payload of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasRequest builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest { message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest{ IntAliasField: int32(payload.IntAliasField), @@ -12,9 +12,9 @@ func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageuserty return message } -// NewMethodMessageUserTypeWithAliasResult builds the result type of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC response type. +// NewMethodMessageUserTypeWithAliasResult builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse. func NewMethodMessageUserTypeWithAliasResult(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse) *servicemessageusertypewithalias.PayloadAliasT { result := &servicemessageusertypewithalias.PayloadAliasT{ IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden index 45aa6d3796..7c03c13ca2 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden @@ -1,6 +1,5 @@ -// NewProtoDupePayload builds the gRPC request type from the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service. +// NewProtoDupePayload builds *service_payload_with_nested_typespb.DupePayload +// from servicepayloadwithnestedtypes.DupePayload. func NewProtoDupePayload(payload servicepayloadwithnestedtypes.DupePayload) *service_payload_with_nested_typespb.DupePayload { message := &service_payload_with_nested_typespb.DupePayload{} message.Field = string(payload) diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden index 2945ad9abf..a45530d532 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden @@ -1,32 +1,33 @@ -// NewProtoMethodPayloadWithNestedTypesRequest builds the gRPC request type -// from the payload of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesRequest builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest +// from *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload. func NewProtoMethodPayloadWithNestedTypesRequest(payload *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload) *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest{} if payload.AParams != nil { - message.AParams = svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(payload.AParams) + message.AParams = transformAParamsToProtoAParams(payload.AParams) } if payload.BParams != nil { - message.BParams = svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(payload.BParams) + message.BParams = transformBParamsToProtoBParams(payload.BParams) } return message } -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { +// transformAParamsToProtoAParams builds a value of type +// *service_payload_with_nested_typespb.AParams from a value of type +// *servicepayloadwithnestedtypes.AParams. +func transformAParamsToProtoAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { if v == nil { return nil } - res := &servicepayloadwithnestedtypes.AParams{} + res := &service_payload_with_nested_typespb.AParams{} if v.A != nil { - res.A = make(map[string][]string, len(v.A)) + res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) for key, val := range v.A { tk := key - tv := make([]string, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + tv := &service_payload_with_nested_typespb.ArrayOfString{} + tv.Field = make([]string, len(val)) + for i, val := range val { + tv.Field[i] = val } res.A[tk] = tv } @@ -35,14 +36,14 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { +// transformBParamsToProtoBParams builds a value of type +// *service_payload_with_nested_typespb.BParams from a value of type +// *servicepayloadwithnestedtypes.BParams. +func transformBParamsToProtoBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { if v == nil { return nil } - res := &servicepayloadwithnestedtypes.BParams{} + res := &service_payload_with_nested_typespb.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { @@ -55,22 +56,21 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { if v == nil { return nil } - res := &service_payload_with_nested_typespb.AParams{} + res := &servicepayloadwithnestedtypes.AParams{} if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) + res.A = make(map[string][]string, len(v.A)) for key, val := range v.A { tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val + tv := make([]string, len(val.Field)) + for i, val := range val.Field { + tv[i] = val } res.A[tk] = tv } @@ -79,14 +79,14 @@ func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAPa return res } -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { if v == nil { return nil } - res := &service_payload_with_nested_typespb.BParams{} + res := &servicepayloadwithnestedtypes.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { diff --git a/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden new file mode 100644 index 0000000000..4057042a28 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden @@ -0,0 +1,65 @@ +// ValidateExchangeResponse runs the validations defined on ExchangeResponse. +func ValidateExchangeResponse(message *union_validationpb.ExchangeResponse) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeResponse_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeResponse_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden index e0dfc5b766..f97f9d8104 100644 --- a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden @@ -1,49 +1,26 @@ -// NewProtoMethodResultWithCollectionRequest builds the gRPC request type from -// the payload of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionRequest builds +// *service_result_with_collectionpb.MethodResultWithCollectionRequest from +// metadata values. func NewProtoMethodResultWithCollectionRequest() *service_result_with_collectionpb.MethodResultWithCollectionRequest { message := &service_result_with_collectionpb.MethodResultWithCollectionRequest{} return message } -// NewMethodResultWithCollectionResult builds the result type of the -// "MethodResultWithCollection" endpoint of the "ServiceResultWithCollection" -// service from the gRPC response type. +// NewMethodResultWithCollectionResult builds +// *serviceresultwithcollection.MethodResultWithCollectionResult from +// *service_result_with_collectionpb.MethodResultWithCollectionResponse. func NewMethodResultWithCollectionResult(message *service_result_with_collectionpb.MethodResultWithCollectionResponse) *serviceresultwithcollection.MethodResultWithCollectionResult { result := &serviceresultwithcollection.MethodResultWithCollectionResult{} if message.Result != nil { - result.Result = protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(message.Result) + result.Result = transformProtoResultTToResultT(message.Result) } return result } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { - if v == nil { - return nil - } - res := &service_result_with_collectionpb.ResultT{} - if v.CollectionField != nil { - res.CollectionField = &service_result_with_collectionpb.RTCollection{} - res.CollectionField.Field = make([]*service_result_with_collectionpb.RT, len(v.CollectionField)) - for i, val := range v.CollectionField { - res.CollectionField.Field[i] = &service_result_with_collectionpb.RT{} - if val.IntField != nil { - intField := int32(*val.IntField) - res.CollectionField.Field[i].IntField = &intField - } - } - } - - return res -} - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { +// transformProtoResultTToResultT builds a value of type +// *serviceresultwithcollection.ResultT from a value of type +// *service_result_with_collectionpb.ResultT. +func transformProtoResultTToResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { if v == nil { return nil } diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..257dd37b24 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden @@ -0,0 +1,17 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewRequest builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithExplicitViewRequest() *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest{} + return message +} + +// NewMethodMessageResultTypeWithExplicitViewResult builds +// *servicemessageresulttypewithexplicitviewviews.RTView from +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse. +func NewMethodMessageResultTypeWithExplicitViewResult(message *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) *servicemessageresulttypewithexplicitviewviews.RTView { + result := &servicemessageresulttypewithexplicitviewviews.RTView{} + intField := int(message.IntField) + result.IntField = &intField + return result +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden new file mode 100644 index 0000000000..43067100d9 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden @@ -0,0 +1,29 @@ +// NewProtoMethodMessageResultTypeWithViewsRequest builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithViewsRequest() *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest{} + return message +} + +// NewMethodMessageResultTypeWithViewsResult builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResult(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{ + StringField: &message.StringField, + } + intField := int(message.IntField) + result.IntField = &intField + return result +} + +// NewMethodMessageResultTypeWithViewsResultTiny builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResultTiny(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{} + intField := int(message.IntField) + result.IntField = &intField + return result +} diff --git a/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..cbc2ed3426 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden @@ -0,0 +1,29 @@ +// NewProtoMethodServerStreamingUserTypeRPCRequest builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest +// from metadata values. +func NewProtoMethodServerStreamingUserTypeRPCRequest() *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest{} + return message +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeView builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{ + DoubleField: &v.DoubleField, + } + intField := int(v.IntField) + vresult.IntField = &intField + return vresult +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{} + intField := int(v.IntField) + vresult.IntField = &intField + return vresult +} diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden index 6614d43394..771ef5fdb1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{ A: &payload.Foo, @@ -13,8 +13,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden index 38bac435c2..5c557cb6eb 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{} a := int64(payload.A) @@ -19,8 +19,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { diff --git a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden index dc512d56b5..38b14555c1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden @@ -1,23 +1,21 @@ -// NewProtoMethodUnaryRPCWithErrorsRequest builds the gRPC request type from -// the payload of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsRequest builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest from string. func NewProtoMethodUnaryRPCWithErrorsRequest(payload string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest{} message.Field = payload return message } -// NewMethodUnaryRPCWithErrorsResult builds the result type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC response type. +// NewMethodUnaryRPCWithErrorsResult builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse. func NewMethodUnaryRPCWithErrorsResult(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse) string { result := message.Field return result } -// NewMethodUnaryRPCWithErrorsInternalError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError. func NewMethodUnaryRPCWithErrorsInternalError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ Name: message.Name, @@ -26,9 +24,9 @@ func NewMethodUnaryRPCWithErrorsInternalError(message *service_unary_rpc_with_er return er } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError. func NewMethodUnaryRPCWithErrorsBadRequestError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ Name: message.Name, @@ -37,9 +35,9 @@ func NewMethodUnaryRPCWithErrorsBadRequestError(message *service_unary_rpc_with_ return er } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *serviceunaryrpcwitherrors.ErrorType from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError. func NewMethodUnaryRPCWithErrorsCustomErrorError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError) *serviceunaryrpcwitherrors.ErrorType { er := &serviceunaryrpcwitherrors.ErrorType{ A: message.A, diff --git a/grpc/codegen/testdata/golden/planned_name_collisions.go.golden b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..aa1a2c15ba --- /dev/null +++ b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,271 @@ +// BuildWatchFunc2 builds the remote method to invoke for "SavedTransport" +// service "Watch" endpoint. +func BuildWatchFunc2(grpccli saved_transportpb.SavedTransportClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + stream, err := grpccli.Watch(ctx, opts...) + if err != nil { + return nil, err + } + if reqpb != nil { + if err := stream.Send(reqpb.(*saved_transportpb.WatchStreamingRequest)); err != nil { + return nil, err + } + } + return stream, nil + } +} + +// EncodeWatchRequest2 encodes requests sent to SavedTransport Watch endpoint. +func EncodeWatchRequest2(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*savedtransport.SavedPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedPayload", v) + } + tokenWire := payload.Token + (*md).Append("authorization", tokenWire) + (*md).Append(goagrpc.StreamProtocolMetadataKey, goagrpc.StreamProtocolEnvelope) + message := NewProtoWatchRequest2(payload) + return &saved_transportpb.WatchStreamingRequest{ + Body: &saved_transportpb.WatchStreamingRequest_InitialPayload{ + InitialPayload: message, + }, + }, nil +} + +// DecodeWatchResponse2 decodes responses from the SavedTransport Watch +// endpoint. +func DecodeWatchResponse2(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var ( + count int + value string + err error + ) + { + + if vals := hdr.Get("x-count"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-count", "metadata")) + } else { + countRaw := vals[0] + + v, err2 := strconv.ParseInt(countRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("count", countRaw, "integer")) + } + count = int(v) + } + + if vals := trlr.Get("x-value"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-value", "metadata")) + } else { + value = vals[0] + } + } + if err != nil { + return nil, err + } + return &WatchClientStream2{ + stream: v.(saved_transportpb.SavedTransport_WatchClient), + }, nil +} + +// Watch calls the "Watch" function in saved_transportpb.SavedTransportClient +// interface. +func (c *Client) Watch() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildWatchFunc2(c.grpccli, c.opts...), + EncodeWatchRequest2, + DecodeWatchResponse2) + res, err := inv.Invoke(ctx, v) + if err != nil { + // Try to decode a Goa error response detail before falling back to Fault. + resp := goagrpc.DecodeError(err) + if eresp, ok := resp.(*goapb.ErrorResponse); ok { + return nil, goagrpc.NewServiceError(eresp) + } + return nil, goa.Fault("%s", err.Error()) + } + return res, nil + } +} + +// WatchClientStream2 implements the savedtransport.WatchClientStream interface. +type WatchClientStream2 struct { + stream saved_transportpb.SavedTransport_WatchClient +} + +// DecodeWatchRequest2 decodes requests sent to "SavedTransport" service +// "Watch" endpoint. +func DecodeWatchRequest2(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("authorization"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("authorization", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var ( + message *saved_transportpb.WatchRequest + ok bool + ) + { + if v == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + var envelope *saved_transportpb.WatchStreamingRequest + if envelope, ok = v.(*saved_transportpb.WatchStreamingRequest); !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*saved_transportpb.WatchStreamingRequest", v) + } + switch body := envelope.Body.(type) { + case *saved_transportpb.WatchStreamingRequest_InitialPayload: + if body.InitialPayload == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + message = body.InitialPayload + case *saved_transportpb.WatchStreamingRequest_StreamItem: + return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") + default: + return nil, goa.MissingFieldError("initial_payload", "stream") + } + if err = ValidateWatchRequest2(message); err != nil { + return nil, err + } + } + var payload *savedtransport.SavedPayload + { + payload = NewWatchPayload(message, token) + } + return payload, nil +} + +// EncodeWatchResponse2 encodes responses from the "SavedTransport" service +// "Watch" endpoint. +func EncodeWatchResponse2(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + result, ok := v.(*savedtransport.SavedResult) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedResult", v) + } + resp := NewProtoSavedResult() + + countWire := result.Count + (*hdr).Append("x-count", strconv.Itoa(countWire)) + + valueWire := result.Value + (*trlr).Append("x-value", valueWire) + return resp, nil +} + +// NewWatchHandler2 creates a gRPC handler which serves the "SavedTransport" +// service "Watch" endpoint. +func NewWatchHandler2(endpoint goa.Endpoint, h goagrpc.StreamHandler) goagrpc.StreamHandler { + if h == nil { + h = goagrpc.NewStreamHandler(endpoint, DecodeWatchRequest2) + } + return h +} + +// Watch implements the "Watch" method in +// saved_transportpb.SavedTransportServer interface. +func (s *Server) Watch(stream saved_transportpb.SavedTransport_WatchServer) error { + ctx := stream.Context() + ctx = context.WithValue(ctx, goa.MethodKey, "Watch") + ctx = context.WithValue(ctx, goa.ServiceKey, "SavedTransport") + var reqpb any + message, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + reqpb = nil + } else { + return goagrpc.EncodeError(err) + } + } else { + reqpb = message + } + p, err := s.WatchH.Decode(ctx, reqpb) + if err != nil { + return goagrpc.EncodeError(err) + } + ep := &savedtransport.WatchEndpointInput{ + Stream: &WatchServerStream2{stream: stream}, + Payload: p.(*savedtransport.SavedPayload), + } + err = s.WatchH.Handle(ctx, ep) + if err != nil { + return goagrpc.EncodeError(err) + } + return nil +} + +// WatchServerStream2 implements the savedtransport.WatchServerStream interface. +type WatchServerStream2 struct { + stream saved_transportpb.SavedTransport_WatchServer +} + +// NewProtoWatchRequest2 builds *saved_transportpb.WatchRequest from +// *savedtransport.SavedPayload. +func NewProtoWatchRequest2(payload *savedtransport.SavedPayload) *saved_transportpb.WatchRequest { + message := &saved_transportpb.WatchRequest{ + Value: payload.Value, + } + return message +} + +// NewWatchResponseSavedResult builds *savedtransport.SavedResult from metadata +// values. +func NewWatchResponseSavedResult() *savedtransport.SavedResult { + result := &savedtransport.SavedResult{} + return result +} + +// NewProtoSavedStreamWatchStreamItem builds *saved_transportpb.WatchStreamItem +// from *savedtransport.SavedStream. +func NewProtoSavedStreamWatchStreamItem(spayload *savedtransport.SavedStream) *saved_transportpb.WatchStreamItem { + v := &saved_transportpb.WatchStreamItem{ + Value: spayload.Value, + } + return v +} + +// NewWatchPayload builds *savedtransport.SavedPayload from +// *saved_transportpb.WatchRequest. +func NewWatchPayload(message *saved_transportpb.WatchRequest, token string) *savedtransport.SavedPayload { + v := &savedtransport.SavedPayload{ + Value: message.Value, + } + v.Token = token + return v +} + +// NewProtoSavedResult builds *saved_transportpb.WatchResponse from +// *savedtransport.SavedResult. +func NewProtoSavedResult() *saved_transportpb.WatchResponse { + message := &saved_transportpb.WatchResponse{} + return message +} + +// NewWatchStreamItemSavedStream builds *savedtransport.SavedStream from +// *saved_transportpb.WatchStreamItem. +func NewWatchStreamItemSavedStream(v *saved_transportpb.WatchStreamItem) *savedtransport.SavedStream { + spayload := &savedtransport.SavedStream{ + Value: v.Value, + } + return spayload +} + +// ValidateWatchRequest2 runs the validations defined on WatchRequest. +func ValidateWatchRequest2(message *saved_transportpb.WatchRequest) (err error) { + if utf8.RuneCountInString(message.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("message.value", message.Value, utf8.RuneCountInString(message.Value), 2, true)) + } + return +} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden index f22dc74bba..f7e0a6ef69 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero string - if target.RawJson == zero { + if target.RawJson == nil { target.RawJson = json.RawMessage{0x66, 0x6f, 0x6f} } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden index f096a8fa5e..a6db5e3516 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden @@ -6,7 +6,7 @@ func transform() { switch string(source.EmbeddedOneOf.Kind()) { case "string": actual, _ := source.EmbeddedOneOf.AsString() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_{String_: string(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_2{String_2: string(actual)} case "integer": actual, _ := source.EmbeddedOneOf.AsInteger() target.EmbeddedOneOf = &proto.EmbeddedOneOf_Integer{Integer: int32(actual)} @@ -18,10 +18,10 @@ func transform() { target.EmbeddedOneOf = &proto.EmbeddedOneOf_Number{Number: int32(actual)} case "array": actual, _ := source.EmbeddedOneOf.AsArray() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray2(actual)} case "map": actual, _ := source.EmbeddedOneOf.AsMap() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap2(actual)} case "user_type": actual, _ := source.EmbeddedOneOf.AsUserType() target.EmbeddedOneOf = &proto.EmbeddedOneOf_UserType{UserType: svcProtoSimpleOneOfToProtoSimpleOneOf(actual)} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden index a77e32ff1f..f423522ef8 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero json.RawMessage - if target.RawJSON == zero { + if target.RawJSON == nil { target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} } } @@ -29,8 +28,7 @@ func transform() { } } { - var zero []byte - if target.Bytes == zero { + if target.Bytes == nil { target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden index 4e75f8833f..8b9b815437 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden @@ -4,10 +4,10 @@ func transform() { } if source.EmbeddedOneOf != nil { switch val := source.EmbeddedOneOf.(type) { - case *proto.EmbeddedOneOf_String_: + case *proto.EmbeddedOneOf_String_2: { u := target.EmbeddedOneOf - u.SetString(proto.EmbeddedOneOfString(val.String_)) + u.SetString(proto.EmbeddedOneOfString(val.String_2)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Integer: @@ -31,13 +31,13 @@ func transform() { case *proto.EmbeddedOneOf_Array: { u := target.EmbeddedOneOf - u.SetArray(protobufProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(val.Array)) + u.SetArray(protobufProtoEmbeddedOneOfArray2ToProtoEmbeddedOneOfArray(val.Array)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Map_: { u := target.EmbeddedOneOf - u.SetMap(protobufProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(val.Map_)) + u.SetMap(protobufProtoEmbeddedOneOfMap2ToProtoEmbeddedOneOfMap(val.Map_)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_UserType: diff --git a/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden new file mode 100644 index 0000000000..6bdec9df76 --- /dev/null +++ b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden @@ -0,0 +1,57 @@ +// NewProtoResultTypeCollection builds +// *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection +// from +// serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView. +func NewProtoResultTypeCollection(result serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView) *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection { + message := &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection{} + message.Field = make([]*service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType, len(result)) + for i, val := range result { + message.Field[i] = &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType{} + if val.IntField != nil { + intField := int32(*val.IntField) + message.Field[i].IntField = &intField + } + } + return message +} + +// NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest +// builds string from +// *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest. +func NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest(v *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest) string { + spayload := v.Field + return spayload +} + +// EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse +// encodes responses from the +// "ServiceClientStreamingResultTypeCollectionWithExplicitView" service +// "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint. +func EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceClientStreamingResultTypeCollectionWithExplicitView", "MethodClientStreamingResultTypeCollectionWithExplicitView", "serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection", v) + } + result := vres.Projected + resp := NewProtoResultTypeCollection(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} + +// SendAndClose streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndClose(res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + vres := serviceclientstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") + v := NewProtoResultTypeCollection(vres.Projected) + return s.stream.SendAndClose(v) +} + +// SendAndCloseWithContext streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream with context. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndCloseWithContext(ctx context.Context, res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + return s.SendAndClose(res) +} diff --git a/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden new file mode 100644 index 0000000000..851cf6027b --- /dev/null +++ b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden @@ -0,0 +1,79 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + DoubleField: *result.DoubleField, + } + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + } + return message +} + +// EncodeMethodServerStreamingUserTypeRPCResponse encodes responses from the +// "ServiceServerStreamingUserTypeRPC" service +// "MethodServerStreamingUserTypeRPC" endpoint. +func EncodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*serviceserverstreamingusertyperpcviews.ResultType) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceServerStreamingUserTypeRPC", "MethodServerStreamingUserTypeRPC", "*serviceserverstreamingusertyperpcviews.ResultType", v) + } + result := vres.Projected + var resp *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result) + case "default", "": + resp = NewProtoMethodServerStreamingUserTypeRPCResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} + +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden new file mode 100644 index 0000000000..02a139a599 --- /dev/null +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden @@ -0,0 +1,24 @@ +// DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest decodes +// requests sent to "ServiceClientStreamingRPCWithMetadataOnlyPayload" service +// "MethodClientStreamingRPCWithMetadataOnlyPayload" endpoint. +func DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("token"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var payload *serviceclientstreamingrpcwithmetadataonlypayload.MethodClientStreamingRPCWithMetadataOnlyPayloadPayload + { + payload = NewMethodClientStreamingRPCWithMetadataOnlyPayloadPayload(token) + } + return payload, nil +} diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden index 1c050b2b15..7c8f78f74a 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden @@ -7,8 +7,7 @@ func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *meta } if payload.InMetadata != nil { inMetadataWire := *payload.InMetadata - (*md).Append("Authorization", fmt.Sprintf("%v", - inMetadataWire)) + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithMetadataRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden index c5549ab734..bc48fd95db 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden @@ -7,23 +7,19 @@ func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *meta } if payload.Token != nil { tokenWire := *payload.Token - (*md).Append("authorization", - tokenWire) + (*md).Append("authorization", tokenWire) } if payload.Key != nil { keyWire := *payload.Key - (*md).Append("authorization", - keyWire) + (*md).Append("authorization", keyWire) } if payload.Username != nil { usernameWire := *payload.Username - (*md).Append("username", - usernameWire) + (*md).Append("username", usernameWire) } if payload.Password != nil { passwordWire := *payload.Password - (*md).Append("password", - passwordWire) + (*md).Append("password", passwordWire) } return NewProtoMethodMessageWithSecurityRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden index 9fe4eab899..444c7cdf3d 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden @@ -7,8 +7,7 @@ func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *meta } if payload.InMetadata != nil { inMetadataWire := *payload.InMetadata - (*md).Append("Authorization", fmt.Sprintf("%v", - inMetadataWire)) + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithValidateRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden index f469ab0e62..92eb83d19c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden @@ -1,14 +1,7 @@ // DecodeMethodBidirectionalStreamingRPCResponse decodes responses from the // ServiceBidirectionalStreamingRPC MethodBidirectionalStreamingRPC endpoint. func DecodeMethodBidirectionalStreamingRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodBidirectionalStreamingRPCClientStream{ stream: v.(service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient), - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden index 67e0bf115d..9e1d02139c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden @@ -12,7 +12,13 @@ func DecodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*service_message_user_type_with_nested_user_typespb.RTCollection", v) } - res := NewMethodMessageUserTypeWithNestedUserTypesResult(message) + var res servicemessageusertypewithnestedusertypesviews.RTCollectionView + switch view { + case "default", "": + res = NewMethodMessageUserTypeWithNestedUserTypesResult(message) + case "tiny": + res = NewMethodMessageUserTypeWithNestedUserTypesResultTiny(message) + } vres := servicemessageusertypewithnestedusertypesviews.RTCollection{Projected: res, View: view} if err := servicemessageusertypewithnestedusertypesviews.ValidateRTCollection(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden index 6b8a15f20a..85b4e7eaa6 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden @@ -2,18 +2,12 @@ // the ServiceMessageResultTypeWithExplicitView // MethodMessageResultTypeWithExplicitView endpoint. func DecodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } message, ok := v.(*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse", v) } res := NewMethodMessageResultTypeWithExplicitViewResult(message) - vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: view} + vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: "tiny"} if err := servicemessageresulttypewithexplicitviewviews.ValidateRT(vres); err != nil { return nil, err } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden index 0f229f21ab..e6d4ee8986 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden @@ -11,7 +11,13 @@ func DecodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse", v) } - res := NewMethodMessageResultTypeWithViewsResult(message) + var res *servicemessageresulttypewithviewsviews.RTView + switch view { + case "tiny": + res = NewMethodMessageResultTypeWithViewsResultTiny(message) + case "default", "": + res = NewMethodMessageResultTypeWithViewsResult(message) + } vres := &servicemessageresulttypewithviewsviews.RT{Projected: res, View: view} if err := servicemessageresulttypewithviewsviews.ValidateRT(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden index 68e963cdce..0025b57207 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden @@ -1,14 +1,7 @@ // DecodeMethodServerStreamingUserTypeRPCResponse decodes responses from the // ServiceServerStreamingUserTypeRPC MethodServerStreamingUserTypeRPC endpoint. func DecodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodServerStreamingUserTypeRPCClientStream{ stream: v.(service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient), - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden index bb2fe25f47..333ac28f59 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) } result := vres.Projected + var resp *service_message_user_type_with_nested_user_typespb.RTCollection + switch vres.View { + case "default", "": + resp = NewProtoRTCollection(result) + case "tiny": + resp = NewProtoRTCollectionTiny(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"default", "tiny"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden index 525201a0f1..b8aa816b08 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden @@ -7,7 +7,7 @@ func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden index 0eede6a829..febb194a0d 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden @@ -9,14 +9,12 @@ func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr if result.InHeader != nil { inHeaderWire := *result.InHeader - (*hdr).Append("Location", fmt.Sprintf("%v", - inHeaderWire)) + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } if result.InTrailer != nil { inTrailerWire := *result.InTrailer - (*trlr).Append("InTrailer", fmt.Sprintf("%v", - inTrailerWire)) + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden index df095300f5..a4acf54b13 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden @@ -9,14 +9,12 @@ func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr if result.InHeader != nil { inHeaderWire := *result.InHeader - (*hdr).Append("Location", fmt.Sprintf("%v", - inHeaderWire)) + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } if result.InTrailer != nil { inTrailerWire := *result.InTrailer - (*trlr).Append("InTrailer", fmt.Sprintf("%v", - inTrailerWire)) + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden index 3699a16bf5..afd26b1453 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) } result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden index cb8b274a93..42eff96b6d 100644 --- a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden @@ -1,14 +1,12 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds serviceelemvalidation.UUID from +// *service_elem_validationpb.UUID. func NewMethodElemValidationPayload(message *service_elem_validationpb.UUID) serviceelemvalidation.UUID { v := serviceelemvalidation.UUID(message.Field) return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden index 806756a5d4..f6728dec94 100644 --- a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "DefaultFields" service from the gRPC request type. +// NewMethodPayload builds *defaultfields.MethodPayload from +// *default_fieldspb.MethodRequest. func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.MethodPayload { v := &defaultfields.MethodPayload{ Req: message.Req, @@ -54,8 +54,8 @@ func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.Me return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodResponse builds *default_fieldspb.MethodResponse from metadata +// values. func NewProtoMethodResponse() *default_fieldspb.MethodResponse { message := &default_fieldspb.MethodResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden index 46fa2111a8..f4c87aa3e9 100644 --- a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden @@ -1,6 +1,5 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds *serviceelemvalidation.PayloadType +// from *service_elem_validationpb.MethodElemValidationRequest. func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodElemValidationRequest) *serviceelemvalidation.PayloadType { v := &serviceelemvalidation.PayloadType{} if message.Foo != nil { @@ -17,9 +16,8 @@ func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodEle return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden index 565b291bff..05d5b8b491 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden @@ -1,6 +1,6 @@ -// NewMethodMessageUserTypeWithAliasPayload builds the payload of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC request type. +// NewMethodMessageUserTypeWithAliasPayload builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest. func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest) *servicemessageusertypewithalias.PayloadAliasT { v := &servicemessageusertypewithalias.PayloadAliasT{ IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), @@ -12,9 +12,9 @@ func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type return v } -// NewProtoMethodMessageUserTypeWithAliasResponse builds the gRPC response type -// from the result of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasResponse builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasResponse(result *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse { message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse{ IntAliasField: int32(result.IntAliasField), diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden index 2ca3f95d14..a6317b5f99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden @@ -1,6 +1,5 @@ -// NewMethodPayloadWithCustomTypePackagePayload builds the payload of the -// "MethodPayloadWithCustomTypePackage" endpoint of the -// "ServicePayloadWithCustomTypePackage" service from the gRPC request type. +// NewMethodPayloadWithCustomTypePackagePayload builds *types.CustomType from +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest. func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest) *types.CustomType { v := &types.CustomType{} if message.Field != nil { @@ -10,9 +9,9 @@ func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_ return v } -// NewProtoMethodPayloadWithCustomTypePackageResponse builds the gRPC response -// type from the result of the "MethodPayloadWithCustomTypePackage" endpoint of -// the "ServicePayloadWithCustomTypePackage" service. +// NewProtoMethodPayloadWithCustomTypePackageResponse builds +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse +// from *types.CustomType. func NewProtoMethodPayloadWithCustomTypePackageResponse(result *types.CustomType) *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse { message := &service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse{} if result.Field != nil { diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden index 7fc3e8f5eb..2f49c12f8f 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden @@ -1,30 +1,21 @@ -// NewMethodPayloadDuplicateAPayload builds the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateAPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { +// NewDupePayload builds servicepayloadwithnestedtypes.DupePayload from +// *service_payload_with_nested_typespb.DupePayload. +func NewDupePayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { v := servicepayloadwithnestedtypes.DupePayload(message.Field) return v } -// NewProtoMethodPayloadDuplicateAResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateA" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateAResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse from +// metadata values. func NewProtoMethodPayloadDuplicateAResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse{} return message } -// NewMethodPayloadDuplicateBPayload builds the payload of the -// "MethodPayloadDuplicateB" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateBPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { - v := servicepayloadwithnestedtypes.DupePayload(message.Field) - return v -} - -// NewProtoMethodPayloadDuplicateBResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateB" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateBResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse from +// metadata values. func NewProtoMethodPayloadDuplicateBResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden index 2c4e2f962e..8f2e5d9989 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden @@ -1,5 +1,5 @@ -// NewUnaryMethodPayload builds the payload of the "UnaryMethod" endpoint of -// the "ServicePayloadWithMixedAttributes" service from the gRPC request type. +// NewUnaryMethodPayload builds *servicepayloadwithmixedattributes.APayload +// from *service_payload_with_mixed_attributespb.UnaryMethodRequest. func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.UnaryMethodRequest) *servicepayloadwithmixedattributes.APayload { v := &servicepayloadwithmixedattributes.APayload{ Required: int(message.Required), @@ -18,22 +18,25 @@ func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.Unar return v } -// NewProtoUnaryMethodResponse builds the gRPC response type from the result of -// the "UnaryMethod" endpoint of the "ServicePayloadWithMixedAttributes" -// service. +// NewProtoUnaryMethodResponse builds +// *service_payload_with_mixed_attributespb.UnaryMethodResponse from metadata +// values. func NewProtoUnaryMethodResponse() *service_payload_with_mixed_attributespb.UnaryMethodResponse { message := &service_payload_with_mixed_attributespb.UnaryMethodResponse{} return message } -// NewProtoStreamingMethodResponse builds the gRPC response type from the -// result of the "StreamingMethod" endpoint of the -// "ServicePayloadWithMixedAttributes" service. +// NewProtoStreamingMethodResponse builds +// *service_payload_with_mixed_attributespb.StreamingMethodResponse from +// metadata values. func NewProtoStreamingMethodResponse() *service_payload_with_mixed_attributespb.StreamingMethodResponse { message := &service_payload_with_mixed_attributespb.StreamingMethodResponse{} return message } +// NewStreamingMethodStreamingRequestAPayload builds +// *servicepayloadwithmixedattributes.APayload from +// *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest. func NewStreamingMethodStreamingRequestAPayload(v *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest) *servicepayloadwithmixedattributes.APayload { spayload := &servicepayloadwithmixedattributes.APayload{ Required: int(v.Required), diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden index 468b18e174..56c0bd9981 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden @@ -1,20 +1,20 @@ -// NewMethodPayloadWithNestedTypesPayload builds the payload of the -// "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service from the gRPC request type. +// NewMethodPayloadWithNestedTypesPayload builds +// *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload from +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest. func NewMethodPayloadWithNestedTypesPayload(message *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest) *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload { v := &servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload{} if message.AParams != nil { - v.AParams = protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(message.AParams) + v.AParams = transformProtoAParamsToAParams(message.AParams) } if message.BParams != nil { - v.BParams = protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(message.BParams) + v.BParams = transformProtoBParamsToBParams(message.BParams) } return v } -// NewProtoMethodPayloadWithNestedTypesResponse builds the gRPC response type -// from the result of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesResponse builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse +// from metadata values. func NewProtoMethodPayloadWithNestedTypesResponse() *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse{} return message @@ -51,10 +51,10 @@ func ValidateArrayOfString(val *service_payload_with_nested_typespb.ArrayOfStrin return } -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { if v == nil { return nil } @@ -74,10 +74,10 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { if v == nil { return nil } @@ -93,47 +93,3 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } - -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.AParams{} - if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) - for key, val := range v.A { - tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val - } - res.A[tk] = tv - } - } - - return res -} - -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.BParams{} - if v.B != nil { - res.B = make(map[string]string, len(v.B)) - for key, val := range v.B { - tk := key - tv := val - res.B[tk] = tv - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden new file mode 100644 index 0000000000..7f46ae1c08 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden @@ -0,0 +1,65 @@ +// ValidateExchangeRequest runs the validations defined on ExchangeRequest. +func ValidateExchangeRequest(message *union_validationpb.ExchangeRequest) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeRequest_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeRequest_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden index b6f63e4a56..72bc0013aa 100644 --- a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden @@ -1,18 +1,18 @@ -// NewProtoMethodResultWithCollectionResponse builds the gRPC response type -// from the result of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionResponse builds +// *service_result_with_collectionpb.MethodResultWithCollectionResponse from +// *serviceresultwithcollection.MethodResultWithCollectionResult. func NewProtoMethodResultWithCollectionResponse(result *serviceresultwithcollection.MethodResultWithCollectionResult) *service_result_with_collectionpb.MethodResultWithCollectionResponse { message := &service_result_with_collectionpb.MethodResultWithCollectionResponse{} if result.Result != nil { - message.Result = svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(result.Result) + message.Result = transformResultTToProtoResultT(result.Result) } return message } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { +// transformResultTToProtoResultT builds a value of type +// *service_result_with_collectionpb.ResultT from a value of type +// *serviceresultwithcollection.ResultT. +func transformResultTToProtoResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { if v == nil { return nil } @@ -31,25 +31,3 @@ func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT return res } - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { - if v == nil { - return nil - } - res := &serviceresultwithcollection.ResultT{} - if v.CollectionField != nil { - res.CollectionField = make([]*serviceresultwithcollection.RT, len(v.CollectionField.Field)) - for i, val := range v.CollectionField.Field { - res.CollectionField[i] = &serviceresultwithcollection.RT{} - if val.IntField != nil { - intField := int(*val.IntField) - res.CollectionField[i].IntField = &intField - } - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..ba47b9cc00 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden @@ -0,0 +1,9 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewResponse builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse +// from *servicemessageresulttypewithexplicitviewviews.RTView. +func NewProtoMethodMessageResultTypeWithExplicitViewResponse(result *servicemessageresulttypewithexplicitviewviews.RTView) *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden new file mode 100644 index 0000000000..94b3aa57ad --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden @@ -0,0 +1,20 @@ +// NewProtoMethodMessageResultTypeWithViewsResponse builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponse(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{ + IntField: int32(*result.IntField), + StringField: *result.StringField, + } + return message +} + +// NewProtoMethodMessageResultTypeWithViewsResponseTiny builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponseTiny(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..ff83835048 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden @@ -0,0 +1,20 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + DoubleField: *result.DoubleField, + } + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden index c0146338fd..6d90ef7b6a 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -17,8 +17,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{ A: &result.Foo, diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden index d222a7cbf1..b70e2e4a99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -27,8 +27,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{} a := int64(result.A) diff --git a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden index 25c09c5c8b..e72d3fb8c1 100644 --- a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden @@ -1,23 +1,22 @@ -// NewMethodUnaryRPCWithErrorsPayload builds the payload of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC request type. +// NewMethodUnaryRPCWithErrorsPayload builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest. func NewMethodUnaryRPCWithErrorsPayload(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest) string { v := message.Field return v } -// NewProtoMethodUnaryRPCWithErrorsResponse builds the gRPC response type from -// the result of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsResponse builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse from +// string. func NewProtoMethodUnaryRPCWithErrorsResponse(result string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse{} message.Field = result return message } -// NewMethodUnaryRPCWithErrorsInternalError builds the gRPC error response type -// from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError from +// *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsInternalError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError{ Name: er.Name, @@ -26,9 +25,9 @@ func NewMethodUnaryRPCWithErrorsInternalError(er *serviceunaryrpcwitherrors.Anot return message } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError +// from *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsBadRequestError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError{ Name: er.Name, @@ -37,9 +36,9 @@ func NewMethodUnaryRPCWithErrorsBadRequestError(er *serviceunaryrpcwitherrors.An return message } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError +// from *serviceunaryrpcwitherrors.ErrorType. func NewMethodUnaryRPCWithErrorsCustomErrorError(er *serviceunaryrpcwitherrors.ErrorType) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError{ A: er.A, diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden new file mode 100644 index 0000000000..afd26b1453 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden @@ -0,0 +1,21 @@ +// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the +// "ServiceMessageResultTypeWithViews" service +// "MethodMessageResultTypeWithViews" endpoint. +func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) + } + result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden new file mode 100644 index 0000000000..4ce4cfd685 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden @@ -0,0 +1,36 @@ +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden new file mode 100644 index 0000000000..b8aa816b08 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden @@ -0,0 +1,13 @@ +// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from +// the "ServiceMessageResultTypeWithExplicitView" service +// "MethodMessageResultTypeWithExplicitView" endpoint. +func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) + } + result := vres.Projected + resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} diff --git a/grpc/codegen/testdata/request_encoder_code.go b/grpc/codegen/testdata/request_encoder_code.go deleted file mode 100644 index 1251cba5bc..0000000000 --- a/grpc/codegen/testdata/request_encoder_code.go +++ /dev/null @@ -1,128 +0,0 @@ -package testdata - -const PayloadUserTypeRequestEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesRequest encodes requests sent -// to ServiceMessageUserTypeWithNestedUserTypes -// MethodMessageUserTypeWithNestedUserTypes endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessageusertypewithnestedusertypes.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*servicemessageusertypewithnestedusertypes.UT", v) - } - return NewProtoMethodMessageUserTypeWithNestedUserTypesRequest(payload), nil -} -` - -const PayloadArrayRequestEncoderCode = `// EncodeMethodUnaryRPCNoResultRequest encodes requests sent to -// ServiceUnaryRPCNoResult MethodUnaryRPCNoResult endpoint. -func EncodeMethodUnaryRPCNoResultRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.([]string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoResult", "MethodUnaryRPCNoResult", "[]string", v) - } - return NewProtoMethodUnaryRPCNoResultRequest(payload), nil -} -` - -const PayloadMapRequestEncoderCode = `// EncodeMethodMessageMapRequest encodes requests sent to ServiceMessageMap -// MethodMessageMap endpoint. -func EncodeMethodMessageMapRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(map[int]*servicemessagemap.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageMap", "MethodMessageMap", "map[int]*servicemessagemap.UT", v) - } - return NewProtoMethodMessageMapRequest(payload), nil -} -` - -const PayloadPrimitiveRequestEncoderCode = `// EncodeMethodServerStreamingRPCRequest encodes requests sent to -// ServiceServerStreamingRPC MethodServerStreamingRPC endpoint. -func EncodeMethodServerStreamingRPCRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceServerStreamingRPC", "MethodServerStreamingRPC", "int", v) - } - return NewProtoMethodServerStreamingRPCRequest(payload), nil -} -` - -const PayloadPrimitiveWithStreamingPayloadRequestEncoderCode = `// EncodeMethodClientStreamingRPCWithPayloadRequest encodes requests sent to -// ServiceClientStreamingRPCWithPayload MethodClientStreamingRPCWithPayload -// endpoint. -func EncodeMethodClientStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceClientStreamingRPCWithPayload", "MethodClientStreamingRPCWithPayload", "int", v) - } - (*md).Append("goa_payload", fmt.Sprintf("%v", payload)) - return nil, nil -} -` - -const PayloadUserTypeWithStreamingPayloadRequestEncoderCode = `// EncodeMethodBidirectionalStreamingRPCWithPayloadRequest encodes requests -// sent to ServiceBidirectionalStreamingRPCWithPayload -// MethodBidirectionalStreamingRPCWithPayload endpoint. -func EncodeMethodBidirectionalStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicebidirectionalstreamingrpcwithpayload.Payload) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceBidirectionalStreamingRPCWithPayload", "MethodBidirectionalStreamingRPCWithPayload", "*servicebidirectionalstreamingrpcwithpayload.Payload", v) - } - if payload.A != nil { - (*md).Append("a", fmt.Sprintf("%v", *payload.A)) - } - if payload.B != nil { - (*md).Append("b", *payload.B) - } - return nil, nil -} -` - -const PayloadWithMetadataRequestEncoderCode = `// EncodeMethodMessageWithMetadataRequest encodes requests sent to -// ServiceMessageWithMetadata MethodMessageWithMetadata endpoint. -func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithmetadata.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithMetadataRequest(payload), nil -} -` - -const PayloadWithValidateRequestEncoderCode = `// EncodeMethodMessageWithValidateRequest encodes requests sent to -// ServiceMessageWithValidate MethodMessageWithValidate endpoint. -func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithvalidate.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithValidateRequest(payload), nil -} -` - -const PayloadWithSecurityAttrsRequestEncoderCode = `// EncodeMethodMessageWithSecurityRequest encodes requests sent to -// ServiceMessageWithSecurity MethodMessageWithSecurity endpoint. -func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithsecurity.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) - } - if payload.Token != nil { - (*md).Append("authorization", *payload.Token) - } - if payload.Key != nil { - (*md).Append("authorization", *payload.Key) - } - if payload.Username != nil { - (*md).Append("username", *payload.Username) - } - if payload.Password != nil { - (*md).Append("password", *payload.Password) - } - return NewProtoMethodMessageWithSecurityRequest(payload), nil -} -` diff --git a/grpc/codegen/testdata/response_encoder_code.go b/grpc/codegen/testdata/response_encoder_code.go deleted file mode 100644 index 770c6fb81c..0000000000 --- a/grpc/codegen/testdata/response_encoder_code.go +++ /dev/null @@ -1,118 +0,0 @@ -package testdata - -const EmptyResultResponseEncoderCode = `// EncodeMethodUnaryRPCNoResultResponse encodes responses from the -// "ServiceUnaryRPCNoResult" service "MethodUnaryRPCNoResult" endpoint. -func EncodeMethodUnaryRPCNoResultResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - resp := NewProtoMethodUnaryRPCNoResultResponse() - return resp, nil -} -` - -const ResultWithViewsResponseEncoderCode = `// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the -// "ServiceMessageResultTypeWithViews" service -// "MethodMessageResultTypeWithViews" endpoint. -func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) - return resp, nil -} -` - -const ResultWithExplicitViewResponseEncoderCode = `// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from -// the "ServiceMessageResultTypeWithExplicitView" service -// "MethodMessageResultTypeWithExplicitView" endpoint. -func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) - return resp, nil -} -` - -const ResultArrayResponseEncoderCode = `// EncodeMethodMessageArrayResponse encodes responses from the -// "ServiceMessageArray" service "MethodMessageArray" endpoint. -func EncodeMethodMessageArrayResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.([]*servicemessagearray.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageArray", "MethodMessageArray", "[]*servicemessagearray.UT", v) - } - resp := NewProtoMethodMessageArrayResponse(result) - return resp, nil -} -` - -const ResultPrimitiveResponseEncoderCode = `// EncodeMethodUnaryRPCNoPayloadResponse encodes responses from the -// "ServiceUnaryRPCNoPayload" service "MethodUnaryRPCNoPayload" endpoint. -func EncodeMethodUnaryRPCNoPayloadResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoPayload", "MethodUnaryRPCNoPayload", "string", v) - } - resp := NewProtoMethodUnaryRPCNoPayloadResponse(result) - return resp, nil -} -` - -const ResultWithMetadataResponseEncoderCode = `// EncodeMethodMessageWithMetadataResponse encodes responses from the -// "ServiceMessageWithMetadata" service "MethodMessageWithMetadata" endpoint. -func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithmetadata.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.ResponseUT", v) - } - resp := NewProtoMethodMessageWithMetadataResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultWithValidateResponseEncoderCode = `// EncodeMethodMessageWithValidateResponse encodes responses from the -// "ServiceMessageWithValidate" service "MethodMessageWithValidate" endpoint. -func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithvalidate.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.ResponseUT", v) - } - resp := NewProtoMethodMessageWithValidateResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultCollectionResponseEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesResponse encodes responses -// from the "ServiceMessageUserTypeWithNestedUserTypes" service -// "MethodMessageUserTypeWithNestedUserTypes" endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(servicemessageusertypewithnestedusertypesviews.RTCollection) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) - return resp, nil -} -` diff --git a/grpc/codegen/testdata/server-no-server.golden b/grpc/codegen/testdata/server-no-server.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-no-server.golden +++ b/grpc/codegen/testdata/server-no-server.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden index e9311a5676..6082624304 100644 --- a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden @@ -28,12 +28,8 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) another_servicepb.RegisterAnotherServiceServer(srv, anotherServiceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") + log.Printf(ctx, "serving gRPC method %s", "another_service.AnotherService/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-service-subset.golden b/grpc/codegen/testdata/server-server-hosting-service-subset.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/server-server-hosting-service-subset.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/streaming_code.go b/grpc/codegen/testdata/streaming_code.go index 0a13907ff6..e2851c732b 100644 --- a/grpc/codegen/testdata/streaming_code.go +++ b/grpc/codegen/testdata/streaming_code.go @@ -1,3 +1,4 @@ +// This file contains expected gRPC stream code used by generator tests. package testdata var ServerStreamingServerStructCode = `// MethodServerStreamingUserTypeRPCServerStream implements the @@ -12,7 +13,7 @@ var ServerStreamingServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.UserType) error { - v := NewProtoUserTypeMethodServerStreamingUserTypeRPCResponse(res) + v := NewProtoMethodServerStreamingUserTypeRPCResponse(res) return s.stream.Send(v) } @@ -65,6 +66,9 @@ var ServerStreamingResultWithViewsServerStructCode = `// MethodServerStreamingUs type MethodServerStreamingUserTypeRPCServerStream struct { stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCServer view string + // sentView is the result view named in the response header. Later sends must + // use the same view. + sentView string } ` @@ -72,8 +76,29 @@ var ServerStreamingResultWithViewsServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { - vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, s.view) - v := NewProtoResultTypeViewMethodServerStreamingUserTypeRPCResponse(vres.Projected) + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } return s.stream.Send(v) } @@ -95,8 +120,9 @@ var ServerStreamingResultWithViewsClientStructCode = `// MethodServerStreamingUs // serviceserverstreamingusertyperpc.MethodServerStreamingUserTypeRPCClientStream // interface. type MethodServerStreamingUserTypeRPCClientStream struct { - stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient - view string + stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient + view string + viewSet bool } ` @@ -109,7 +135,25 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) Recv() (*serviceserverstr if err != nil { return res, err } - proj := NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + var proj *serviceserverstreamingusertyperpcviews.ResultTypeView + switch s.view { + case "tiny": + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v) + case "default", "": + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + } vres := &serviceserverstreamingusertyperpcviews.ResultType{Projected: proj, View: s.view} if err := serviceserverstreamingusertyperpcviews.ValidateResultType(vres); err != nil { return nil, err @@ -129,6 +173,7 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) RecvWithContext(ctx conte var ServerStreamingResultWithViewsClientSetViewCode = `// SetView sets the view. func (s *MethodServerStreamingUserTypeRPCClientStream) SetView(view string) { s.view = view + s.viewSet = true } ` @@ -138,7 +183,7 @@ var ServerStreamingResultCollectionWithExplicitViewServerSendCode = `// Send str // gRPC stream. func (s *MethodServerStreamingResultTypeCollectionWithExplicitViewServerStream) Send(res serviceserverstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { vres := serviceserverstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") - v := NewProtoResultTypeCollectionViewResultTypeCollection(vres.Projected) + v := NewProtoResultTypeCollection(vres.Projected) return s.stream.Send(v) } @@ -429,7 +474,6 @@ var BidirectionalStreamingServerStructCode = `// MethodBidirectionalStreamingRPC // interface. type MethodBidirectionalStreamingRPCServerStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCServer - view string } ` @@ -438,7 +482,7 @@ var BidirectionalStreamingServerSendCode = `// Send streams instances of // to the "MethodBidirectionalStreamingRPC" endpoint gRPC stream. func (s *MethodBidirectionalStreamingRPCServerStream) Send(res *servicebidirectionalstreamingrpc.ID) error { vres := servicebidirectionalstreamingrpc.NewViewedID(res, "default") - v := NewProtoIDViewMethodBidirectionalStreamingRPCResponse(vres.Projected) + v := NewProtoMethodBidirectionalStreamingRPCResponse(vres.Projected) return s.stream.Send(v) } @@ -481,7 +525,6 @@ var BidirectionalStreamingClientStructCode = `// MethodBidirectionalStreamingRPC // interface. type MethodBidirectionalStreamingRPCClientStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient - view string } ` diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index af550f53a5..ff5393a9af 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -27,8 +27,8 @@ func CreateGRPCServices(root *expr.RootExpr) *ServicesData { return createServiceServices(root) } -// createServiceServices performs the complete package declaration lifecycle -// required by transport test helpers. +// createServiceServices chooses every package name and builds the gRPC service +// data required by transport tests. func createServiceServices(root *expr.RootExpr) *ServicesData { return createServiceServicesForPackage(root, "generated.local/gen") } @@ -48,7 +48,39 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *Servic if err != nil { panic(err) } - if err := example.Plan(generation); err != nil { + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return grpcPlans[0].services +} + +// createExamplePlan builds linked gRPC data and copied server data that belong +// to the same service plan. +func createExamplePlan(root *expr.RootExpr, genpkg string) *ExamplePlan { + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { + panic(err) + } + examplePlan, err := example.NewPlan(generation, servicePlan) + if err != nil { + panic(err) + } + examples, err := NewExamplePlan(grpcPlans[0], examplePlan) + if err != nil { panic(err) } if err := generation.Freeze(); err != nil { @@ -60,7 +92,7 @@ func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *Servic if err := grpcPlans[0].Link(); err != nil { panic(err) } - return grpcPlans[0].services + return examples } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index d049ff3c0b..9e33cc8526 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -7,25 +7,22 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the server types files containing all the server -// interfaces and types needed to implement gRPC server. -func ServerTypeFiles(services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(typesFile(svc, services, true), services, svc.GRPCEndpoints...) +// serverTypeFiles returns the planned conversion types used by gRPC servers. +func serverTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, true), services, servicePlan) } return fw } -// ClientTypeFiles returns the client types files containing all the client -// interfaces and types needed to implement gRPC client. -func ClientTypeFiles(services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = addEndpointImports(typesFile(svc, services, false), services, svc.GRPCEndpoints...) +// clientTypeFiles returns the planned conversion types used by gRPC clients. +func clientTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, false), services, servicePlan) } return fw } @@ -33,22 +30,23 @@ func ClientTypeFiles(services *ServicesData) []*codegen.File { // typesFile returns the file defining the gRPC types for the given service. // svr indicates whether the file is generated for the server (true) or the // client (false) package. -func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *codegen.File { +func typesFile(servicePlan *grpcServicePlan, services *ServicesData, svr bool) *codegen.File { + svc := servicePlan.expression var ( initData []*InitData sd = services.Get(svc.Name()) ) { - seen := make(map[string]struct{}) + seen := make(map[*codegen.NameDeclaration]struct{}) collect := func(c *ConvertData) { if c == nil || c.Init == nil { return } - if _, ok := seen[c.Init.Name]; ok { + if _, ok := seen[c.Init.Declaration]; ok { return } - seen[c.Init.Name] = struct{}{} + seen[c.Init.Declaration] = struct{}{} initData = append(initData, c.Init) } for _, a := range svc.GRPCEndpoints { @@ -59,8 +57,14 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod collect(ed.Request.LegacyDecode.ServerConvert) } collect(ed.Response.ServerConvert) + for _, conversion := range ed.Response.ServerConverts { + collect(conversion.Convert) + } if ed.ServerStream != nil { collect(ed.ServerStream.SendConvert) + for _, conversion := range ed.ServerStream.SendConverts { + collect(conversion.Convert) + } collect(ed.ServerStream.RecvConvert) } for _, e := range ed.Errors { @@ -69,8 +73,14 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod } else { collect(ed.Request.ClientConvert) collect(ed.Response.ClientConvert) + for _, conversion := range ed.Response.ClientConverts { + collect(conversion.Convert) + } if ed.ClientStream != nil { collect(ed.ClientStream.RecvConvert) + for _, conversion := range ed.ClientStream.RecvConverts { + collect(conversion.Convert) + } collect(ed.ClientStream.SendConvert) } for _, e := range ed.Errors { @@ -93,18 +103,19 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod ) { svcName := sd.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, side) fpath = filepath.Join(codegen.Gendir, "grpc", svcName, side, "types.go") imports := []*codegen.ImportSpec{ {Path: "unicode/utf8"}, codegen.GoaImport(""), - services.ServiceImport(svc.Name()), - services.PackageImport(path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } if serviceHasViewedResult(sd) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } // Add imports if Any type is used - if usesAnyType(svc.GRPCEndpoints, true) { + if servicePlan.usesAnyInErrors { imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) imports = append(imports, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}) } @@ -127,7 +138,11 @@ func typesFile(svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *cod Data: data, }) } - for _, h := range sd.transformHelpers { + helpers := sd.clientTransformHelpers + if svr { + helpers = sd.serverTransformHelpers + } + for _, h := range helpers { sections = append(sections, &codegen.SectionTemplate{ Name: side + "-transform-helper", Source: grpcTemplates.Read(grpcTransformHelperT), diff --git a/grpc/codegen/view_specialization_test.go b/grpc/codegen/view_specialization_test.go new file mode 100644 index 0000000000..e7dec67321 --- /dev/null +++ b/grpc/codegen/view_specialization_test.go @@ -0,0 +1,127 @@ +// This file checks that generated gRPC code keeps design-selected views in +// source and reads transport metadata only when the caller selects the view. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestUnaryViewedResultSpecialization(t *testing.T) { + t.Run("missing or conflicting headers keep design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithExplicitViewDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.NotContains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, `View: "tiny"`) + assert.NotContains(t, decoder, "switch") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", "tiny")`) + assert.NotContains(t, encoder, `Append("goa-view", vres.View)`) + testutil.AssertGo(t, "testdata/golden/viewed_result_fixed_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithExplicitView").Endpoints[0].Response + require.Len(t, response.ClientConverts, 1) + require.Equal(t, "tiny", response.ClientConverts[0].View) + require.Same(t, response.ClientConverts[0].Convert, response.ClientConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithViewsDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.Contains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, "View: view") + assert.Contains(t, decoder, "switch view") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResultTiny(message)") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResult(message)") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", vres.View)`) + assert.Contains(t, encoder, `return nil, goa.InvalidEnumValueError("view", vres.View`) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithViews").Endpoints[0].Response + require.Len(t, response.ServerConverts, 2) + require.Equal(t, "default", response.ServerConverts[1].View) + require.Same(t, response.ServerConverts[1].Convert, response.ServerConvert) + require.Len(t, response.ClientConverts, 2) + require.Equal(t, "default", response.ClientConverts[1].View) + require.Same(t, response.ClientConverts[1].Convert, response.ClientConvert) + }) +} + +func TestStreamingViewedResultSpecialization(t *testing.T) { + t.Run("design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.NotContains(t, serverStruct, "\n\tview") + assert.NotContains(t, clientStruct, "\n\tview") + stream := services.Get("ServiceServerStreamingResultTypeCollectionWithExplicitView").Endpoints[0].ClientStream + require.Len(t, stream.RecvConverts, 1) + require.Equal(t, "tiny", stream.RecvConverts[0].View) + require.Same(t, stream.RecvConverts[0].Convert, stream.RecvConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.Contains(t, serverStruct, "\n\tview") + assert.Contains(t, clientStruct, "\n\tview") + assert.Contains(t, serverStruct, "sentView string") + assert.Contains(t, clientStruct, "viewSet bool") + send := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-send")) + assert.Contains(t, send, `if view == "" {`) + assert.Contains(t, send, `view = "default"`) + assert.Contains(t, send, `if s.sentView != "" && view != s.sentView`) + assert.Contains(t, send, `SetHeader(metadata.Pairs("goa-view", view))`) + assert.Contains(t, send, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(send, `InvalidEnumValueError("view", view`), + strings.Index(send, `SetHeader(metadata.Pairs("goa-view", view))`), + ) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_stream_send.go.golden", send) + recv := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-recv")) + assert.Contains(t, recv, `s.stream.Header()`) + assert.Contains(t, recv, `goa.MissingFieldError("goa-view", "metadata")`) + assert.Contains(t, recv, "switch s.view") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v)") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v)") + stream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ServerStream + require.Len(t, stream.SendConverts, 2) + require.Equal(t, "default", stream.SendConverts[1].View) + require.Same(t, stream.SendConverts[1].Convert, stream.SendConvert) + clientStream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ClientStream + require.Len(t, clientStream.RecvConverts, 2) + require.Equal(t, "default", clientStream.RecvConverts[1].View) + require.Same(t, clientStream.RecvConverts[1].Convert, clientStream.RecvConvert) + }) +} diff --git a/http/client.go b/http/client.go index 7b447cb156..e117a12908 100644 --- a/http/client.go +++ b/http/client.go @@ -180,7 +180,7 @@ func ErrInvalidURL(svc, m, u string, err error) error { return &ClientError{Name: "invalid_url", Message: msg, Service: svc, Method: m, Err: err} } -// ErrDecodingError is the error returned when the decoder fails to decode the +// ErrDecodingError reports a failure while reading, decoding, or closing a // response body. func ErrDecodingError(svc, m string, err error) error { msg := fmt.Sprintf("failed to decode response body: %s", err) diff --git a/http/codegen/client.go b/http/codegen/client.go index e6d6d90a95..0f0bdc81c8 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -15,17 +15,17 @@ import ( func clientFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { - files = append(files, addEndpointImports(clientFile(svc, data), data, svc.HTTPEndpoints...)) + files = append(files, addPlannedFileImports(clientFile(svc, data), data)) if f := websocketClientFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, httpWebSocketEndpoints(svc)...)) + files = append(files, addPlannedFileImports(f, data)) } if f := sseClientFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, httpSSEEndpoints(svc)...)) + files = append(files, addPlannedFileImports(f, data)) } } for _, svc := range data.Expressions.Services { if f := clientEncodeDecodeFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) + files = append(files, addPlannedFileImports(f, data)) } } return files @@ -37,6 +37,8 @@ func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "client", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s client encoders and decoders", svc.Name(), services.label()) imports := []*codegen.ImportSpec{ {Path: "bytes"}, @@ -53,10 +55,16 @@ func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), + } + for _, endpoint := range data.Endpoints { + if !endpoint.Method.SkipResponseBodyEncodeDecode { + imports = append(imports, &codegen.ImportSpec{Path: "errors"}) + break + } } if serviceHasViewedResult(data, nil) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } for _, e := range data.Endpoints { if e.IsJSONRPC { @@ -79,7 +87,7 @@ func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * if e.RequestEncoderDeclaration != nil && (e.Payload.Ref != "" || e.IsJSONRPC) { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: httpTemplates.Read(requestEncoderT, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), + Source: httpTemplates.Read(requestEncoderT, clientTypeExpressionP, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), FuncMap: map[string]any{ "typeConversionData": typeConversionData, "mapConversionData": mapConversionData, @@ -139,11 +147,13 @@ func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * return &codegen.File{Path: path, SectionTemplates: sections} } -// clientFile returns the client HTTP transport file +// clientFile returns the client HTTP transport file. func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "http", svcName, "client", "client.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s client HTTP transport", svc.Name()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -157,7 +167,13 @@ func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), + } + for _, endpoint := range data.Endpoints { + if endpoint.SSE != nil || endpoint.Method.SkipResponseBodyEncodeDecode { + imports = append(imports, &codegen.ImportSpec{Path: "errors"}) + break + } } sections := []*codegen.SectionTemplate{ codegen.Header(title, "client", imports), @@ -211,6 +227,7 @@ func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File FuncMap: map[string]any{ "isWebSocketEndpoint": IsWebSocketEndpoint, "isSSEEndpoint": IsSSEEndpoint, + "isServerStreamKind": isServerStreamKind, }, }) } diff --git a/http/codegen/client_body_types_test.go b/http/codegen/client_body_types_test.go index 978a76ea94..e9544c6ce8 100644 --- a/http/codegen/client_body_types_test.go +++ b/http/codegen/client_body_types_test.go @@ -14,8 +14,6 @@ import ( ) func TestBodyTypeDecl(t *testing.T) { - const genpkg = "gen" - cases := []struct { Name string DSL func() @@ -36,7 +34,6 @@ func TestBodyTypeDecl(t *testing.T) { } func TestBodyTypeInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -96,12 +93,11 @@ func TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer(t *testing.T } definition := codegen.FormatTestCode(t, "package client\n"+generated.String()) - require.Contains(t, definition, `func NewFetchResultOK(body string) *valuesviews.RequiredViewedPrimitiveView`) + require.Contains(t, definition, `func NewFetchRequiredViewedPrimitiveOK(body string) *valuesviews.RequiredViewedPrimitiveView`) require.Contains(t, definition, "Value: &v,") } func TestClientTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -116,6 +112,7 @@ func TestClientTypes(t *testing.T) { {"client-empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, {"client-with-error-custom-pkg", testdata.WithErrorCustomPkgDSL}, {"client-body-custom-name", testdata.PayloadBodyCustomNameDSL}, + {"client-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"client-path-custom-name", testdata.PayloadPathCustomNameDSL}, {"client-query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"client-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, @@ -138,7 +135,6 @@ func TestClientTypes(t *testing.T) { } func TestClientTypeFiles(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index bcdcb82af0..2c7fa6cad6 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -27,23 +27,47 @@ type commandData struct { ClientInit *codegen.NameDeclaration // Configurer is the WebSocket configuration type accepted by ParseEndpoint. Configurer *codegen.NameDeclaration + // ConfigurerLocal is the exact ParseEndpoint parameter that receives the + // WebSocket or JSON-RPC connection configuration. + ConfigurerLocal *cli.ParserLocalData } -// commandData wraps the common SubcommandData and adds HTTP-specific fields. +// subcommandData wraps the common SubcommandData and adds HTTP-specific fields. type subcommandData struct { *cli.SubcommandData methodName string // MultipartFuncDeclaration supplies the multipart request encoder type name. MultipartFuncDeclaration *codegen.NameDeclaration + // MultipartFuncName is the final multipart request encoder name kept for + // existing plugin templates. + // + // Deprecated: Use MultipartFuncDeclaration.Name() after planning. + MultipartFuncName string // MultipartVarName is the variable that holds the multipart request encoder. MultipartVarName string + // MultipartLocal is the exact ParseEndpoint parameter that receives the + // multipart request encoder. + MultipartLocal *cli.ParserLocalData // StreamFlag is the flag used to identify the file to be streamed when // the endpoint uses SkipRequestBodyEncodeDecode. StreamFlag *cli.FlagData - // BuildStreamPayload is the name of the generated function that builds the - // request data structure that wraps the payload and the file stream for - // endpoints that use SkipRequestBodyEncodeDecode. - BuildStreamPayload *codegen.NameDeclaration + // StreamPointerVar is the exact parser variable passed to the stream payload builder. + StreamPointerVar string + // BuildStreamPayloadDeclaration is the generated function that builds the + // request containing the payload and file stream. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // BuildStreamPayload is the final stream payload helper name kept for + // existing plugin templates. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning. + BuildStreamPayload string +} + +// ClientCLIFiles returns the client HTTP CLI support files. genpkg must match +// the package used to create data. +func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientCLIFiles(data) } // clientCLIFiles builds the client command file read by Plan.Link. @@ -59,7 +83,7 @@ func clientCLIFiles(data *ServicesData) []*codegen.File { sd := data.Get(svc.Name()) if len(sd.Endpoints) > 0 { command := &commandData{ - CommandData: cli.BuildCommandData(sd.Service, sd.ClientPkgName), + CommandData: cli.BuildCommandData(sd.Service), serviceName: sd.Service.Name, NeedDialer: HasWebSocket(sd), JSONRPC: sd.Endpoints[0].IsJSONRPC, @@ -100,7 +124,7 @@ func clientCLIFiles(data *ServicesData) []*codegen.File { func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { flags, buildFunction := buildFlags(sd, e) if buildFunction != nil { - buildFunction.Declaration = e.CLIPayloadDeclaration + buildFunction.Name = e.CLIPayloadDeclaration.Name() } sub := &subcommandData{ @@ -110,10 +134,12 @@ func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { if e.MultipartRequestEncoder != nil { sub.MultipartVarName = e.MultipartRequestEncoder.VarName sub.MultipartFuncDeclaration = e.MultipartRequestEncoder.FuncDeclaration + sub.MultipartFuncName = e.MultipartRequestEncoder.FuncDeclaration.Name() } if e.Method.SkipRequestBodyEncodeDecode { - sub.StreamFlag = streamFlag(sd.Service.Name, e.Method.Name) - sub.BuildStreamPayload = e.BuildStreamPayloadDeclaration + sub.StreamFlag = flags[len(flags)-1] + sub.BuildStreamPayloadDeclaration = e.BuildStreamPayloadDeclaration + sub.BuildStreamPayload = e.BuildStreamPayloadDeclaration.Name() } return sub } @@ -124,6 +150,7 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa genpkg := services.GenPkg() pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) path := filepath.Join(codegen.Gendir, services.dir(), "cli", pkg, "cli.go") + outputPackage := generatedFileOutputPackage(services, path) title := fmt.Sprintf("%s %s client CLI support package", svr.Name, services.label()) specs := []*codegen.ImportSpec{ {Path: "encoding/json"}, @@ -142,22 +169,24 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa if sd == nil { continue } - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + services.dir() + "/" + sd.Service.PathName + "/client", - Name: sd.ClientPkgName, - }) + clientImport := services.PackageImport( + outputPackage, + genpkg+"/"+services.dir()+"/"+sd.Service.PathName+"/client", + ) + specs = append(specs, clientImport) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, services.ServiceImport(svc.Name)) + specs = append(specs, services.ServiceImport(outputPackage, svc.Name)) } } - parser := services.cliParsers[svr] + parser := services.cliParsers[svr.Name] if parser == nil { panic(fmt.Sprintf("HTTP CLI parser names are missing for server %q", svr.Name)) } plannedData := make([]*commandData, len(data)) cliData := make([]*cli.CommandData, len(data)) + var parserLocals []*cli.ParserLocalData for i, command := range data { commandNames := parser.Commands[command.serviceName] if commandNames == nil { @@ -165,8 +194,32 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa } commandCopy := *command commonCommand := *command.CommandData + clientImport := services.PackageImport( + outputPackage, + genpkg+"/"+services.dir()+"/"+services.Get(command.serviceName).Service.PathName+"/client", + ) + commonCommand.PkgName = clientImport.Name + if commonCommand.Interceptors != nil { + interceptors := *commonCommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonCommand.Interceptors = &interceptors + } commonCommand.UsageDeclaration = commandNames.Usage commandCopy.CommandData = &commonCommand + if commandCopy.NeedDialer { + suffix := "Configurer" + use := "websocket configurer" + if commandCopy.JSONRPC { + suffix = "ConfigFn" + use = "JSON-RPC connection configurer" + } + commandCopy.ConfigurerLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + Use: use, + PreferredName: commonCommand.VarName + suffix, + } + parserLocals = append(parserLocals, commandCopy.ConfigurerLocal) + } commandCopy.Subcommands = make([]*subcommandData, len(command.Subcommands)) commonCommand.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) for j, subcommand := range command.Subcommands { @@ -176,14 +229,36 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa } subcommandCopy := *subcommand commonSubcommand := *subcommand.SubcommandData + if commonSubcommand.Interceptors != nil { + interceptors := *commonSubcommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonSubcommand.Interceptors = &interceptors + } commonSubcommand.UsageDeclaration = usage subcommandCopy.SubcommandData = &commonSubcommand + if subcommandCopy.MultipartVarName != "" { + subcommandCopy.MultipartLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + MethodName: subcommand.methodName, + Use: "multipart request encoder", + PreferredName: subcommandCopy.MultipartVarName, + } + parserLocals = append(parserLocals, subcommandCopy.MultipartLocal) + } commandCopy.Subcommands[j] = &subcommandCopy commonCommand.Subcommands[j] = &commonSubcommand } plannedData[i] = &commandCopy cliData[i] = &commonCommand } + parser.PlanVariables(cliData, parserLocals) + for _, command := range plannedData { + for _, subcommand := range command.Subcommands { + if subcommand.StreamFlag != nil { + subcommand.StreamPointerVar = subcommand.StreamFlag.PointerVar + } + } + } parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint", @@ -192,14 +267,16 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa Declaration *codegen.NameDeclaration FlagsCode string Commands []*commandData + Variables *cli.ParserVariablesData }{ parser.Declarations.ParseEndpoint, - cli.FlagsCode(cliData), + parser.FlagsCode(cliData), plannedData, + parser.Variables, }, FuncMap: map[string]any{"streamingCmdExists": streamingCmdExists}, } - return cli.EndpointParserFile(path, title, specs, cliData, parser.Declarations, parseSection) + return parser.EndpointParserFile(path, title, specs, cliData, parseSection) } // payloadBuilders returns the file that contains the payload constructors that @@ -207,6 +284,7 @@ func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandDa func payloadBuilders(svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { sd := services.Get(svc.Name()) path := filepath.Join(codegen.Gendir, services.dir(), sd.Service.PathName, "client", "cli.go") + outputPackage := generatedFileOutputPackage(services, path) title := fmt.Sprintf("%s %s client CLI support package", svc.Name(), services.label()) specs := []*codegen.ImportSpec{ {Path: "encoding/json"}, @@ -217,9 +295,9 @@ func payloadBuilders(svc *expr.HTTPServiceExpr, data *cli.CommandData, services {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } - return addEndpointImports(cli.PayloadBuildersFile(path, title, specs, data), services, svc.HTTPEndpoints...) + return addPlannedFileImports(cli.PayloadBuildersFile(path, title, specs, data), services) } // buildFlags builds the flag data and build function for an endpoint. @@ -236,7 +314,7 @@ func buildFlags(svc *ServiceData, e *EndpointData) ([]*cli.FlagData, *cli.BuildF args = append(args, e.Payload.Request.PayloadInit.CLIArgs...) flags, buildFunction = makeFlags(e, args, e.Payload.Request.PayloadType) } else if e.Payload.Ref != "" { - flags = append(flags, cli.NewFlagData(svcn, en, "p", e.Method.PayloadRef, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) + flags = append(flags, cli.NewFlagDataForPlan(svcn, en, "p", e.Payload.CLIPlan, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) } if e.Method.SkipRequestBodyEncodeDecode { flags = append(flags, streamFlag(svcn, en)) @@ -261,13 +339,13 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* fargs[i] = &cli.FlagArgData{ Name: arg.VarName, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, OmitField: arg.FieldName == "" && arg.VarName != "body", } } @@ -288,7 +366,8 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* // streamFlag returns the flag used to specify the upload file for endpoints // that use SkipRequestBodyEncodeDecode. func streamFlag(svcn, en string) *cli.FlagData { - return cli.NewFlagData(svcn, en, "stream", "string", "path to file containing the streamed request body", true, "goa.png", nil) + plan := cli.NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + return cli.NewFlagDataForPlan(svcn, en, "stream", plan, "path to file containing the streamed request body", true, "goa.png", nil) } // streamingCmdExists returns true if at least one command in the list of commands diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index e3597e7a22..c70964c30b 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" @@ -65,6 +66,35 @@ func TestClientCLIFiles(t *testing.T) { } } +// TestClientCLIBuildNameMatchesDeclaration verifies released plugins can read +// the final payload builder name without choosing that name themselves. +func TestClientCLIBuildNameMatchesDeclaration(t *testing.T) { + root := expr.RunDSL(t, testdata.MultiSimpleDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + endpoint := plan.services.Get("ServiceMultiSimple1").Endpoint("MethodMultiSimplePayload") + require.Equal(t, endpoint.CLIPayloadDeclaration.Name(), build.Name) +} + +// TestClientCLITransportNamesMatchDeclarations checks the released multipart +// and stream helper names exposed to plugin templates. +func TestClientCLITransportNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + + multipart := buildSubcommandData(service, service.Endpoint("Multipart")) + require.NotNil(t, multipart.MultipartFuncDeclaration) + require.Equal(t, multipart.MultipartFuncDeclaration.Name(), multipart.MultipartFuncName) + + stream := buildSubcommandData(service, service.Endpoint("Raw")) + require.NotNil(t, stream.BuildStreamPayloadDeclaration) + require.Equal(t, stream.BuildStreamPayloadDeclaration.Name(), stream.BuildStreamPayload) +} + func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveFieldEmptyDSL) plan := linkedHTTPPlanForRoot(t, root) @@ -76,3 +106,23 @@ func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { require.IsType(t, []string{}, example) require.NotEmpty(t, example) } + +// TestClientCLINestedBodyWithoutValidationEmitsNoChecks verifies that a body +// with no top-level checks does not add validation to the payload builder. +func TestClientCLINestedBodyWithoutValidationEmitsNoChecks(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.NotEmpty(t, files) + service := plan.services.Get("ServiceBodyUserInner") + endpoint := service.Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + arg := endpoint.Payload.Request.PayloadInit.ClientArgs[0] + require.Empty(t, arg.Validate) + require.NotNil(t, arg.CLIPlan) + _, builder := buildFlags(service, endpoint) + require.NotNil(t, builder) + require.Len(t, builder.Fields, 1) + require.NotContains(t, builder.Fields[0].Init, "goa.") +} diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index 021735a333..954a9cf501 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -34,6 +34,8 @@ func TestClientDecode(t *testing.T) { {"with-headers-dsl-viewed-result", testdata.WithHeadersBlockViewedResultDSL}, {"validate-error-response-type", testdata.ValidateErrorResponseTypeDSL}, {"empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, + {"required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"skip-response-body-encode-decode", testdata.ServerSkipResponseBodyEncodeDecodeDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { @@ -42,8 +44,14 @@ func TestClientDecode(t *testing.T) { fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates - require.Greater(t, len(sections), 2) - code := codegen.SectionCode(t, sections[2]) + var section *codegen.SectionTemplate + for _, s := range sections { + if s.Name == "response-decoder" { + section = s + } + } + require.NotNil(t, section) + code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_decode_"+c.Name+".go.golden", code) }) } diff --git a/http/codegen/client_encode_test.go b/http/codegen/client_encode_test.go index 3db0ef495e..453c50f036 100644 --- a/http/codegen/client_encode_test.go +++ b/http/codegen/client_encode_test.go @@ -177,6 +177,7 @@ func TestClientEncode(t *testing.T) { {"query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, + {"skip-request-body-header", testdata.SkipRequestBodyEncodeDecodeHeaderDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { @@ -192,6 +193,25 @@ func TestClientEncode(t *testing.T) { } } +// TestSkipRequestBodyEncoderSelection verifies raw body access keeps encoders +// needed for headers while omitting an encoder that would do no work. +func TestSkipRequestBodyEncoderSelection(t *testing.T) { + t.Run("mapped header", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeHeaderDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecodeHeader")) + require.True(t, ok) + require.NotNil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) + t.Run("raw body only", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecode")) + require.True(t, ok) + require.Nil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) +} + func TestClientBuildRequest(t *testing.T) { cases := []struct { Name string diff --git a/http/codegen/client_query_float_runtime_test.go b/http/codegen/client_query_float_runtime_test.go new file mode 100644 index 0000000000..69eb58b2c1 --- /dev/null +++ b/http/codegen/client_query_float_runtime_test.go @@ -0,0 +1,141 @@ +// This file runs generated HTTP client query encoders and checks the exact +// values that a server receives after URL parsing. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientFormatsFloatQueriesCompactly catches fixed-point query +// formatting that expands values which have a shorter exponent form. +func TestGeneratedClientFormatsFloatQueriesCompactly(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("float_query", func() { + dsl.Method("format", func() { + dsl.Payload(func() { + dsl.Attribute("scalar32", dsl.Float32) + dsl.Attribute("scalar64", dsl.Float64) + dsl.Attribute("repeated32", dsl.ArrayOf(dsl.Float32)) + dsl.Attribute("repeated64", dsl.ArrayOf(dsl.Float64)) + dsl.Required("scalar32", "scalar64", "repeated32", "repeated64") + }) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Param("scalar32") + dsl.Param("scalar64") + dsl.Param("repeated32") + dsl.Param("repeated64") + }) + }) + }) + }) + + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedFloatQueryTest(t, files) +} + +// runGeneratedFloatQueryTest writes the generated packages and a test in the +// generated client package, then runs that test in an isolated module. +func runGeneratedFloatQueryTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + goaRoot := floatQueryModuleDirectory(t) + module := "module generated.local\n\ngo 1.24\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "float_query", "client", "float_query_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedFloatQueryTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/float_query/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated float query test:\n%s", output) +} + +// floatQueryModuleDirectory returns this Goa checkout so the temporary module +// tests the generated code against the same runtime as the generator. +func floatQueryModuleDirectory(t *testing.T) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve Goa module:\n%s", output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} + +const generatedFloatQueryTest = `package client + +import ( + "net/http" + "testing" + + genfloatquery "generated.local/gen/float_query" +) + +func TestFloatQueryValues(t *testing.T) { + payload := &genfloatquery.FormatPayload{ + Scalar32: 12.5, + Scalar64: 1e100, + Repeated32: []float32{1e20, 0.25}, + Repeated64: []float64{1e100, 0.25}, + } + request, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + if err := EncodeFormatRequest(nil)(request, payload); err != nil { + t.Fatal(err) + } + query := request.URL.Query() + if got := query.Get("scalar32"); got != "12.5" { + t.Fatalf("scalar32 query = %q, want %q", got, "12.5") + } + if got := query.Get("scalar64"); got != "1e+100" { + t.Fatalf("scalar64 query = %q, want %q", got, "1e+100") + } + if got := query["repeated32"]; len(got) != 2 || got[0] != "1e+20" || got[1] != "0.25" { + t.Fatalf("repeated32 query = %#v, want %#v", got, []string{"1e+20", "0.25"}) + } + if got := query["repeated64"]; len(got) != 2 || got[0] != "1e+100" || got[1] != "0.25" { + t.Fatalf("repeated64 query = %#v, want %#v", got, []string{"1e+100", "0.25"}) + } +} +` diff --git a/http/codegen/client_response_body_runtime_test.go b/http/codegen/client_response_body_runtime_test.go new file mode 100644 index 0000000000..b85fe039a2 --- /dev/null +++ b/http/codegen/client_response_body_runtime_test.go @@ -0,0 +1,311 @@ +// This file renders an HTTP client into a temporary module and calls its +// generated endpoints and response decoders. The response bodies can fail +// while reading or closing so the tests can check every returned error. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientResponseBodyLifecycle checks that generated decoders +// close bodies they consume, preserve bodies requested by callers, and return +// every read, decode, and close error. +func TestGeneratedClientResponseBodyLifecycle(t *testing.T) { + root := expr.RunDSL(t, responseBodyLifecycleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + clientFiles := httpPlans[0].ClientFiles() + endpointCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) + testutil.AssertGo(t, "testdata/golden/client_endpoint_response_body_lifecycle.go.golden", endpointCode) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := append(serviceFiles, clientFiles...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedResponseBodyLifecycleTest(t, files) +} + +// responseBodyLifecycleDSL defines an ordinary response, a response whose +// bytes are returned to the caller, and a server-sent event stream. +func responseBodyLifecycleDSL() { + dsl.Service("body_lifecycle", func() { + dsl.Method("read", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/read") + }) + }) + dsl.Method("raw", func() { + dsl.Error("bad", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.HTTP(func() { + dsl.GET("/raw") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK) + dsl.Response("bad", dsl.StatusBadRequest) + }) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// runGeneratedResponseBodyLifecycleTest writes generated code and its runtime +// test into an isolated module, then runs only the generated client package. +func runGeneratedResponseBodyLifecycleTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "body_lifecycle", "client", "response_body_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedResponseBodyLifecycleTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/body_lifecycle/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated response body test:\n%s", output) +} + +const generatedResponseBodyLifecycleTest = `package client + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (doer doerFunc) Do(request *http.Request) (*http.Response, error) { + return doer(request) +} + +type controlledBody struct { + reader io.Reader + readErr error + closeErr error + closeCalls int +} + +func (body *controlledBody) Read(buffer []byte) (int, error) { + if body.readErr != nil { + return 0, body.readErr + } + return body.reader.Read(buffer) +} + +func (body *controlledBody) Close() error { + body.closeCalls++ + return body.closeErr +} + +func TestRestoreBodyReturnsReadFailureAndClosesOriginal(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusOK, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsCloseFailureAfterSuccess(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader(` + "`" + `{"value":"ready"}` + "`" + `), closeErr: closeErr} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + require.NotNil(t, result) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + response := response(http.StatusOK, body) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + + _, err := DecodeReadResponse(decoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRestoreBodyLeavesReadableCopyAndClosesOriginal(t *testing.T) { + const encoded = ` + "`" + `{"value":"ready"}` + "`" + ` + body := &controlledBody{reader: strings.NewReader(encoded)} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, body.closeCalls) + restored, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, encoded, string(restored)) +} + +func TestUnexpectedStatusReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusTeapot, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRawBodyRemainsCallerOwned(t *testing.T) { + for _, restoreBody := range []bool{false, true} { + t.Run(fmt.Sprintf("restoreBody=%t", restoreBody), func(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("raw bytes")} + response := response(http.StatusOK, body) + + _, err := DecodeRawResponse(goahttp.ResponseDecoder, restoreBody)(response) + + require.NoError(t, err) + require.Same(t, body, response.Body) + require.Zero(t, body.closeCalls) + content, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "raw bytes", string(content)) + }) + } +} + +func TestStreamContentTypeReturnsCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func TestStreamContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("ignored")} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRawEndpointReturnsDecoderAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusBadRequest, body), nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Raw()(context.Background(), nil) + + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func response(status int, body io.ReadCloser) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: body} +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` diff --git a/http/codegen/compatibility.go b/http/codegen/compatibility.go new file mode 100644 index 0000000000..7f26895443 --- /dev/null +++ b/http/codegen/compatibility.go @@ -0,0 +1,72 @@ +// This file keeps released HTTP generator entry points available to plugins +// while all rendering uses the one transport plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create data. +func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientFiles(data) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create data. +func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverFiles(data) +} + +// ServerTypeFiles returns the planned server type files. genpkg must match the +// package used to create data. +func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverTypeFiles(data) +} + +// ClientTypeFiles returns the planned client type files. genpkg must match the +// package used to create data. +func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientTypeFiles(data) +} + +// PathFiles returns the planned request path files. +func PathFiles(data *ServicesData) []*codegen.File { + return pathFiles(data) +} + +// ClientEncodeDecodeFile returns the planned client encoder and decoder file +// for service. genpkg must match the package used to create data. +func ClientEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return clientEncodeDecodeFile(service, data) +} + +// ServerEncodeDecodeFile returns the planned server encoder and decoder file +// for service. genpkg must match the package used to create data. +func ServerEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return serverEncodeDecodeFile(service, data) +} + +// WebsocketClientFile returns the planned WebSocket client file for service. +// genpkg must match the package used to create data. +func WebsocketClientFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return websocketClientFile(service, data) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the HTTP data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, data *ServicesData) { + if genpkg != data.GenPkg() { + panic(fmt.Sprintf("HTTP generation package %q does not match planned package %q", genpkg, data.GenPkg())) + } +} diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 0071c0a047..91ec5906eb 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -38,9 +38,8 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("openapi uses cookie security scheme", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - openapi.Definitions = make(map[string]*openapi.Schema) - v2Files, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) + v2Files, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err) v2JSON := renderOpenAPIJSON(t, v2Files) var swagger openapi2.T @@ -57,8 +56,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.Contains(t, (*swagger.Paths["/auth/profile"].Get.Security)[0], name) } - openapi.Definitions = make(map[string]*openapi.Schema) - v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30, expr.NewExampleGenerator(root.API.RandomizerFactory))) + v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30)) loader := openapi3.NewLoader() doc, err := loader.LoadFromData(v3JSON) require.NoError(t, err) diff --git a/http/codegen/error_body_description_test.go b/http/codegen/error_body_description_test.go new file mode 100644 index 0000000000..8b63bce767 --- /dev/null +++ b/http/codegen/error_body_description_test.go @@ -0,0 +1,44 @@ +// This file verifies generated HTTP error body comments name the service +// errors that use each body type. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestErrorBodyDescriptionNamesSingleError(t *testing.T) { + root := expr.RunDSL(t, testdata.WithErrorCustomPkgDSL) + plan := linkedHTTPPlanForRoot(t, root) + want := "MethodWithErrorCustomPkgErrorNameResponseBody is the type of the\n" + + "// \"ServiceWithErrorCustomPkg\" service \"MethodWithErrorCustomPkg\" endpoint HTTP\n" + + "// response body for the \"error_name\" error." + + for _, file := range []struct { + name string + sections string + }{ + {name: "client", sections: renderHTTPSections(t, plan.ClientTypeFiles()[0])}, + {name: "server", sections: renderHTTPSections(t, plan.ServerTypeFiles()[0])}, + } { + t.Run(file.name, func(t *testing.T) { + require.Contains(t, file.sections, want) + }) + } +} + +// renderHTTPSections writes all generated sections after the file header. +func renderHTTPSections(t *testing.T, file *codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index ec0e02f674..90a69f51fb 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -1,24 +1,23 @@ -// This file renders runnable HTTP and JSON-RPC client examples whose generated -// CLI, service, application, and interceptor imports use the qualifiers -// selected during planning. +// This file writes runnable HTTP and JSON-RPC command-line examples with the +// package names already chosen for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) // exampleCLIFiles returns an example command-line client for the HTTP services // on each configured server. -func exampleCLIFiles(services *ServicesData) []*codegen.File { +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := exampleCLI(svr, services); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(root, server, services); f != nil { files = append(files, f) } } @@ -27,28 +26,26 @@ func exampleCLIFiles(services *ServicesData) []*codegen.File { // exampleCLI returns an example command-line client for the HTTP services on // the given server. -func exampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { +func exampleCLI(root *example.Root, server *example.Data, services *ServicesData) *codegen.File { genpkg := services.GenPkg() - svrdata := example.Servers.Get(svr, services.Root) - outputPath := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") - if _, err := os.Stat(outputPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } + outputPath := filepath.Join("cmd", server.Dir+"-cli", services.dir()+".go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir+"-cli") funcSuffix := "HTTP" if services.jsonrpc { funcSuffix = "JSONRPC" } rootPath := path.Dir(genpkg) - cliImport := services.PackageImport(path.Join(genpkg, services.dir(), "cli", svrdata.Dir)) - parser := services.cliParsers[svr] + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, services.dir(), "cli", server.Dir)) + parser := services.cliParsers[server.Name] if parser == nil { - panic("HTTP command parser names are missing for server " + svr.Name) + panic("HTTP command parser names are missing for server " + server.Name) } specs := []*codegen.ImportSpec{ {Path: "context"}, - {Path: "encoding/json"}, + {Path: "errors"}, {Path: "flag"}, {Path: "fmt"}, + {Path: "io"}, {Path: "net/http"}, {Path: "net/url"}, {Path: "os"}, @@ -60,26 +57,26 @@ func exampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { cliImport, } hasClientInterceptors := false - for _, svc := range services.Root.Services { - data := services.ServicesData.Get(svc.Name) - serviceImport := services.ServiceImport(svc.Name) + for _, name := range root.Services { + data := services.ServicesData.Get(name) + serviceImport := services.ServiceImport(outputPackage, name) specs = append(specs, serviceImport) hasClientInterceptors = hasClientInterceptors || len(data.ClientInterceptors) > 0 } var interceptorsPkg string if hasClientInterceptors { - interceptorImport := services.PackageImport(rootPath + "/interceptors") + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") interceptorsPkg = interceptorImport.Name specs = append(specs, interceptorImport) } - apiImport := services.PackageImport(rootPath) + apiImport := services.PackageImport(outputPackage, rootPath) apiPkg := apiImport.Name specs = append(specs, apiImport) var svcData []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + svcData = append(svcData, exampleServiceDataForOutput(data, services, outputPackage)) } } sections := []*codegen.SectionTemplate{ @@ -107,14 +104,22 @@ func exampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { Name: "cli-http-end", Source: httpTemplates.Read(cliEndT), Data: map[string]any{ - "Services": svcData, - "APIPkg": apiPkg, - "CLIPkg": cliImport.Name, - "Parser": parser.Declarations, + "Services": svcData, + "APIPkg": apiPkg, + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, + "Transport": services.label(), }, FuncMap: map[string]any{ - "needDialer": NeedDialer, - "hasWebSocket": HasWebSocket, + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "needDialer": NeedDialer, + "hasWebSocket": HasWebSocket, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, }, }, { @@ -133,3 +138,59 @@ func exampleCLI(svr *expr.ServerExpr, services *ServicesData) *codegen.File { SkipExist: true, } } + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 15f21edb37..dc171c8bd4 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/http/codegen/testdata" @@ -25,14 +24,14 @@ func TestExampleCLIFiles(t *testing.T) { {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL}, {"streaming", testdata.StreamingResultDSL}, {"streaming-multiple-services", testdata.StreamingMultipleServicesDSL}, + {"streaming-input-only", testdata.StreamingPayloadDSL}, + {"mixed-results", testdata.MixedResultsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - plan := linkedHTTPPlanForRoot(t, root) - fs := plan.ExampleCLIFiles() + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.CLIFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -45,3 +44,22 @@ func TestExampleCLIFiles(t *testing.T) { }) } } + +func TestExampleCLIUsesServicePathsForCommands(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.CLIFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstCommand := codegen.KebabCase(first.PathName) + secondCommand := codegen.KebabCase(second.PathName) + require.NotEqual(t, firstCommand, secondCommand) + require.Contains(t, output.String(), `case "`+firstCommand+`":`) + require.Contains(t, output.String(), `case "`+secondCommand+`":`) +} diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 7ce8b94807..b4f2bf5c35 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,11 +1,9 @@ -// This file renders runnable HTTP servers and multipart helpers. Each example -// file imports relocated types and generated service packages with the -// qualifiers selected during planning. +// This file writes runnable HTTP servers and file-upload helpers with the +// package names already chosen for this generation. package codegen import ( "maps" - "os" "path" "path/filepath" @@ -14,11 +12,35 @@ import ( "goa.design/goa/v3/expr" ) -// exampleServerFiles builds each runnable HTTP server read by Plan.Link. -func exampleServerFiles(data *ServicesData) []*codegen.File { +type ( + // exampleServerArgumentData contains one typed parameter accepted by a + // generated transport helper. + exampleServerArgumentData struct { + // Name is the parameter name used inside the helper. + Name string + // PkgName is the generated service package name. + PkgName string + // TypeName is the generated service or endpoint type name. + TypeName string + // Pointer is true when TypeName is an endpoint collection pointer. + Pointer bool + } + + // exampleMultipartDecoderData describes the HTTP request body filled by one + // starter multipart decoder. + exampleMultipartDecoderData struct { + *MultipartData + // BodyType is the request body type as seen from the starter service + // package. + BodyType string + } +) + +// exampleServerFiles builds each runnable HTTP server from copied server data. +func exampleServerFiles(root *example.Root, data *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := exampleServer(data.Root, svr, data); m != nil { + for _, server := range root.Servers { + if m := exampleServer(server, data); m != nil { fw = append(fw, m) } } @@ -31,11 +53,11 @@ func exampleServerFiles(data *ServicesData) []*codegen.File { } // exampleServer returns an example HTTP server implementation. -func exampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { +func exampleServer(server *example.Data, services *ServicesData) *codegen.File { genpkg := services.GenPkg() - svrdata := example.Servers.Get(svr, root) - fpath := filepath.Join("cmd", svrdata.Dir, "http.go") - specs := make([]*codegen.ImportSpec, 0, 12+2*len(root.API.HTTP.Services)) + fpath := filepath.Join("cmd", server.Dir, "http.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) + specs := make([]*codegen.ImportSpec, 0, 12+2*len(services.Expressions.Services)) baseSpecs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "net/http"}, @@ -51,23 +73,31 @@ func exampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services } specs = append(specs, baseSpecs...) - for _, svc := range root.API.HTTP.Services { - sd := services.Get(svc.Name()) + for _, serviceName := range server.Services { + sd := services.Get(serviceName) + if sd == nil { + continue + } svcName := sd.Service.PathName - serverImport := services.PackageImport(path.Join(genpkg, "http", svcName, "server")) - serviceImport := services.ServiceImport(svc.Name()) + serverImport := services.PackageImport(outputPackage, path.Join(genpkg, "http", svcName, "server")) + serviceImport := services.ServiceImport(outputPackage, serviceName) specs = append(specs, serverImport, serviceImport) } rootPath := path.Dir(genpkg) - apiImport := services.PackageImport(rootPath) + apiImport := services.PackageImport(outputPackage, rootPath) apiPkg := apiImport.Name specs = append(specs, apiImport) var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + copy := exampleServiceDataForOutput(data, services, outputPackage) + copy.ServerPkgName = services.PackageImport( + outputPackage, + path.Join(genpkg, "http", data.Service.PathName, "server"), + ).Name + svcdata = append(svcdata, copy) } } @@ -77,7 +107,8 @@ func exampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: map[string]any{ - "Services": svcdata, + "Services": svcdata, + "HandlerArgs": exampleServerArguments(server, svcdata, nil), // JSONRPCServices must always be set (typed nil when // absent) so the template functions receive a valid // []*ServiceData value. The JSON-RPC generator @@ -127,11 +158,10 @@ func exampleServer(root *expr.RootExpr, svr *expr.ServerExpr, services *Services // combinedExampleServerFiles builds runnable server files that mount both the // JSON-RPC services and the ordinary HTTP services from one design. The caller // may edit every returned file without changing either input plan. -func combinedExampleServerFiles(jsonrpc, application *ServicesData) []*codegen.File { - root := jsonrpc.Root - files := make([]*codegen.File, 0, len(root.API.Servers)) - for _, server := range root.API.Servers { - file := combinedExampleServer(root, server, jsonrpc, application) +func combinedExampleServerFiles(root *example.Root, jsonrpc, application *ServicesData) []*codegen.File { + files := make([]*codegen.File, 0, len(root.Servers)) + for _, server := range root.Servers { + file := combinedExampleServer(server, jsonrpc, application) if file != nil { files = append(files, file) } @@ -149,8 +179,8 @@ func combinedExampleServerFiles(jsonrpc, application *ServicesData) []*codegen.F // combinedExampleServer builds one main-package file for a configured server. // It reads service membership from server and writes separate HTTP and // JSON-RPC lists because the code that writes main initializes them differently. -func combinedExampleServer(root *expr.RootExpr, server *expr.ServerExpr, jsonrpc, application *ServicesData) *codegen.File { - serverData := example.Servers.Get(server, root) +func combinedExampleServer(server *example.Data, jsonrpc, application *ServicesData) *codegen.File { + outputPackage := path.Join(path.Dir(jsonrpc.GenPkg()), "cmd", server.Dir) imports := []*codegen.ImportSpec{ {Path: "context"}, {Path: "net/http"}, @@ -171,10 +201,15 @@ func combinedExampleServer(root *expr.RootExpr, server *expr.ServerExpr, jsonrpc if data == nil { continue } - ordinaryServices = append(ordinaryServices, data) + copy := exampleServiceDataForOutput(data, application, outputPackage) + copy.ServerPkgName = application.PackageImport( + outputPackage, + path.Join(application.GenPkg(), "http", data.Service.PathName, "server"), + ).Name + ordinaryServices = append(ordinaryServices, copy) imports = append(imports, - application.PackageImport(path.Join(application.GenPkg(), "http", data.Service.PathName, "server")), - application.ServiceImport(name), + application.PackageImport(outputPackage, path.Join(application.GenPkg(), "http", data.Service.PathName, "server")), + application.ServiceImport(outputPackage, name), ) } } @@ -184,21 +219,27 @@ func combinedExampleServer(root *expr.RootExpr, server *expr.ServerExpr, jsonrpc if data == nil { continue } - jsonrpcServices = append(jsonrpcServices, data) + copy := exampleServiceDataForOutput(data, jsonrpc, outputPackage) + copy.ServerPkgName = jsonrpc.PackageImport( + outputPackage, + path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server"), + ).Name + jsonrpcServices = append(jsonrpcServices, copy) imports = append(imports, - jsonrpc.PackageImport(path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server")), - jsonrpc.ServiceImport(name), + jsonrpc.PackageImport(outputPackage, path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server")), + jsonrpc.ServiceImport(outputPackage, name), ) } if len(ordinaryServices) == 0 && len(jsonrpcServices) == 0 { return nil } - apiImport := jsonrpc.PackageImport(path.Dir(jsonrpc.GenPkg())) + apiImport := jsonrpc.PackageImport(outputPackage, path.Dir(jsonrpc.GenPkg())) imports = append(imports, apiImport) imports = uniqueExampleImports(imports) data := map[string]any{ "Services": ordinaryServices, "JSONRPCServices": jsonrpcServices, + "HandlerArgs": exampleServerArguments(server, ordinaryServices, jsonrpcServices), } sections := []*codegen.SectionTemplate{ codegen.Header("", "main", imports), @@ -220,12 +261,45 @@ func combinedExampleServer(root *expr.RootExpr, server *expr.ServerExpr, jsonrpc {Name: "server-http-errorhandler", Source: httpTemplates.Read(serverErrorHandlerT)}, } return &codegen.File{ - Path: filepath.Join("cmd", serverData.Dir, "http.go"), + Path: filepath.Join("cmd", server.Dir, "http.go"), SectionTemplates: sections, SkipExist: true, } } +// exampleServerArguments adds generated Go names and types to the ordered +// service values copied by the shared example plan. +func exampleServerArguments( + server *example.Data, + ordinary, jsonrpc []*ServiceData, +) []*exampleServerArgumentData { + services := make(map[string]*ServiceData, len(ordinary)+len(jsonrpc)) + for _, service := range ordinary { + services[service.Service.Name] = service + } + for _, service := range jsonrpc { + services[service.Service.Name] = service + } + planned := server.HandlerArgs(example.TransportHTTP) + arguments := make([]*exampleServerArgumentData, len(planned)) + for index, argument := range planned { + service := services[argument.Service].Service + data := &exampleServerArgumentData{ + PkgName: service.PkgName, + } + if argument.Endpoint { + data.Name = service.VarName + "Endpoints" + data.TypeName = service.EndpointsDeclaration.Name() + data.Pointer = true + } else { + data.Name = service.VarName + "Svc" + data.TypeName = service.ServiceDeclaration.Name() + } + arguments[index] = data + } + return arguments +} + // uniqueExampleImports keeps the first import for each Go package path. A // service exposed over both protocols uses the same generated service package. func uniqueExampleImports(imports []*codegen.ImportSpec) []*codegen.ImportSpec { @@ -297,12 +371,11 @@ func cloneJSONRPCCodecFile(source *codegen.File) *codegen.File { func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { genpkg := services.GenPkg() mpath := "multipart.go" - if _, err := os.Stat(mpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } + outputPackage := path.Dir(genpkg) var ( - sections []*codegen.SectionTemplate - mustGen bool + sections []*codegen.SectionTemplate + decoderData = make(map[*MultipartData]*exampleMultipartDecoderData) + mustGen bool ) { specs := make([]*codegen.ImportSpec, 0, 2) @@ -314,11 +387,24 @@ func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *code multipartEndpoints = append(multipartEndpoints, svc.Endpoint(endpoint.Method.Name)) } } - specs = append(specs, services.ServiceImport(svc.Name())) + specs = append(specs, services.ServiceImport(outputPackage, svc.Name())) rootPath := path.Dir(genpkg) specs = append(specs, services.AttributeImports(rootPath, serviceReferenceAttributes(multipartEndpoints...)...)...) + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder == nil { + continue + } + bodyType, bodyImport := exampleMultipartBodyType(svc, endpoint, services, outputPackage) + if bodyImport != nil { + specs = append(specs, bodyImport) + } + decoderData[endpoint.MultipartRequestDecoder] = &exampleMultipartDecoderData{ + MultipartData: endpoint.MultipartRequestDecoder, + BodyType: bodyType, + } + } - apiPkg := services.PackageImport(rootPath).Name + apiPkg := examplePackageImportName(services.Root) sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} for _, e := range data.Endpoints { if e.MultipartRequestDecoder != nil { @@ -326,7 +412,7 @@ func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *code sections = append(sections, &codegen.SectionTemplate{ Name: "dummy-multipart-request-decoder", Source: httpTemplates.Read(dummyMultipartRequestDecoderT), - Data: e.MultipartRequestDecoder, + Data: decoderData[e.MultipartRequestDecoder], }) } if e.MultipartRequestEncoder != nil { @@ -348,3 +434,31 @@ func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *code SkipExist: true, } } + +// exampleMultipartBodyType returns the request body type visible from the +// starter service package and the generated server import needed to name it. +func exampleMultipartBodyType(svc *expr.HTTPServiceExpr, endpoint *EndpointData, services *ServicesData, outputPackage string) (string, *codegen.ImportSpec) { + serverBody := endpoint.Payload.Request.ServerBody + service := services.Get(svc.Name()) + serverPath := path.Join(services.GenPkg(), "http", service.Service.PathName, "server") + serverImport := services.PackageImport(outputPackage, serverPath) + if serverBody.Declaration != nil { + return serverImport.Name + "." + serverBody.Declaration.Name(), serverImport + } + body := serverBody.attribute + usesServerType := false + collectUserTypes(body.Type, func(expr.UserType) { + usesServerType = true + }) + if usesServerType { + resolver := &wireAttributeScope{ + catalog: service.serverWireTypes, + base: codegen.NewAttributeScope(service.serverWireTypes.scope), + pkg: serverImport.Name, + policy: jsonBodyPolicy(true, true, true, ""), + exactOccurrence: true, + } + return resolver.Name(body, serverImport.Name, true, false), serverImport + } + return serverBody.VarName, nil +} diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index 8a41d3b257..8c2f1c97e4 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -10,9 +10,9 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/testutil" + dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,12 +31,10 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) - plan := linkedHTTPPlanForRoot(t, root) - fs := plan.ExampleServerFiles() + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 2) for i, f := range fs { if i < len(fs)-1 { @@ -55,6 +53,38 @@ func TestExampleServerFiles(t *testing.T) { } }) + t.Run("multipart code check", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"object", testdata.PayloadMultipartValidationDSL}, + {"array", testdata.PayloadMultipartArrayTypeDSL}, + {"map", testdata.PayloadMultipartMapTypeDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + var multipartFile *codegen.File + for _, file := range examples.ServerFiles() { + if file.Path == "multipart.go" { + multipartFile = file + break + } + } + require.NotNil(t, multipartFile) + var buf bytes.Buffer + for _, section := range multipartFile.SectionTemplates { + require.NoError(t, section.Write(&buf)) + } + code := codegen.FormatTestCode(t, buf.String()) + golden := filepath.Join("testdata", "golden", "server-multipart-"+c.Name+".golden") + testutil.CompareOrUpdateGolden(t, code, golden) + }) + } + }) + t.Run("code check", func(t *testing.T) { cases := []struct { Name string @@ -68,11 +98,9 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - plan := linkedHTTPPlanForRoot(t, root) - fs := plan.ExampleServerFiles() + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -86,3 +114,44 @@ func TestExampleServerFiles(t *testing.T) { } }) } + +func TestExampleServerUsesServicePathsForLocalNames(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.ServerFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstBase := codegen.Goify(first.PathName, false) + secondBase := codegen.Goify(second.PathName, false) + require.NotEqual(t, firstBase, secondBase) + require.Contains(t, output.String(), firstBase+"Endpoints") + require.Contains(t, output.String(), secondBase+"Endpoints") + require.Contains(t, output.String(), firstBase+"Server") + require.Contains(t, output.String(), secondBase+"Server") +} + +// collidingServiceNamesDSL defines two services whose names become the same Go +// name. Their generated package paths remain distinct. +func collidingServiceNamesDSL() { + dsl.API("collision", func() { + dsl.Server("collision", func() { + dsl.Services("read_value", "read-value") + }) + }) + dsl.Service("read_value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/underscore") }) + }) + }) + dsl.Service("read-value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/dash") }) + }) + }) +} diff --git a/http/codegen/handler_test.go b/http/codegen/handler_test.go index b4858d4d2d..f7d79025e9 100644 --- a/http/codegen/handler_test.go +++ b/http/codegen/handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestHandlerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/jsonrpc_data.go b/http/codegen/jsonrpc_data.go index c49f97dea8..d9a89c04b7 100644 --- a/http/codegen/jsonrpc_data.go +++ b/http/codegen/jsonrpc_data.go @@ -33,6 +33,14 @@ type ( // copyJSONRPCEndpoint returns the values read by JSON-RPC files for endpoint. // Changing the returned value cannot change endpoint. func copyJSONRPCEndpoint(endpoint *EndpointData) JSONRPCEndpointSnapshot { + requestEncoder := "" + if endpoint.RequestEncoderDeclaration != nil { + requestEncoder = endpoint.RequestEncoderDeclaration.Name() + } + requestDecoder := "" + if endpoint.RequestDecoderDeclaration != nil { + requestDecoder = endpoint.RequestDecoderDeclaration.Name() + } result := JSONRPCEndpointSnapshot{ IsJSONRPC: endpoint.IsJSONRPC, Method: copyJSONRPCMethod(endpoint.Method), @@ -44,14 +52,17 @@ func copyJSONRPCEndpoint(endpoint *EndpointData) JSONRPCEndpointSnapshot { Routes: copyJSONRPCRoutes(endpoint.Routes), RequestInit: copyInitData(endpoint.RequestInit), EndpointInit: endpoint.EndpointInit, + HandlerInit: endpoint.HandlerInitDeclaration.Name(), HandlerInitDeclaration: endpoint.HandlerInitDeclaration, + ClientStruct: endpoint.ClientStructDeclaration.Name(), ClientStructDeclaration: endpoint.ClientStructDeclaration, + RequestEncoder: requestEncoder, RequestEncoderDeclaration: endpoint.RequestEncoderDeclaration, + RequestDecoder: requestDecoder, RequestDecoderDeclaration: endpoint.RequestDecoderDeclaration, + ResponseDecoder: endpoint.ResponseDecoderDeclaration.Name(), ResponseDecoderDeclaration: endpoint.ResponseDecoderDeclaration, SSE: copyJSONRPCSSE(endpoint.SSE), - ClientWebSocket: copyJSONRPCWebSocket(endpoint.ClientWebSocket), - ServerWebSocket: copyJSONRPCWebSocket(endpoint.ServerWebSocket), } return result } @@ -111,8 +122,8 @@ func copyJSONRPCMethod(method *service.MethodData) JSONRPCMethodData { result := JSONRPCMethodData{ Name: method.Name, VarName: method.VarName, - EventDeclaration: method.EventDeclaration, Result: method.Result, + HasMixedResults: method.HasMixedResults, Idempotent: method.Idempotent, ServerStream: copyJSONRPCStream(method.ServerStream), ClientStream: copyJSONRPCStream(method.ClientStream), @@ -199,9 +210,6 @@ func copyJSONRPCPayload(payload *PayloadData) *JSONRPCPayloadData { MustHaveBody: request.MustHaveBody, MustValidate: request.MustValidate, } - if request.PayloadType != nil { - result.Request.PayloadTypeName = request.PayloadType.Name() - } } return result } @@ -279,31 +287,20 @@ func copyJSONRPCSSE(stream *SSEData) *JSONRPCSSEData { ClientStructDeclaration: stream.ClientStructDeclaration, ClientInitDeclaration: stream.ClientInitDeclaration, EventTypeRef: stream.EventTypeRef, + HasResponseBody: stream.HasResponseBody, + Response: copyJSONRPCResponsePtr(stream.Response), RequestIDField: stream.RequestIDField, + RequestIDPointer: stream.RequestIDPointer, } } -// copyJSONRPCWebSocket returns the WebSocket stream names read by JSON-RPC files. -func copyJSONRPCWebSocket(stream *WebSocketData) *JSONRPCWebSocketData { - if stream == nil { +// copyJSONRPCResponsePtr returns an independent copy of response. +func copyJSONRPCResponsePtr(response *ResponseData) *JSONRPCResponseData { + if response == nil { return nil } - return &JSONRPCWebSocketData{ - VarDeclaration: stream.VarDeclaration, - VarName: stream.VarName, - SendName: stream.SendName, - SendDesc: stream.SendDesc, - SendWithContextName: stream.SendWithContextName, - SendWithContextDesc: stream.SendWithContextDesc, - SendTypeName: stream.SendTypeName, - SendTypeRef: stream.SendTypeRef, - RecvName: stream.RecvName, - RecvDesc: stream.RecvDesc, - RecvWithContextName: stream.RecvWithContextName, - RecvWithContextDesc: stream.RecvWithContextDesc, - RecvTypeName: stream.RecvTypeName, - RecvTypeRef: stream.RecvTypeRef, - } + copy := copyJSONRPCResponse(response) + return © } // copyJSONRPCBody returns the generated body names and the code that converts @@ -313,10 +310,13 @@ func copyJSONRPCBody(body *TypeData) *JSONRPCBodyData { return nil } return &JSONRPCBodyData{ - VarName: body.VarName, - Ref: body.Ref, - ValidateRef: body.ValidateRef, - Init: copyInitData(body.Init), + Declaration: body.Declaration, + VarName: body.VarName, + Ref: body.Ref, + ValidateRef: body.ValidateRef, + ValidatorDeclaration: body.ValidatorDeclaration, + ValidationTarget: body.ValidationTarget, + Init: copyInitData(body.Init), } } diff --git a/http/codegen/multipart_test.go b/http/codegen/multipart_test.go index 0d267ab0de..9ade50f6aa 100644 --- a/http/codegen/multipart_test.go +++ b/http/codegen/multipart_test.go @@ -13,13 +13,13 @@ import ( ) func TestServerMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, } @@ -38,7 +38,6 @@ func TestServerMultipartFuncType(t *testing.T) { } func TestClientMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -63,13 +62,13 @@ func TestClientMultipartFuncType(t *testing.T) { } func TestServerMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"server-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"server-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"server-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"server-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"server-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, {"server-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, @@ -90,7 +89,6 @@ func TestServerMultipartNewFunc(t *testing.T) { } func TestClientMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 36d53b6d8b..8ff858576c 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -20,7 +20,7 @@ func TestClientCLIInlinesOneOfRequestValidation(t *testing.T) { require.Contains(t, code, "BuildMethodBodyUnionUserValidatePayload") require.Contains(t, code, "if body.A == nil") - require.Contains(t, code, "marshalUnionUserValidateTo") + require.Contains(t, code, "marshalUnionUserValidateRequestBodyToServicebodyunionuservalidateUnionUserValidate") require.NotContains(t, code, "ValidateMethodBodyUnionUserValidateRequestBody") } @@ -76,8 +76,6 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde func renderClientTypesCode(t *testing.T, dsl func()) string { t.Helper() - const genpkg = "gen" - root := expr.RunDSL(t, dsl) plan := linkedHTTPPlanForRoot(t, root) fs := plan.ClientTypeFiles()[0] diff --git a/http/codegen/openapi.go b/http/codegen/openapi.go index fcde98d977..db7055ec47 100644 --- a/http/codegen/openapi.go +++ b/http/codegen/openapi.go @@ -1,10 +1,13 @@ -// This file turns a prepared HTTP design into the requested OpenAPI documents. -// The generator supplies one run-owned example coordinator; each OpenAPI -// version anchors its schema and displayed values to the exact request, -// response, or error expression that owns them. +// This file reads one HTTP design and builds the requested OpenAPI files. +// The plan builds every file immediately and returns those files later without +// reading the design again. package codegen import ( + "fmt" + "path" + "strings" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" @@ -12,39 +15,72 @@ import ( openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" ) -// OpenAPIFiles returns the files for the OpenAPI specs of the given HTTP API. +type ( + // OpenAPIPlan stores the OpenAPI files built from one HTTP design. + OpenAPIPlan struct { + files []*codegen.File + } +) + +// NewOpenAPIPlan builds the OpenAPI files for root. Later calls to Files +// return these same files without reading root again. // The "openapi:versions" API meta selects the generated specification // versions and the "openapi:path:" API meta overrides their output // paths, see openapi.Specs. -func OpenAPIFiles(root *expr.RootExpr, generator *expr.ExampleGenerator) ([]*codegen.File, error) { - // Only create a OpenAPI specification if there are HTTP services. - if len(root.API.HTTP.Services) == 0 { - return nil, nil - } +func NewOpenAPIPlan(root *expr.RootExpr, generator *expr.ExampleGenerator) (*OpenAPIPlan, error) { + return NewOpenAPIPlanWithValues(root, generator, openapi.Values{}) +} +// NewOpenAPIPlanWithValues builds OpenAPI files using values in place of +// matching titles, descriptions, and examples from the evaluated design. +func NewOpenAPIPlanWithValues(root *expr.RootExpr, generator *expr.ExampleGenerator, values openapi.Values) (*OpenAPIPlan, error) { specs, err := openapi.Specs(root.API.Meta) if err != nil { return nil, err } + return NewOpenAPIPlanFromSpecs(root, generator, specs, values) +} + +// NewOpenAPIPlanFromSpecs builds the exact OpenAPI versions and paths in specs, +// using values in place of matching design text and examples. Paths are +// relative to the gen directory and omit the JSON or YAML extension because +// Goa writes both formats. +func NewOpenAPIPlanFromSpecs(root *expr.RootExpr, generator *expr.ExampleGenerator, specs []openapi.Spec, values openapi.Values) (*OpenAPIPlan, error) { + if err := validateOpenAPISpecs(specs); err != nil { + return nil, err + } + // Only create a OpenAPI specification if there are HTTP services. + if len(root.API.HTTP.Services) == 0 { + return &OpenAPIPlan{}, nil + } + var files []*codegen.File for _, spec := range specs { specGenerator := generator if examplesDisabled(root.API.Meta) { specGenerator = &expr.ExampleGenerator{} } - var fs []*codegen.File + var ( + fs []*codegen.File + err error + ) switch spec.Version { case openapi.Version20: - fs, err = openapiv2.Files(root, spec.Path, specGenerator) + fs, err = openapiv2.FilesWithValues(root, spec.Path, specGenerator, values) if err != nil { return nil, err } default: // Version30, Version32 - fs = openapiv3.Files(root, spec.Version, spec.Path, specGenerator) + fs = openapiv3.FilesWithValues(root, spec.Version, spec.Path, specGenerator, values) } files = append(files, fs...) } - return files, nil + return &OpenAPIPlan{files: files}, nil +} + +// Files returns the OpenAPI files built when the plan was created. +func (p *OpenAPIPlan) Files() []*codegen.File { + return p.files } // examplesDisabled reports whether API metadata suppresses examples from @@ -56,3 +92,63 @@ func examplesDisabled(meta expr.MetaExpr) bool { } return ok && value == "false" } + +// validateOpenAPISpecs checks the complete version and path list before any +// file is built. +func validateOpenAPISpecs(specs []openapi.Spec) error { + versions := make(map[openapi.Version]struct{}, len(specs)) + paths := make(map[string]openapi.Spec, len(specs)) + for _, spec := range specs { + switch spec.Version { + case openapi.Version20, openapi.Version30, openapi.Version32: + default: + return fmt.Errorf("unsupported OpenAPI version %q", spec.Version) + } + if _, ok := versions[spec.Version]; ok { + return fmt.Errorf("OpenAPI version %q appears more than once", spec.Version) + } + versions[spec.Version] = struct{}{} + if err := validateOpenAPIPath(spec.Path); err != nil { + return fmt.Errorf("invalid OpenAPI %s path %q: %w", spec.Version, spec.Path, err) + } + for existingPath, existing := range paths { + if existingPath == spec.Path { + return fmt.Errorf("OpenAPI versions %s and %s use the same output path %q", existing.Version, spec.Version, spec.Path) + } + if strings.EqualFold(existingPath, spec.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existingPath, + spec.Path, + ) + } + } + paths[spec.Path] = spec + } + return nil +} + +// validateOpenAPIPath checks one extension-less path relative to gen. +func validateOpenAPIPath(outputPath string) error { + if outputPath == "" { + return fmt.Errorf("path cannot be empty") + } + if strings.Contains(outputPath, "\\") { + return fmt.Errorf("path cannot contain a backslash") + } + if strings.HasPrefix(outputPath, "/") { + return fmt.Errorf("path must be relative to the gen directory") + } + cleaned := path.Clean(outputPath) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return fmt.Errorf("path must not escape the gen directory") + } + if cleaned != outputPath { + return fmt.Errorf("path must be clean; use %q", cleaned) + } + switch path.Ext(outputPath) { + case ".json", ".yaml", ".yml": + return fmt.Errorf("path must not include an extension") + } + return nil +} diff --git a/http/codegen/openapi/docs.go b/http/codegen/openapi/docs.go index a6d63045c2..d5d44e679f 100644 --- a/http/codegen/openapi/docs.go +++ b/http/codegen/openapi/docs.go @@ -1,3 +1,5 @@ +// This file converts Goa documentation links into OpenAPI values while +// allowing one specification build to replace their displayed description. package openapi import "goa.design/goa/v3/expr" @@ -12,11 +14,23 @@ type ExternalDocs struct { // DocsFromExpr builds a ExternalDocs from the Goa docs expression. func DocsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr) *ExternalDocs { + return docsFromExpr(docs, meta, Values{}) +} + +// DocsFromExprWithValues builds ExternalDocs and uses values for its +// description when one is present for docs. +func DocsFromExprWithValues(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { + return docsFromExpr(docs, meta, values) +} + +// docsFromExpr is the one implementation used by ordinary and customized +// OpenAPI builds. +func docsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { if docs == nil { return nil } return &ExternalDocs{ - Description: docs.Description, + Description: values.Description(docs, docs.Description), URL: docs.URL, Extensions: ExtensionsFromExpr(meta), } diff --git a/http/codegen/openapi/error_example.go b/http/codegen/openapi/error_example.go new file mode 100644 index 0000000000..2ca50d95ce --- /dev/null +++ b/http/codegen/openapi/error_example.go @@ -0,0 +1,51 @@ +// This file derives HTTP response details shared by the OpenAPI 2 and OpenAPI +// 3 generators. The shared Error schema stays reusable while each response +// shows the exact flags generated by its service constructor. +package openapi + +import "goa.design/goa/v3/expr" + +// ResponseContentType returns the content type used for one HTTP response. A +// response setting wins, followed by its result type and application/json. +func ResponseContentType(response *expr.HTTPResponseExpr) string { + if response.ContentType != "" { + return response.ContentType + } + if result, ok := response.Body.Type.(*expr.ResultTypeExpr); ok && result.ContentType != "" { + return result.ContentType + } + return "application/json" +} + +// ErrorResponseExample returns the generated body example for one use of +// Goa's built-in error result. It returns false when the error uses another +// type, supplies an authored example, or suppresses generated examples. +func ErrorResponseExample(errorExpression *expr.ErrorExpr, body *expr.AttributeExpr, generator *expr.ExampleGenerator, values Values) (any, bool) { + if !expr.IsErrorResult(errorExpression.Type) || len(values.Examples(body, body.ExtractUserExamples())) > 0 { + return nil, false + } + example := ProjectExample(body, values.Example(body, generator)) + if example == nil { + return nil, false + } + object, ok := example.(map[string]any) + if !ok { + return example, true + } + setExampleField(object, "name", errorExpression.Name) + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + setExampleField(object, "temporary", temporary) + setExampleField(object, "timeout", timeout) + setExampleField(object, "fault", fault) + return object, true +} + +// setExampleField changes a field only when that field is part of the HTTP +// response body. Fields mapped to headers or cookies are not added back. +func setExampleField(example map[string]any, name string, value any) { + if _, ok := example[name]; ok { + example[name] = value + } +} diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go index 27076a5bde..599e0414b7 100644 --- a/http/codegen/openapi/json_schema.go +++ b/http/codegen/openapi/json_schema.go @@ -1,14 +1,12 @@ -// This file renders shared OpenAPI JSON schemas and anchors every generated -// example to the method or concrete transport response that owns it. +// This file defines the JSON schema values shared by the OpenAPI generators. +// It also converts Goa examples into the fields visible in those schemas. package openapi import ( "encoding/json" - "fmt" "reflect" "strconv" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) @@ -110,26 +108,6 @@ const ( // SchemaRef is the JSON Schema draft 2020-12 meta-schema identifier. const SchemaRef = "https://json-schema.org/draft/2020-12/schema" -var ( - // Definitions contains the generated JSON schema definitions - Definitions map[string]*Schema - - // definitionNames records the definition name assigned to result type - // expressions returned as-is by expr.Project (the design expression when - // it is already projected onto the requested view). The historical - // implementation renamed those expressions in place which made later - // references resolve to the first assigned name; the registry preserves - // that behavior while keeping the design expression tree read-only for - // the generators. Entries are keyed by expression instance so stale - // entries from previous generations cannot collide with new designs. - definitionNames = make(map[*expr.ResultTypeExpr]string) -) - -// Initialize the global variables -func init() { - Definitions = make(map[string]*Schema) -} - // NewSchema instantiates a new JSON schema. func NewSchema() *Schema { js := Schema{ @@ -148,315 +126,6 @@ func (s *Schema) JSON() ([]byte, error) { return json.Marshal(s) } -// APISchema produces the API JSON hyper schema. -func APISchema(api *expr.APIExpr, r *expr.RootExpr, gen *expr.ExampleGenerator) *Schema { - for _, res := range r.API.HTTP.Services { - GenerateServiceDefinition(api, res, gen) - } - href := string(api.Servers[0].Hosts[0].URIs[0]) - links := []*Link{ - { - Href: href, - Rel: "self", - }, - { - Href: "/schema", - Method: "GET", - Rel: "self", - TargetSchema: &Schema{ - Schema: SchemaRef, - AdditionalProperties: true, - }, - }, - } - s := Schema{ - ID: fmt.Sprintf("%s/schema", href), - Title: api.Title, - Description: api.Description, - Type: Object, - Defs: Definitions, - Properties: propertiesFromDefs(Definitions, "#/$defs/"), - Links: links, - } - return &s -} - -// GenerateServiceDefinition produces the JSON schema corresponding to the given -// service. It stores the results in Definitions. -func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr, gen *expr.ExampleGenerator) { - s := NewSchema() - s.Description = res.Description() - s.Type = Object - s.Title = res.Name() - Definitions[res.Name()] = s - for _, a := range res.HTTPEndpoints { - var requestSchema *Schema - if a.MethodExpr.Payload.Type != expr.Empty { - payloadGenerator := gen.At(expr.MethodPayloadExampleIdentity(a.MethodExpr)) - requestSchema = AttributeTypeSchema(api, a.MethodExpr.Payload, payloadGenerator) - requestSchema.Description = a.Name() + " payload" - } - var targetSchema *Schema - var identifier string - for _, resp := range a.Responses { - dt := resp.Body.Type - responseGenerator := gen.At(expr.ResponseBodyExampleIdentity(a, resp)) - if mt := dt.(*expr.ResultTypeExpr); mt != nil { - if identifier == "" { - identifier = mt.Identifier - } else { - identifier = "" - } - switch { - case targetSchema == nil: - targetSchema = TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator) - case targetSchema.AnyOf == nil: - firstSchema := targetSchema - targetSchema = NewSchema() - targetSchema.AnyOf = []*Schema{firstSchema, TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator)} - default: - targetSchema.AnyOf = append(targetSchema.AnyOf, TypeSchemaWithPrefix(api, mt, a.Name(), responseGenerator)) - } - } - } - for i, r := range a.Routes { - for j, href := range toSchemaHrefs(r) { - link := Link{ - Title: a.Name(), - Rel: a.Name(), - Href: href, - Method: r.Method, - Schema: requestSchema, - TargetSchema: targetSchema, - ResultType: identifier, - } - if i == 0 && j == 0 { - if ca := a.Service.CanonicalEndpoint(); ca != nil { - if ca.Name() == a.Name() { - link.Rel = "self" - } - } - } - s.Links = append(s.Links, &link) - } - } - } -} - -// ResultTypeRef produces the JSON reference to the media type definition with -// the given view. -func ResultTypeRef(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) string { - return ResultTypeRefWithPrefix(api, mt, view, "", gen) -} - -// ResultTypeRefWithPrefix produces the JSON reference to the media type definition with -// the given view and adds the provided prefix to the type name -func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string, gen *expr.ExampleGenerator) string { - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug - } - var metaName string - if n, ok := mt.Meta["openapi:typename"]; ok { - metaName = codegen.Goify(n[0], true) - } - name := projected.TypeName - if metaName != "" { - name = metaName - } - if assigned, ok := definitionNames[projected]; ok { - // expr.Project returned the design expression itself and a - // definition name was already assigned to it: keep referencing it. - name = assigned - } else { - if _, ok := Definitions[name]; !ok { - name = codegen.Goify(prefix, true) + codegen.Goify(name, true) - if metaName != "" { - name = metaName - } - } - if projected == mt { - // expr.Project returns its input when the result type is - // already projected onto the requested view. Record the - // assigned name instead of renaming the design expression in - // place: the design tree is read-only for the generators. - definitionNames[projected] = name - } - } - if _, ok := Definitions[name]; !ok { - GenerateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView, gen) - } - return fmt.Sprintf("#/$defs/%s", name) -} - -// TypeRef produces the JSON reference to the type definition. -func TypeRef(api *expr.APIExpr, ut *expr.UserTypeExpr, gen *expr.ExampleGenerator) string { - return TypeRefWithPrefix(api, ut, "", gen) -} - -// TypeRefWithPrefix produces the JSON reference to the type definition and adds the provided prefix -// to the type name -func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string, gen *expr.ExampleGenerator) string { - typeName := ut.TypeName - if prefix != "" { - typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) - } - if n, ok := ut.Meta["openapi:typename"]; ok { - typeName = codegen.Goify(n[0], true) - } - if _, ok := Definitions[typeName]; !ok { - GenerateTypeDefinitionWithName(api, ut, typeName, gen) - } - return fmt.Sprintf("#/$defs/%s", typeName) -} - -// GenerateResultTypeDefinition produces the JSON schema corresponding to the -// given media type and given view. -func GenerateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) { - if _, ok := Definitions[mt.TypeName]; ok { - return - } - s := NewSchema() - s.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) - Definitions[mt.TypeName] = s - buildResultTypeSchema(api, mt, view, s, gen) -} - -// GenerateTypeDefinition produces the JSON schema corresponding to the given -// type. -func GenerateTypeDefinition(api *expr.APIExpr, ut *expr.UserTypeExpr, gen *expr.ExampleGenerator) { - GenerateTypeDefinitionWithName(api, ut, ut.TypeName, gen) -} - -// GenerateTypeDefinitionWithName produces the JSON schema corresponding to the given -// type with provided type name. -func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string, gen *expr.ExampleGenerator) { - if _, ok := Definitions[typeName]; ok { - return - } - s := NewSchema() - - s.Title = typeName - Definitions[typeName] = s - buildAttributeSchema(api, s, ut.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(ut))) -} - -// TypeSchema produces the JSON schema corresponding to the given data type. -func TypeSchema(api *expr.APIExpr, t expr.DataType, gen *expr.ExampleGenerator) *Schema { - return TypeSchemaWithPrefix(api, t, "", gen) -} - -// TypeSchemaWithPrefix produces the JSON schema corresponding to the given data type -// and adds the provided prefix to the type name -func TypeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *Schema { - return typeSchemaWithGen(api, t, prefix, gen) -} - -// typeSchemaWithGen builds the JSON schema for t drawing example values from -// gen. Child schemas derive their example streams from their position (object -// property name, array index, map entry, union member) so every example in -// the schema is anchored to the design element it illustrates. -func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *Schema { - s := NewSchema() - switch actual := t.(type) { - case expr.Primitive: - s.Type = Type(actual.Name()) - switch actual.Kind() { - case expr.AnyKind: - // A schema without a type matches any data type. - // See https://swagger.io/docs/specification/data-models/data-types/#any. - s.Type = Type("") - case expr.IntKind, expr.Int64Kind, - expr.UIntKind, expr.UInt64Kind: - // Use int64 format for IntKind and UIntKind because the OpenAPI - // generator produced int32 by default. - s.Type = Type("integer") - s.Format = "int64" - case expr.Int32Kind, expr.UInt32Kind: - s.Type = Type("integer") - s.Format = "int32" - case expr.Float32Kind: - s.Type = Type("number") - s.Format = "float" - case expr.Float64Kind: - s.Type = Type("number") - s.Format = "double" - case expr.BytesKind: - s.Type = Type("string") - s.Format = "byte" - } - case *expr.Array: - s.Type = Array - s.Items = NewSchema() - buildAttributeSchema(api, s.Items, actual.ElemType, gen.ArrayElement(0)) - case *expr.Object: - s.Type = Object - for _, nat := range *actual { - if !MustGenerate(nat.Attribute.Meta) { - continue - } - prop := NewSchema() - buildAttributeSchema(api, prop, nat.Attribute, gen.Member(nat.Name)) - s.Properties[nat.Name] = prop - } - case *expr.Map: - s.Type = Object - if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { - // Use free-form objects when elements are of type "Any" - additionalProperties := NewSchema() - s.AdditionalProperties = buildAttributeSchema(api, additionalProperties, actual.ElemType, gen.MapValue(0)) - } else { - s.AdditionalProperties = true - } - case *expr.Union: - // Each branch owns both its discriminator literal and value schema so - // clients cannot combine one branch tag with another branch value. - typeKey := actual.GetTypeKey() - valueKey := actual.GetValueKey() - - s.Type = Object - for _, val := range actual.Values { - valueSchema := typeSchemaWithGen(api, val.Attribute.Type, prefix, gen.UnionMember(val.Name)) - initAttributeValidation(valueSchema, val.Attribute) - s.AnyOf = append(s.AnyOf, &Schema{ - Type: Object, - Properties: map[string]*Schema{ - typeKey: { - Type: String, - Enum: []any{val.Name}, - }, - valueKey: valueSchema, - }, - Required: []string{typeKey, valueKey}, - }) - } - case *expr.UserTypeExpr: - if expr.IsAlias(actual) { - s = typeSchemaWithGen(api, actual.Attribute().Type, prefix, gen.At(expr.UserTypeExampleIdentity(actual))) - initAttributeValidation(s, actual.Attribute()) - break - } - s.Ref = TypeRefWithPrefix(api, actual, prefix, gen) - case *expr.ResultTypeExpr: - // Use "default" view by default - s.Ref = ResultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix, gen) - } - return s -} - -// AttributeTypeSchema produces the JSON schema corresponding to the given attribute. -func AttributeTypeSchema(api *expr.APIExpr, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *Schema { - return AttributeTypeSchemaWithPrefix(api, at, "", gen) -} - -// AttributeTypeSchemaWithPrefix produces the JSON schema corresponding to the given attribute -// and adds the provided prefix to the type name -func AttributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string, gen *expr.ExampleGenerator) *Schema { - s := TypeSchemaWithPrefix(api, at.Type, prefix, gen) - initAttributeValidation(s, at) - return s -} - // ToString returns the string representation of the given type. func ToString(val any) string { switch actual := val.(type) { @@ -518,38 +187,75 @@ func (s *Schema) MarshalYAML() (any, error) { return MarshalYAML((*_Schema)(s), s.Extensions) } -// Dup creates a shallow clone of the given schema. +// Dup returns an independent copy of the schema. Callers may change any nested +// schema, collection, example, default, or extension without changing s. func (s *Schema) Dup() *Schema { js := Schema{ - ID: s.ID, - Description: s.Description, - Schema: s.Schema, - Type: s.Type, - DefaultValue: s.DefaultValue, - Title: s.Title, - Media: s.Media, - ReadOnly: s.ReadOnly, - PathStart: s.PathStart, - Links: s.Links, - Ref: s.Ref, - Enum: s.Enum, - Format: s.Format, - Pattern: s.Pattern, - Minimum: s.Minimum, - Maximum: s.Maximum, - MinLength: s.MinLength, - MaxLength: s.MaxLength, - MinItems: s.MinItems, - MaxItems: s.MaxItems, - Required: s.Required, - AdditionalProperties: s.AdditionalProperties, - ContentMediaType: s.ContentMediaType, + Schema: s.Schema, + ID: s.ID, + Title: s.Title, + Type: s.Type, + Description: s.Description, + DefaultValue: duplicateJSONValue(s.DefaultValue), + Example: duplicateJSONValue(s.Example), + ReadOnly: s.ReadOnly, + PathStart: s.PathStart, + Ref: s.Ref, + Format: s.Format, + Pattern: s.Pattern, + ExclusiveMinimum: duplicatePointer(s.ExclusiveMinimum), + Minimum: duplicatePointer(s.Minimum), + ExclusiveMaximum: duplicatePointer(s.ExclusiveMaximum), + Maximum: duplicatePointer(s.Maximum), + MinLength: duplicatePointer(s.MinLength), + MaxLength: duplicatePointer(s.MaxLength), + MinItems: duplicatePointer(s.MinItems), + MaxItems: duplicatePointer(s.MaxItems), + Required: append([]string(nil), s.Required...), + ContentMediaType: s.ContentMediaType, + } + if s.Media != nil { + media := *s.Media + js.Media = &media + } + if s.Links != nil { + js.Links = make([]*Link, len(s.Links)) + for index, link := range s.Links { + copy := *link + if link.Schema != nil { + copy.Schema = link.Schema.Dup() + } + if link.TargetSchema != nil { + copy.TargetSchema = link.TargetSchema.Dup() + } + js.Links[index] = © + } + } + if s.Enum != nil { + js.Enum = make([]any, len(s.Enum)) + for index, value := range s.Enum { + js.Enum[index] = duplicateJSONValue(value) + } + } + if additional, ok := s.AdditionalProperties.(*Schema); ok { + js.AdditionalProperties = additional.Dup() + } else { + js.AdditionalProperties = duplicateJSONValue(s.AdditionalProperties) + } + if s.Extensions != nil { + js.Extensions = make(map[string]any, len(s.Extensions)) + for name, value := range s.Extensions { + js.Extensions[name] = duplicateJSONValue(value) + } } if s.ContentSchema != nil { js.ContentSchema = s.ContentSchema.Dup() } - for n, p := range s.Properties { - js.Properties[n] = p.Dup() + if s.Properties != nil { + js.Properties = make(map[string]*Schema, len(s.Properties)) + for name, property := range s.Properties { + js.Properties[name] = property.Dup() + } } if s.Items != nil { js.Items = s.Items.Dup() @@ -560,138 +266,13 @@ func (s *Schema) Dup() *Schema { js.AnyOf[i] = branch.Dup() } } - for n, d := range s.Defs { - js.Defs[n] = d.Dup() - } - return &js -} - -// buildAttributeSchema initializes the given JSON schema that corresponds to -// the given attribute, drawing example values from gen. -func buildAttributeSchema(api *expr.APIExpr, s *Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *Schema { - s.Merge(typeSchemaWithGen(api, at.Type, "", gen)) - if s.Ref != "" { - // Ref is exclusive with other fields - return s - } - s.DefaultValue = ToStringMap(at.DefaultValue) - if at.Description != "" { - s.Description = at.Description - } - s.Example = ProjectExample(at, at.Example(gen)) - s.Extensions = ExtensionsFromExpr(at.Meta) - if ap := AdditionalPropertiesFromExpr(at.Meta); ap != nil { - s.AdditionalProperties = ap - } - initAttributeValidation(s, at) - - return s -} - -// initAttributeValidation initializes validation rules for an attribute. -func initAttributeValidation(s *Schema, at *expr.AttributeExpr) { - val := at.Validation - if val == nil { - return - } - s.Enum = val.Values - if val.Format != "" { - s.Format = string(val.Format) - } - s.Pattern = val.Pattern - if val.ExclusiveMinimum != nil { - s.ExclusiveMinimum = val.ExclusiveMinimum - } - if val.Minimum != nil { - s.Minimum = val.Minimum - } - if val.ExclusiveMaximum != nil { - s.ExclusiveMaximum = val.ExclusiveMaximum - } - if val.Maximum != nil { - s.Maximum = val.Maximum - } - if val.MinLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MinItems = val.MinLength - } else { - s.MinLength = val.MinLength - } - } - if val.MaxLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MaxItems = val.MaxLength - } else { - s.MaxLength = val.MaxLength - } - } - for _, v := range val.Required { - if a := at.Find(v); a != nil { - if !MustGenerate(a.Meta) { - continue - } - } - s.Required = append(s.Required, v) - } -} - -// renamedResultType returns rt carrying the given type name. When the name -// already matches it returns rt unchanged, otherwise it returns a shallow -// copy sharing the attribute, views and identifier so the schema definition -// is registered under the assigned name without renaming the (possibly design -// owned) expression in place. -func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { - if rt.TypeName == name { - return rt - } - ut := *rt.UserTypeExpr - ut.TypeName = name - dup := *rt - dup.UserTypeExpr = &ut - return &dup -} - -// toSchemaHrefs produces hrefs that replace the path wildcards with JSON -// schema references when appropriate. -func toSchemaHrefs(r *expr.RouteExpr) []string { - paths := r.FullPaths() - res := make([]string, len(paths)) - for i, path := range paths { - params := expr.ExtractHTTPWildcards(path) - args := make([]any, len(params)) - for j, p := range params { - args[j] = fmt.Sprintf("/{%s}", p) - } - tmpl := expr.HTTPWildcardRegex.ReplaceAllLiteralString(path, "%s") - res[i] = fmt.Sprintf(tmpl, args...) - } - return res -} - -// propertiesFromDefs creates a Properties map referencing the given definitions -// under the given path. -func propertiesFromDefs(definitions map[string]*Schema, path string) map[string]*Schema { - res := make(map[string]*Schema, len(definitions)) - for n := range definitions { - if n == "identity" { - continue + if s.Defs != nil { + js.Defs = make(map[string]*Schema, len(s.Defs)) + for name, definition := range s.Defs { + js.Defs[name] = definition.Dup() } - s := NewSchema() - s.Ref = path + n - res[n] = s - } - return res -} - -// buildResultTypeSchema initializes s as the JSON schema representing mt for the -// given view. -func buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, s *Schema, gen *expr.ExampleGenerator) { - s.Media = &Media{Type: mt.Identifier} - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug } - buildAttributeSchema(api, s, projected.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(projected))) + return &js } // MustGenerate returns true if the meta indicates that a OpenAPI specification should be @@ -733,6 +314,75 @@ func projectExample(t expr.DataType, val any) any { } } +// duplicateJSONValue copies the maps, slices, arrays, pointers, and interface +// values accepted by JSON fields while preserving their concrete Go types. +func duplicateJSONValue(value any) any { + if value == nil { + return nil + } + return duplicateJSONReflectValue(reflect.ValueOf(value)).Interface() +} + +// duplicateJSONReflectValue recursively copies one reflected JSON value. +func duplicateJSONReflectValue(value reflect.Value) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := duplicateJSONReflectValue(value.Elem()) + result := reflect.New(value.Type()).Elem() + result.Set(copy) + return result + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.New(value.Type().Elem()) + copy.Elem().Set(duplicateJSONReflectValue(value.Elem())) + return copy + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copy.SetMapIndex( + duplicateJSONReflectValue(iterator.Key()), + duplicateJSONReflectValue(iterator.Value()), + ) + } + return copy + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeSlice(value.Type(), value.Len(), value.Cap()) + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + case reflect.Array: + copy := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + default: + return value + } +} + +// duplicatePointer copies one scalar schema limit while preserving nil. +func duplicatePointer[T any](value *T) *T { + if value == nil { + return nil + } + copy := *value + return © +} + func projectObjectExample(obj *expr.Object, val any) any { values, ok := exampleMap(val) if !ok { diff --git a/http/codegen/openapi/json_schema_dup_test.go b/http/codegen/openapi/json_schema_dup_test.go new file mode 100644 index 0000000000..f7841bbb06 --- /dev/null +++ b/http/codegen/openapi/json_schema_dup_test.go @@ -0,0 +1,105 @@ +// This file verifies that copied schemas share no mutable values with their +// source. Plugins may safely edit a copy without changing Goa's planned schema. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaDupCopiesEveryMutableField(t *testing.T) { + exclusiveMinimum := 1.0 + minimum := 2.0 + exclusiveMaximum := 9.0 + maximum := 10.0 + minLength := 1 + maxLength := 8 + minItems := 2 + maxItems := 4 + original := &Schema{ + Schema: "schema", + ID: "id", + Title: "title", + Type: Object, + Items: &Schema{Title: "items"}, + Properties: map[string]*Schema{"property": {Title: "property"}}, + Defs: map[string]*Schema{"definition": {Title: "definition"}}, + Description: "description", + DefaultValue: map[string][]string{"values": {"default"}}, + Example: []map[string]any{{"value": "example"}}, + Media: &Media{BinaryEncoding: "binary", Type: "media"}, + ReadOnly: true, + PathStart: "/path", + Links: []*Link{{Title: "link", Schema: &Schema{Title: "link schema"}, TargetSchema: &Schema{Title: "target schema"}}}, + Ref: "#/$defs/reference", + Enum: []any{[]string{"enum"}}, + Format: "format", + Pattern: "pattern", + ExclusiveMinimum: &exclusiveMinimum, + Minimum: &minimum, + ExclusiveMaximum: &exclusiveMaximum, + Maximum: &maximum, + MinLength: &minLength, + MaxLength: &maxLength, + MinItems: &minItems, + MaxItems: &maxItems, + Required: []string{"required"}, + AdditionalProperties: &Schema{Title: "additional properties"}, + ContentMediaType: "application/json", + ContentSchema: &Schema{Title: "content schema"}, + AnyOf: []*Schema{{Title: "union branch"}}, + Extensions: map[string]any{"x-values": []string{"extension"}}, + } + + duplicate := original.Dup() + require.Equal(t, original, duplicate) + + duplicate.Items.Title = "changed" + duplicate.Properties["property"].Title = "changed" + duplicate.Defs["definition"].Title = "changed" + duplicate.DefaultValue.(map[string][]string)["values"][0] = "changed" + duplicate.Example.([]map[string]any)[0]["value"] = "changed" + duplicate.Media.Type = "changed" + duplicate.Links[0].Title = "changed" + duplicate.Links[0].Schema.Title = "changed" + duplicate.Links[0].TargetSchema.Title = "changed" + duplicate.Enum[0].([]string)[0] = "changed" + *duplicate.ExclusiveMinimum = 3 + *duplicate.Minimum = 4 + *duplicate.ExclusiveMaximum = 7 + *duplicate.Maximum = 8 + *duplicate.MinLength = 2 + *duplicate.MaxLength = 7 + *duplicate.MinItems = 1 + *duplicate.MaxItems = 3 + duplicate.Required[0] = "changed" + duplicate.AdditionalProperties.(*Schema).Title = "changed" + duplicate.ContentSchema.Title = "changed" + duplicate.AnyOf[0].Title = "changed" + duplicate.Extensions["x-values"].([]string)[0] = "changed" + + require.Equal(t, "items", original.Items.Title) + require.Equal(t, "property", original.Properties["property"].Title) + require.Equal(t, "definition", original.Defs["definition"].Title) + require.Equal(t, "default", original.DefaultValue.(map[string][]string)["values"][0]) + require.Equal(t, "example", original.Example.([]map[string]any)[0]["value"]) + require.Equal(t, "media", original.Media.Type) + require.Equal(t, "link", original.Links[0].Title) + require.Equal(t, "link schema", original.Links[0].Schema.Title) + require.Equal(t, "target schema", original.Links[0].TargetSchema.Title) + require.Equal(t, "enum", original.Enum[0].([]string)[0]) + require.Equal(t, 1.0, *original.ExclusiveMinimum) + require.Equal(t, 2.0, *original.Minimum) + require.Equal(t, 9.0, *original.ExclusiveMaximum) + require.Equal(t, 10.0, *original.Maximum) + require.Equal(t, 1, *original.MinLength) + require.Equal(t, 8, *original.MaxLength) + require.Equal(t, 2, *original.MinItems) + require.Equal(t, 4, *original.MaxItems) + require.Equal(t, "required", original.Required[0]) + require.Equal(t, "additional properties", original.AdditionalProperties.(*Schema).Title) + require.Equal(t, "content schema", original.ContentSchema.Title) + require.Equal(t, "union branch", original.AnyOf[0].Title) + require.Equal(t, "extension", original.Extensions["x-values"].([]string)[0]) +} diff --git a/http/codegen/openapi/json_schema_union_test.go b/http/codegen/openapi/json_schema_union_test.go deleted file mode 100644 index 4d61207ee0..0000000000 --- a/http/codegen/openapi/json_schema_union_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// This file verifies shared JSON Schema rendering for unions, including that a -// typed owner keeps each discriminator paired with its generated member value. -package openapi - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/expr" -) - -func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} - generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( - expr.MethodPayloadExampleIdentity(method), - ) - schema := AttributeTypeSchema(&expr.APIExpr{}, unionAttribute(), generator) - - require.Len(t, schema.AnyOf, 2) - assertUnionSchemaBranch(t, schema.AnyOf[0], "text", Type(String)) - assertUnionSchemaBranch(t, schema.AnyOf[1], "count", Type(Integer)) - assert.Empty(t, schema.Properties) -} - -func assertUnionSchemaBranch(t *testing.T, branch *Schema, tag string, valueType Type) { - t.Helper() - assert.Equal(t, Type(Object), branch.Type) - assert.Equal(t, []string{"type", "value"}, branch.Required) - require.Contains(t, branch.Properties, "type") - assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) - require.Contains(t, branch.Properties, "value") - assert.Equal(t, valueType, branch.Properties["value"].Type) -} - -func unionAttribute() *expr.AttributeExpr { - return &expr.AttributeExpr{ - Type: &expr.Union{ - TypeName: "outcome", - Values: []*expr.NamedAttributeExpr{ - {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, - {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, - }, - }, - } -} diff --git a/http/codegen/openapi/response_projection.go b/http/codegen/openapi/response_projection.go new file mode 100644 index 0000000000..c31da2031c --- /dev/null +++ b/http/codegen/openapi/response_projection.go @@ -0,0 +1,47 @@ +// This file selects a response view on a private result type copy and keeps +// the component names produced by released Goa versions. +package openapi + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ResponseProjection contains one detached response result and the types whose +// released names should be preferred when they each describe one schema. +type ResponseProjection struct { + Result *expr.ResultTypeExpr + Preferred []expr.UserType +} + +// ProjectResponseResult selects view without changing the design. Collection +// result names keep the Response suffix before the view name. +func ProjectResponseResult(result *expr.ResultTypeExpr, view string) ResponseProjection { + projected, err := expr.Project(result, view) + if err != nil { + panic(fmt.Sprintf("failed to project result type %q to view %q: %s", result.Identifier, view, err)) + } + copy := expr.DupAtt(&expr.AttributeExpr{Type: projected}).Type.(*expr.ResultTypeExpr) + originalArray := expr.AsArray(result.Type) + projectedArray := expr.AsArray(copy.Type) + if originalArray == nil || projectedArray == nil { + return ResponseProjection{Result: copy} + } + originalElement, originalNamed := originalArray.ElemType.Type.(*expr.ResultTypeExpr) + projectedElement, projectedNamed := projectedArray.ElemType.Type.(*expr.ResultTypeExpr) + if !originalNamed || !projectedNamed { + return ResponseProjection{Result: copy} + } + name := codegen.Goify(originalElement.Name(), true) + "Response" + if view != "" && view != expr.DefaultView { + name += codegen.Goify(view, true) + } + projectedElement.Rename(name) + copy.Rename(name + "Collection") + return ResponseProjection{ + Result: copy, + Preferred: []expr.UserType{copy, projectedElement}, + } +} diff --git a/http/codegen/openapi/v2/build_isolation_test.go b/http/codegen/openapi/v2/build_isolation_test.go new file mode 100644 index 0000000000..4387d59ebb --- /dev/null +++ b/http/codegen/openapi/v2/build_isolation_test.go @@ -0,0 +1,125 @@ +// This file checks that Swagger builds do not share or change each other's schemas. +package openapiv2_test + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +func TestBuildsKeepDefinitionsSeparate(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + first, err := openapiv2.NewV2( + firstRoot, + firstRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + firstJSON, err := json.Marshal(first) + require.NoError(t, err) + + second, err := openapiv2.NewV2( + secondRoot, + secondRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + + firstDefinition := definitionWithProperty(first.Definitions, "first") + require.NotNil(t, firstDefinition, "definitions: %#v", first.Definitions) + require.Contains(t, firstDefinition.Properties, "first") + require.NotContains(t, firstDefinition.Properties, "second") + secondDefinition := definitionWithProperty(second.Definitions, "second") + require.NotNil(t, secondDefinition, "definitions: %#v", second.Definitions) + require.Contains(t, secondDefinition.Properties, "second") + require.NotContains(t, secondDefinition.Properties, "first") + + firstJSONAfterSecondBuild, err := json.Marshal(first) + require.NoError(t, err) + require.Equal(t, firstJSON, firstJSONAfterSecondBuild) +} + +func TestBuildsAreSafeToRunTogether(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + type result struct { + spec *openapiv2.V2 + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(root *expr.RootExpr) { + ready.Done() + <-start + spec, err := openapiv2.NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + results <- result{spec: spec, err: err} + } + go build(firstRoot) + go build(secondRoot) + ready.Wait() + close(start) + + properties := make(map[string]int) + for range 2 { + built := <-results + require.NoError(t, built.err) + var found []string + for _, definition := range built.spec.Definitions { + for property := range definition.Properties { + if property == "first" || property == "second" { + found = append(found, property) + } + } + } + require.Len(t, found, 1) + properties[found[0]]++ + } + require.Equal(t, map[string]int{"first": 1, "second": 1}, properties) +} + +// definitionWithProperty finds the returned schema that contains property. +func definitionWithProperty(definitions map[string]*openapi.Schema, property string) *openapi.Schema { + for _, definition := range definitions { + if _, ok := definition.Properties[property]; ok { + return definition + } + } + return nil +} + +// schemaBuildDSL returns an API whose Shared result contains only field. +func schemaBuildDSL(field string) func() { + return func() { + shared := dsl.Type("Shared", func() { + dsl.Attribute(field, dsl.String) + }) + dsl.API("test", func() { + dsl.Server("test", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("testService", func() { + dsl.Method("show", func() { + dsl.Result(shared) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index b424197564..1c17cbf9dc 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -1,5 +1,5 @@ -// This file builds OpenAPI v2 operations and schemas from evaluated HTTP -// endpoints, using exact request and response owners for generated examples. +// This file builds Swagger 2.0 operations and schemas from HTTP endpoints. It +// uses the request or response being described to choose each example value. package openapiv2 import ( @@ -17,9 +17,23 @@ import ( openapiinternal "goa.design/goa/v3/http/codegen/openapi/internal" ) -// NewV2 returns the OpenAPI v2 specification for the given API using examples -// from generator. -func NewV2(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerator) (*V2, error) { +// NewV2 returns the OpenAPI v2 specification for the given API. +func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { + if root == nil { + return nil, nil + } + return NewV2WithValues( + root, + h, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// NewV2WithValues returns the OpenAPI v2 specification using values in place +// of matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewV2WithValues(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerator, values openapi.Values) (*V2, error) { if root == nil { return nil, nil } @@ -34,12 +48,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerat if !openapi.MustGenerate(root.API.Servers[0].Meta) || !openapi.MustGenerate(h.Meta) { host = "" } + schemas := newSchemaBuilder(values) + var contact *expr.ContactExpr + if root.API.Contact != nil { + contactCopy := *root.API.Contact + contact = &contactCopy + } + var license *expr.LicenseExpr + if root.API.License != nil { + licenseCopy := *root.API.License + license = &licenseCopy + } basePath := root.API.HTTP.Path if hasAbsoluteRoutes(root) { basePath = "" } - params := paramsFromExpr(nil, root.API.HTTP.Params, basePath) + params := paramsFromExpr(nil, root.API.HTTP.Params, basePath, values) var paramMap map[string]*Parameter if len(params) > 0 { paramMap = make(map[string]*Parameter, len(params)) @@ -50,23 +75,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerat s := &V2{ Swagger: "2.0", Info: &Info{ - Title: root.API.Title, - Description: root.API.Description, + Title: values.Title(root.API, root.API.Title), + Description: values.Description(root.API, root.API.Description), TermsOfService: root.API.TermsOfService, - Contact: root.API.Contact, - License: root.API.License, + Contact: contact, + License: license, Version: root.API.Version, Extensions: openapi.ExtensionsFromExpr(root.API.Meta), }, Host: host, BasePath: basePath, Paths: make(map[string]any), - Consumes: root.API.HTTP.Consumes, - Produces: root.API.HTTP.Produces, + Consumes: slices.Clone(root.API.HTTP.Consumes), + Produces: slices.Clone(root.API.HTTP.Produces), Parameters: paramMap, Tags: tags, - SecurityDefinitions: securitySpecFromExpr(root), - ExternalDocs: openapi.DocsFromExpr(root.API.Docs, root.API.Meta), + SecurityDefinitions: securitySpecFromExpr(root, values), + ExternalDocs: openapi.DocsFromExprWithValues(root.API.Docs, root.API.Meta, values), } for _, res := range root.API.HTTP.Services { if !openapi.MustGenerate(res.Meta) || !openapi.MustGenerate(res.ServiceExpr.Meta) { @@ -77,24 +102,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerat if !openapi.MustGenerate(fs.Meta) || !openapi.MustGenerate(fs.Service.Meta) { continue } - buildPathFromFileServer(s, root, fs, generator) + buildPathFromFileServer(s, root, fs, schemas, generator, values) } for _, a := range res.HTTPEndpoints { if !openapi.MustGenerate(a.Meta) || !openapi.MustGenerate(a.MethodExpr.Meta) { continue } for _, route := range a.Routes { - buildPathFromExpr(s, root, h, route, basePath, generator) + buildPathFromExpr(s, root, h, route, basePath, schemas, generator, values) } } } - if len(openapi.Definitions) > 0 { - s.Definitions = make(map[string]*openapi.Schema) - for n, d := range openapi.Definitions { - // sad but swagger doesn't support these + if len(schemas.definitions) > 0 { + s.Definitions = schemas.definitions + for _, d := range schemas.definitions { + // Swagger 2.0 does not support media metadata or schema links. d.Media = nil d.Links = nil - s.Definitions[n] = d } } // Convert OpenAPI 3.0 references (#/$defs/) to Swagger 2.0 format (#/definitions/) @@ -143,14 +167,20 @@ func addScopeDescription(scopes []*expr.ScopeExpr, sd *SecurityDefinition) { // securitySpecFromExpr generates the OpenAPI security definitions from the // security design. -func securitySpecFromExpr(root *expr.RootExpr) map[string]*SecurityDefinition { +func securitySpecFromExpr(root *expr.RootExpr, values openapi.Values) map[string]*SecurityDefinition { sds := make(map[string]*SecurityDefinition) for _, svc := range root.API.HTTP.Services { + if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { + continue + } for _, e := range svc.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, req := range e.Requirements { for _, s := range req.Schemes { sd := SecurityDefinition{ - Description: s.Description, + Description: values.Description(s.AuthoredScheme(), s.Description), Extensions: openapi.ExtensionsFromExpr(s.Meta), } @@ -273,7 +303,7 @@ func summaryFromMeta(name string, meta expr.MetaExpr) string { return name } -func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string) []*Parameter { +func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string, values openapi.Values) []*Parameter { if params == nil { return nil } @@ -290,14 +320,14 @@ func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttribut if endpoint != nil && in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - param := paramFor(at, pn, in, required) + param := paramFor(at, pn, in, required, values) res = append(res, param) return nil }) return res } -func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { +func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr, values openapi.Values) []*Parameter { var params []*Parameter expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -305,21 +335,21 @@ func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required)) + params = append(params, paramFor(att, elem, "header", required, values)) return nil }) return params } -func paramFor(at *expr.AttributeExpr, name, in string, required bool) *Parameter { +func paramFor(at *expr.AttributeExpr, name, in string, required bool, values openapi.Values) *Parameter { alias := at at = resolvedAliasAttribute(at) p := &Parameter{ In: in, Name: name, Default: openapi.ToStringMap(at.DefaultValue), - Description: at.Description, + Description: values.Description(alias.AuthoredAttribute(), at.Description), Required: required, } p.Type, p.Format = openAPITypeFormat(at) @@ -345,7 +375,7 @@ func itemsFromExpr(at *expr.AttributeExpr) *Items { return items } -func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string, generator *expr.ExampleGenerator) *Response { +func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string, schemas *schemaBuilder, generator *expr.ExampleGenerator, fallbackDescription string, values openapi.Values) *Response { var schema *openapi.Schema if mt, ok := r.Body.Type.(*expr.ResultTypeExpr); ok { view := expr.DefaultView @@ -353,15 +383,19 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, view = v } schema = openapi.NewSchema() - schema.Ref = openapi.ResultTypeRefWithPrefix(root.API, mt, view, typeNamePrefix, generator) + projection := openapi.ProjectResponseResult(mt, view) + schema.Ref = schemas.projectedResultTypeRefWithPrefix(root.API, mt, projection.Result, typeNamePrefix, generator) } else if r.Body.Type != expr.Empty { - schema = openapi.AttributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix, generator) + schema = schemas.attributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix, generator) } if schema != nil { schema.Extensions = openapi.ExtensionsFromExpr(r.Meta) } - headers := headersFromExpr(r.Headers) - desc := r.Description + headers := headersFromExpr(r.Headers, values) + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -373,7 +407,7 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, } } -func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { +func headersFromExpr(headers *expr.MappedAttributeExpr, values openapi.Values) map[string]*Header { if headers == nil { return nil } @@ -382,7 +416,7 @@ func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { headerType, headerFormat := openAPITypeFormat(at) header := &Header{ Default: at.DefaultValue, - Description: at.Description, + Description: values.Description(at.AuthoredAttribute(), at.Description), Type: headerType, Format: headerFormat, } @@ -438,7 +472,7 @@ func initAttributeValidations(at *expr.AttributeExpr, def any) { initValidations(at, def) } -func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr, generator *expr.ExampleGenerator) { +func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { for _, path := range fs.RequestPaths { wcs := expr.ExtractHTTPWildcards(path) var param []*Parameter @@ -460,7 +494,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } if len(wcs) > 0 { errgen := generator.At(expr.UserTypeExampleIdentity(expr.ErrorResult)) - schema := openapi.TypeSchema(root.API, expr.ErrorResult, errgen) + schema := schemas.typeSchema(root.API, expr.ErrorResult, errgen) responses["404"] = &Response{Description: "File not found", Schema: schema} } @@ -481,9 +515,9 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } operation := &Operation{ - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summaryFromMeta(fmt.Sprintf("Download %s", fs.FilePath), fs.Meta), - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), OperationID: operationID, Parameters: param, Responses: responses, @@ -507,7 +541,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } } -func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string, generator *expr.ExampleGenerator) { +func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { endpoint := route.Endpoint tagNames := openapi.TagNamesFromExpr(endpoint.Meta) @@ -519,8 +553,8 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - params := paramsFromExpr(endpoint, endpoint.Params, key) - params = append(params, paramsFromHeaders(endpoint)...) + params := paramsFromExpr(endpoint, endpoint.Params, key, values) + params = append(params, paramsFromHeaders(endpoint, values)...) var produces []string responses := make(map[string]*Response, len(endpoint.Responses)) @@ -535,7 +569,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr r.StatusCode = expr.StatusSwitchingProtocols } } - resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name(), responseGenerator) + resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name(), schemas, responseGenerator, "", values) responses[strconv.Itoa(r.StatusCode)] = resp if r.ContentType != "" { foundCT := slices.Contains(produces, r.ContentType) @@ -546,7 +580,12 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } for _, er := range endpoint.HTTPErrors { responseGenerator := generator.At(expr.ErrorResponseBodyExampleIdentity(endpoint, er)) - resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name(), responseGenerator) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name(), schemas, responseGenerator, errorDescription, values) + resp.Description = er.Name + ": " + resp.Description + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, responseGenerator, values); ok { + resp.Examples = map[string]any{openapi.ResponseContentType(er.Response): example} + } responses[strconv.Itoa(er.Response.StatusCode)] = resp } @@ -563,9 +602,9 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr pp := &Parameter{ Name: endpoint.Body.Type.Name(), In: in, - Description: endpoint.Body.Description, + Description: values.Description(endpoint.Body.AuthoredAttribute(), endpoint.Body.Description), Required: true, - Schema: openapi.AttributeTypeSchemaWithPrefix( + Schema: schemas.attributeTypeSchemaWithPrefix( root.API, endpoint.Body, codegen.Goify(endpoint.Service.Name(), true), @@ -610,7 +649,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } - description := endpoint.Description() + description := values.Description(endpoint.MethodExpr, endpoint.Description()) var requirements SecurityRequirements if len(endpoint.Requirements) > 0 { @@ -649,7 +688,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr Tags: tagNames, Description: description, Summary: summaryFromExpr(endpoint.Name()+" "+endpoint.Service.Name(), endpoint, root.API.Meta), - ExternalDocs: openapi.DocsFromExpr(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta, values), OperationID: operationID, Parameters: params, Consumes: consumes, diff --git a/http/codegen/openapi/v2/builder_test.go b/http/codegen/openapi/v2/builder_test.go index b5fad4c555..123cef057f 100644 --- a/http/codegen/openapi/v2/builder_test.go +++ b/http/codegen/openapi/v2/builder_test.go @@ -10,9 +10,36 @@ import ( "goa.design/goa/v3/codegen" dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" "gopkg.in/yaml.v3" ) +func TestNewV2WithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + operation := spec.Paths["/messages"].(*Path).Get + require.Equal(t, "Localized method description", operation.Description) + require.Contains(t, operation.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + func TestBuildPathFromFileServer(t *testing.T) { cases := []struct { path string @@ -49,7 +76,7 @@ func TestBuildPathFromFileServer(t *testing.T) { }, RequestPaths: []string{tc.path}, } - buildPathFromFileServer(s, root, fs, expr.NewExampleGenerator(root.API.RandomizerFactory)) + buildPathFromFileServer(s, root, fs, newSchemaBuilder(openapi.Values{}), expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) for actual := range s.Paths { if actual != tc.expected { t.Errorf("got %#v, expected %#v", actual, tc.expected) @@ -61,7 +88,7 @@ func TestBuildPathFromFileServer(t *testing.T) { func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) require.NoError(t, err) cases := map[string]struct { @@ -99,7 +126,7 @@ func TestNoSecurityOverridesAPISecurity(t *testing.T) { func TestNoSecurityOverridesServiceSecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesServiceSecurityDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) require.NoError(t, err) cases := map[string]struct { @@ -137,7 +164,7 @@ func TestNoSecurityOverridesServiceSecurity(t *testing.T) { func TestStreamingResponseStatusCodes(t *testing.T) { root := codegen.RunDSL(t, streamingResponseStatusDSL) - spec, err := NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) require.NoError(t, err) sseResponses := spec.Paths["/sse"].(*Path).Get.Responses @@ -195,6 +222,19 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecurityDefinitionsIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + require.NoError(t, err) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.SecurityDefinitions, visible) + require.NotContains(t, spec.SecurityDefinitions, hiddenMethod) + require.NotContains(t, spec.SecurityDefinitions, hiddenService) +} + var noSecurityOverridesAPISecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -221,6 +261,66 @@ var noSecurityOverridesAPISecurityDSL = func() { }) } +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + var noSecurityOverridesServiceSecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -334,7 +434,7 @@ func TestBuildPathFromExpr(t *testing.T) { basePath := "/" generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) - buildPathFromExpr(s, root, h, route, basePath, generator) + buildPathFromExpr(s, root, h, route, basePath, newSchemaBuilder(openapi.Values{}), generator, openapi.Values{}) for _, path := range s.Paths { actual := path.(*Path).Post if len(actual.Consumes) != len(tc.expected.Consumes) { diff --git a/http/codegen/openapi/v2/description_ownership_test.go b/http/codegen/openapi/v2/description_ownership_test.go new file mode 100644 index 0000000000..c10e3028ec --- /dev/null +++ b/http/codegen/openapi/v2/description_ownership_test.go @@ -0,0 +1,52 @@ +// This file verifies that shared OpenAPI v2 definitions use the named Goa +// type description instead of text from one response that uses the type. +package openapiv2 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorDefinitionDescription(t *testing.T) { + cases := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := codegen.RunDSL(t, tc.dsl) + spec, err := NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + require.Equal(t, tc.description, spec.Definitions["SharedError"].Description) + }) + } +} + +func TestSharedErrorDefinitionLocalizedDescription(t *testing.T) { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized shared error", spec.Definitions["SharedError"].Description) +} diff --git a/http/codegen/openapi/v2/files.go b/http/codegen/openapi/v2/files.go index 4687f48a7c..7c43d2174a 100644 --- a/http/codegen/openapi/v2/files.go +++ b/http/codegen/openapi/v2/files.go @@ -1,6 +1,5 @@ -// This file renders a prepared HTTP design as Swagger 2.0 JSON and YAML files. -// Callers provide the run-owned example coordinator, and the builder derives -// every example stream from the HTTP expression represented in the document. +// This file builds Swagger 2.0 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv2 import ( @@ -12,8 +11,20 @@ import ( // Files returns the Swagger 2.0 specification files in JSON and YAML formats. // path is the output path of the files relative to the gen directory, without // extension. -func Files(root *expr.RootExpr, path string, generator *expr.ExampleGenerator) ([]*codegen.File, error) { - spec, err := NewV2(root, root.API.Servers[0].Hosts[0], generator) +func Files(root *expr.RootExpr, path string) ([]*codegen.File, error) { + return FilesWithValues( + root, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns Swagger 2.0 files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, path string, generator *expr.ExampleGenerator, values openapi.Values) ([]*codegen.File, error) { + spec, err := NewV2WithValues(root, root.API.Servers[0].Hosts[0], generator, values) if err != nil { return nil, err } diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go index 29122083d1..52bb5247a5 100644 --- a/http/codegen/openapi/v2/files_test.go +++ b/http/codegen/openapi/v2/files_test.go @@ -35,6 +35,7 @@ func TestSections(t *testing.T) { {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, {"with-spaces", testdata.WithSpacesDSL}, @@ -54,13 +55,13 @@ func TestSections(t *testing.T) { {"additional-properties-type", testdata.AdditionalPropertiesTypeDSL}, {"additional-properties-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, {"additional-properties-embedded-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, + {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) if err != nil { t.Fatalf("OpenAPI failed with %s", err) } @@ -114,10 +115,8 @@ func TestValidations(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") require.NotEmpty(t, oFiles, "No swagger files") for i, o := range oFiles { @@ -158,10 +157,8 @@ func TestExtensions(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles, err := openapiv2.Files(root, openapi.DefaultPath20, expr.NewExampleGenerator(root.API.RandomizerFactory)) + oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") require.NotEmpty(t, oFiles, "No swagger files") for i, o := range oFiles { @@ -191,9 +188,6 @@ func TestExtensions(t *testing.T) { } func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) - root := expr.RunDSL(t, func() { var UUID = dsl.Type("UUID", dsl.String, func() { dsl.Format(dsl.FormatUUID) @@ -235,7 +229,7 @@ func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { }) }) - spec, err := openapiv2.NewV2(root, root.API.Servers[0].Hosts[0], expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec, err := openapiv2.NewV2(root, root.API.Servers[0].Hosts[0]) require.NoError(t, err) path, ok := spec.Paths["/repro"] diff --git a/http/codegen/openapi/v2/json_schema.go b/http/codegen/openapi/v2/json_schema.go new file mode 100644 index 0000000000..cc85615c9f --- /dev/null +++ b/http/codegen/openapi/v2/json_schema.go @@ -0,0 +1,306 @@ +// This file builds the JSON schemas placed in one Swagger 2.0 document. Each +// build keeps its definitions and assigned type names in its own builder. +package openapiv2 + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +type ( + // schemaBuilder builds every schema used by one Swagger document. + schemaBuilder struct { + definitions map[string]*openapi.Schema + definitionNames map[*expr.ResultTypeExpr]string + values openapi.Values + } +) + +// newSchemaBuilder starts a schema build with no definitions or assigned names. +func newSchemaBuilder(values openapi.Values) *schemaBuilder { + return &schemaBuilder{ + definitions: make(map[string]*openapi.Schema), + definitionNames: make(map[*expr.ResultTypeExpr]string), + values: values, + } +} + +// BuildAttributeSchema returns the JSON schema for at. The returned schema +// includes every named definition referenced by at. +func BuildAttributeSchema(api *expr.APIExpr, at *expr.AttributeExpr, generator *expr.ExampleGenerator) *openapi.Schema { + builder := newSchemaBuilder(openapi.Values{}) + schema := builder.attributeTypeSchemaWithPrefix(api, at, "", generator) + if len(builder.definitions) > 0 { + schema.Defs = builder.definitions + } + return schema +} + +// resultTypeRefWithPrefix returns a reference to the requested result view. It +// adds the definition to this builder the first time the result is used. +func (b *schemaBuilder) resultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string, gen *expr.ExampleGenerator) string { + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + return b.projectedResultTypeRefWithPrefix(api, mt, projected, prefix, gen) +} + +// projectedResultTypeRefWithPrefix adds a result type that was already +// projected for one HTTP response. +func (b *schemaBuilder) projectedResultTypeRefWithPrefix(api *expr.APIExpr, source, projected *expr.ResultTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + var metaName string + if n, ok := source.Meta["openapi:typename"]; ok { + metaName = codegen.Goify(n[0], true) + } + name := projected.TypeName + if metaName != "" { + name = metaName + } + if assigned, ok := b.definitionNames[projected]; ok { + // expr.Project can return the original result type. Reuse the name chosen + // when this build first saw that result. + name = assigned + } else { + if _, ok := b.definitions[name]; !ok { + name = codegen.Goify(prefix, true) + codegen.Goify(name, true) + if metaName != "" { + name = metaName + } + } + if projected == source { + // Keep the chosen name here instead of changing the design result. + b.definitionNames[projected] = name + } + } + if _, ok := b.definitions[name]; !ok { + b.generateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView, gen) + } + return fmt.Sprintf("#/$defs/%s", name) +} + +// typeRefWithPrefix returns a reference to a user type. It adds the definition +// to this builder the first time the type is used. +func (b *schemaBuilder) typeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + typeName := ut.TypeName + if prefix != "" { + typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) + } + if n, ok := ut.Meta["openapi:typename"]; ok { + typeName = codegen.Goify(n[0], true) + } + if _, ok := b.definitions[typeName]; !ok { + b.generateTypeDefinitionWithName(api, ut, typeName, gen) + } + return fmt.Sprintf("#/$defs/%s", typeName) +} + +// generateResultTypeDefinition adds the requested result view unless this +// build already has a definition with the same name. +func (b *schemaBuilder) generateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[mt.TypeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) + b.definitions[mt.TypeName] = schema + b.buildResultTypeSchema(api, mt, view, schema, gen) +} + +// generateTypeDefinitionWithName adds the user type under typeName unless this +// build already has a definition with that name. +func (b *schemaBuilder) generateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[typeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = typeName + b.definitions[typeName] = schema + b.buildAttributeSchema(api, schema, ut.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(ut))) +} + +// typeSchema builds a schema for t and adds any named definitions it uses to +// this builder. +func (b *schemaBuilder) typeSchema(api *expr.APIExpr, t expr.DataType, gen *expr.ExampleGenerator) *openapi.Schema { + return b.typeSchemaWithPrefix(api, t, "", gen) +} + +// typeSchemaWithPrefix builds a schema for t and adds prefix to new named +// definitions created while walking the type. +func (b *schemaBuilder) typeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := openapi.NewSchema() + switch actual := t.(type) { + case expr.Primitive: + schema.Type = openapi.Type(actual.Name()) + switch actual.Kind() { + case expr.AnyKind: + // Leaving the type empty allows every JSON value. + schema.Type = openapi.Type("") + case expr.IntKind, expr.Int64Kind, + expr.UIntKind, expr.UInt64Kind: + schema.Type = openapi.Integer + schema.Format = "int64" + case expr.Int32Kind, expr.UInt32Kind: + schema.Type = openapi.Integer + schema.Format = "int32" + case expr.Float32Kind: + schema.Type = openapi.Number + schema.Format = "float" + case expr.Float64Kind: + schema.Type = openapi.Number + schema.Format = "double" + case expr.BytesKind: + schema.Type = openapi.String + schema.Format = "byte" + } + case *expr.Array: + schema.Type = openapi.Array + schema.Items = openapi.NewSchema() + b.buildAttributeSchema(api, schema.Items, actual.ElemType, gen.ArrayElement(0)) + case *expr.Object: + schema.Type = openapi.Object + for _, nat := range *actual { + if !openapi.MustGenerate(nat.Attribute.Meta) { + continue + } + property := openapi.NewSchema() + b.buildAttributeSchema(api, property, nat.Attribute, gen.Member(nat.Name)) + schema.Properties[nat.Name] = property + } + case *expr.Map: + schema.Type = openapi.Object + if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { + value := openapi.NewSchema() + schema.AdditionalProperties = b.buildAttributeSchema(api, value, actual.ElemType, gen.MapValue(0)) + } else { + schema.AdditionalProperties = true + } + case *expr.Union: + typeKey := actual.GetTypeKey() + valueKey := actual.GetValueKey() + schema.Type = openapi.Object + for _, val := range actual.Values { + valueSchema := b.typeSchemaWithPrefix(api, val.Attribute.Type, prefix, gen.UnionMember(val.Name)) + initSchemaValidation(valueSchema, val.Attribute) + schema.AnyOf = append(schema.AnyOf, &openapi.Schema{ + Type: openapi.Object, + Properties: map[string]*openapi.Schema{ + typeKey: { + Type: openapi.String, + Enum: []any{val.Name}, + }, + valueKey: valueSchema, + }, + Required: []string{typeKey, valueKey}, + }) + } + case *expr.UserTypeExpr: + if expr.IsAlias(actual) { + schema = b.typeSchemaWithPrefix(api, actual.Attribute().Type, prefix, gen.At(expr.UserTypeExampleIdentity(actual))) + initSchemaValidation(schema, actual.Attribute()) + break + } + schema.Ref = b.typeRefWithPrefix(api, actual, prefix, gen) + case *expr.ResultTypeExpr: + schema.Ref = b.resultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix, gen) + } + return schema +} + +// attributeTypeSchemaWithPrefix builds a schema for at, including its +// validation rules, and adds prefix to new named definitions. +func (b *schemaBuilder) attributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := b.typeSchemaWithPrefix(api, at.Type, prefix, gen) + initSchemaValidation(schema, at) + return schema +} + +// buildAttributeSchema fills schema with the type, example, description, and +// validation rules from at. +func (b *schemaBuilder) buildAttributeSchema(api *expr.APIExpr, schema *openapi.Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *openapi.Schema { + schema.Merge(b.typeSchemaWithPrefix(api, at.Type, "", gen)) + if schema.Ref != "" { + return schema + } + schema.DefaultValue = openapi.ToStringMap(at.DefaultValue) + if description := b.values.Description(at.AuthoredAttribute(), at.Description); description != "" { + schema.Description = description + } + schema.Example = openapi.ProjectExample(at, b.values.Example(at, gen)) + schema.Extensions = openapi.ExtensionsFromExpr(at.Meta) + if additional := openapi.AdditionalPropertiesFromExpr(at.Meta); additional != nil { + schema.AdditionalProperties = additional + } + initSchemaValidation(schema, at) + return schema +} + +// initSchemaValidation copies the validation rules from at into schema. +func initSchemaValidation(schema *openapi.Schema, at *expr.AttributeExpr) { + validation := at.Validation + if validation == nil { + return + } + schema.Enum = validation.Values + if validation.Format != "" { + schema.Format = string(validation.Format) + } + schema.Pattern = validation.Pattern + if validation.ExclusiveMinimum != nil { + schema.ExclusiveMinimum = validation.ExclusiveMinimum + } + if validation.Minimum != nil { + schema.Minimum = validation.Minimum + } + if validation.ExclusiveMaximum != nil { + schema.ExclusiveMaximum = validation.ExclusiveMaximum + } + if validation.Maximum != nil { + schema.Maximum = validation.Maximum + } + if validation.MinLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MinItems = validation.MinLength + } else { + schema.MinLength = validation.MinLength + } + } + if validation.MaxLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MaxItems = validation.MaxLength + } else { + schema.MaxLength = validation.MaxLength + } + } + for _, name := range validation.Required { + if attribute := at.Find(name); attribute != nil && !openapi.MustGenerate(attribute.Meta) { + continue + } + schema.Required = append(schema.Required, name) + } +} + +// renamedResultType returns rt with name without changing the design result. +func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { + if rt.TypeName == name { + return rt + } + userType := *rt.UserTypeExpr + userType.TypeName = name + result := *rt + result.UserTypeExpr = &userType + return &result +} + +// buildResultTypeSchema fills schema with the requested result view. +func (b *schemaBuilder) buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, schema *openapi.Schema, gen *expr.ExampleGenerator) { + schema.Media = &openapi.Media{Type: mt.Identifier} + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + b.buildAttributeSchema(api, schema, projected.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(projected))) +} diff --git a/http/codegen/openapi/v2/json_schema_union_test.go b/http/codegen/openapi/v2/json_schema_union_test.go new file mode 100644 index 0000000000..4c8d1d434d --- /dev/null +++ b/http/codegen/openapi/v2/json_schema_union_test.go @@ -0,0 +1,116 @@ +// This file checks that each Swagger union choice pairs its name with the +// schema for the matching value. +package openapiv2 + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := newSchemaBuilder(openapi.Values{}).attributeTypeSchemaWithPrefix( + &expr.APIExpr{}, + unionAttribute(), + "", + generator, + ) + + require.Len(t, schema.AnyOf, 2) + assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.String) + assertUnionSchemaBranch(t, schema.AnyOf[1], "count", openapi.Integer) + assert.Empty(t, schema.Properties) +} + +func TestBuildAttributeSchemaKeepsDefinitionsSeparate(t *testing.T) { + type result struct { + field string + schema *openapi.Schema + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(field string) { + ready.Done() + <-start + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(field)) + results <- result{ + field: field, + schema: BuildAttributeSchema( + &expr.APIExpr{}, + namedObjectAttribute(field), + generator, + ), + } + } + go build("first") + go build("second") + ready.Wait() + close(start) + + for range 2 { + built := <-results + require.Equal(t, "#/$defs/Shared", built.schema.Ref) + require.Contains(t, built.schema.Defs, "Shared") + require.Contains(t, built.schema.Defs["Shared"].Properties, built.field) + other := "first" + if built.field == "first" { + other = "second" + } + require.NotContains(t, built.schema.Defs["Shared"].Properties, other) + _, err := json.Marshal(built.schema) + require.NoError(t, err) + } +} + +// assertUnionSchemaBranch checks one generated union branch's tag, required +// fields, and value type. +func assertUnionSchemaBranch(t *testing.T, branch *openapi.Schema, tag string, valueType openapi.Type) { + t.Helper() + assert.Equal(t, openapi.Type(openapi.Object), branch.Type) + assert.Equal(t, []string{"type", "value"}, branch.Required) + require.Contains(t, branch.Properties, "type") + assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) + require.Contains(t, branch.Properties, "value") + assert.Equal(t, valueType, branch.Properties["value"].Type) +} + +// unionAttribute returns the string-or-integer union used by these schema tests. +func unionAttribute() *expr.AttributeExpr { + return &expr.AttributeExpr{ + Type: &expr.Union{ + TypeName: "outcome", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + }, + } +} + +// namedObjectAttribute returns a named object with one field. +func namedObjectAttribute(field string) *expr.AttributeExpr { + object := expr.Object{ + &expr.NamedAttributeExpr{ + Name: field, + Attribute: &expr.AttributeExpr{Type: expr.String}, + }, + } + return &expr.AttributeExpr{ + Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &object}, + TypeName: "Shared", + }, + } +} diff --git a/http/codegen/openapi/v2/openapi.go b/http/codegen/openapi/v2/openapi.go index 828d575689..49b3ed3371 100644 --- a/http/codegen/openapi/v2/openapi.go +++ b/http/codegen/openapi/v2/openapi.go @@ -164,6 +164,8 @@ type ( Schema *openapi.Schema `json:"schema,omitempty" yaml:"schema,omitempty"` // Headers is a list of headers that are sent with the response. Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + // Examples contains one response body example for each content type. + Examples map[string]any `json:"examples,omitempty" yaml:"examples,omitempty"` // Ref references a global API response. // This field is exclusive with the other fields of Response. Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` diff --git a/http/codegen/openapi/v2/public_api_test.go b/http/codegen/openapi/v2/public_api_test.go new file mode 100644 index 0000000000..a76d66ad7b --- /dev/null +++ b/http/codegen/openapi/v2/public_api_test.go @@ -0,0 +1,100 @@ +// This file protects the released OpenAPI v2 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv2_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +var ( + _ func(*expr.RootExpr, *expr.HostExpr) (*openapiv2.V2, error) = openapiv2.NewV2 + _ func(*expr.RootExpr, string) ([]*codegen.File, error) = openapiv2.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + host := root.API.Servers[0].Hosts[0] + + gotSpec, err := openapiv2.NewV2(root, host) + require.NoError(t, err) + wantSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, wantSpec, gotSpec) + otherSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NoError(t, err) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + require.NoError(t, err) + wantFiles, err := openapiv2.FilesWithValues( + root, + openapi.DefaultPath20, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden new file mode 100644 index 0000000000..a5a71ad4ed --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden @@ -0,0 +1,472 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "ErrorsErrorBadRequestResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorInternalResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorNotFoundResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "GoaCustomError": { + "description": "Error_custom_Response_Body result type (default view)", + "example": { + "message": "error message", + "name": "custom" + }, + "properties": { + "message": { + "example": "error message", + "type": "string" + }, + "name": { + "example": "custom", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "title": "Mediatype identifier: application/vnd.goa.custom-error; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/": { + "get": { + "operationId": "Errors#Error", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "bad_request: Bad Request response.", + "schema": { + "$ref": "#/definitions/ErrorsErrorBadRequestResponseBody" + } + }, + "404": { + "description": "not_found: Not Found response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorNotFoundResponseBody" + } + }, + "409": { + "description": "custom: Conflict response.", + "schema": { + "$ref": "#/definitions/GoaCustomError" + } + }, + "429": { + "description": "retry: Too Many Requests response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryResponseBody" + } + }, + "500": { + "description": "internal: Internal Server Error response.", + "examples": { + "application/vnd.goa.error": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorInternalResponseBody" + } + }, + "503": { + "description": "retry_deadline: Service Unavailable response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryDeadlineResponseBody" + } + }, + "504": { + "description": "deadline: Gateway Timeout response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorDeadlineResponseBody" + } + } + }, + "schemes": [ + "http" + ], + "summary": "Error Errors", + "tags": [ + "Errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden new file mode 100644 index 0000000000..440df43548 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden @@ -0,0 +1,369 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /: + get: + tags: + - Errors + summary: Error Errors + operationId: Errors#Error + responses: + "204": + description: No Content response. + "400": + description: 'bad_request: Bad Request response.' + schema: + $ref: '#/definitions/ErrorsErrorBadRequestResponseBody' + "404": + description: 'not_found: Not Found response.' + schema: + $ref: '#/definitions/ErrorsErrorNotFoundResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false + "409": + description: 'custom: Conflict response.' + schema: + $ref: '#/definitions/GoaCustomError' + "429": + description: 'retry: Too Many Requests response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + schema: + $ref: '#/definitions/ErrorsErrorInternalResponseBody' + examples: + application/vnd.goa.error: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + schema: + $ref: '#/definitions/ErrorsErrorDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true + schemes: + - http +definitions: + ErrorsErrorBadRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorInternalResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorNotFoundResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + GoaCustomError: + title: 'Mediatype identifier: application/vnd.goa.custom-error; view=default' + type: object + properties: + message: + type: string + example: error message + name: + type: string + example: custom + description: Error_custom_Response_Body result type (default view) + example: + message: error message + name: custom + required: + - name + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..c5cb00388f --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden @@ -0,0 +1,144 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "StorageStoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponse" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default", + "type": "array" + }, + "StorageStoredBottleResponseTinyCollection": { + "description": "StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponseTiny" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny", + "type": "array" + }, + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view) (default view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseTinyCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..3a23ea386b --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden @@ -0,0 +1,94 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseCollection' + schemes: + - http + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseTinyCollection' + schemes: + - http +definitions: + StorageStoredBottleResponseCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default' + type: array + items: + $ref: '#/definitions/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StorageStoredBottleResponseTinyCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny' + type: array + items: + $ref: '#/definitions/StoredBottleResponseTiny' + description: StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + StoredBottleResponse: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseTiny: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) (default view) + example: + name: Blue's Cuvee + required: + - name diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden new file mode 100644 index 0000000000..21a752d9a4 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden @@ -0,0 +1,92 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "SharedError", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "first_error: First failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "second_error: Second failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden new file mode 100644 index 0000000000..3287698fb0 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden @@ -0,0 +1,62 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + schema: + $ref: '#/definitions/SharedError' + required: + - message + schemes: + - http + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + schema: + $ref: '#/definitions/SharedError' + required: + - message + schemes: + - http +definitions: + SharedError: + title: SharedError + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden index bdacdb36f3..290850a547 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden @@ -9,12 +9,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -23,8 +21,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -35,7 +31,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -47,12 +43,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -61,8 +55,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -73,7 +65,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden index ba2855ce06..8b2d7825be 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden @@ -44,21 +44,17 @@ definitions: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" TestServiceTestEndpointResponseBody: title: TestServiceTestEndpointResponseBody type: object @@ -71,18 +67,14 @@ definitions: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden index e3551637c1..a9eee0c4bb 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden @@ -7,7 +7,7 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -23,10 +23,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -35,10 +32,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden index 0988ba2ddf..0480b2e804 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden @@ -44,7 +44,7 @@ definitions: type: string example: "" example: - string: "" + string: item GoaFoobar: title: 'Mediatype identifier: application/vnd.goa.foobar; view=default' type: object @@ -54,14 +54,12 @@ definitions: items: $ref: '#/definitions/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" description: Test EndpointOKResponseBody result type (default view) example: bar: - - string: "" - - string: "" + - string: item foo: "" diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden index bfb691c3f4..c359d133a6 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden @@ -7,11 +7,11 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -24,24 +24,18 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": [ - "Molestiae labore nihil sunt." + "item" ] }, "properties": { "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { @@ -53,10 +47,10 @@ }, "foo": { "example": [ - "Molestiae labore nihil sunt." + "item" ], "items": { - "example": "Molestiae labore nihil sunt.", + "example": "item", "type": "string" }, "maxItems": 42, diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden index 01e2f191a9..f1da44a905 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden @@ -42,11 +42,11 @@ definitions: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: title: Foobar type: object @@ -56,22 +56,20 @@ definitions: items: $ref: '#/definitions/Bar' example: - - string: "" - - string: "" + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae labore nihil sunt. + example: item example: - - Molestiae labore nihil sunt. + - item minItems: 0 maxItems: 42 example: bar: - - string: "" - - string: "" + - string: item foo: - - Molestiae labore nihil sunt. + - item diff --git a/http/codegen/openapi/v3/builder.go b/http/codegen/openapi/v3/builder.go index af0dfefc1c..8fba4b222b 100644 --- a/http/codegen/openapi/v3/builder.go +++ b/http/codegen/openapi/v3/builder.go @@ -1,5 +1,5 @@ -// This file builds OpenAPI v3 operations from evaluated HTTP endpoints and -// preserves the exact semantic owner of every displayed example. +// This file builds OpenAPI 3 operations from HTTP endpoints. It uses the +// request or response being described to choose each example value. package openapiv3 import ( @@ -31,9 +31,24 @@ const ( ) // New returns the OpenAPI specification conforming to the given version -// (openapi.Version30 or openapi.Version32) for the given API using examples -// from generator. It returns nil if the design does not define HTTP endpoints. -func New(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenerator) *OpenAPI { +// (openapi.Version30 or openapi.Version32) for the given API. It returns nil if +// the design does not define HTTP endpoints. +func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { + if root == nil || root.API == nil { + return nil + } + return NewWithValues( + root, + ver, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// NewWithValues returns an OpenAPI specification using values in place of +// matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewWithValues(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) *OpenAPI { if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { // No HTTP transport return nil @@ -45,14 +60,14 @@ func New(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenera } var ( - bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver, generator) + bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver, generator, values) - info = buildInfo(root.API, ver) - comps = buildComponents(root, types) - servers = buildServers(root.API.Servers, ver) - paths = buildPaths(root.API.HTTP, bodies, root.API, ver, generator) + info = buildInfo(root.API, ver, values) + comps = buildComponents(root, types, values) + servers = buildServers(root.API.Servers, ver, values) + paths = buildPaths(root.API.HTTP, bodies, root.API, ver, generator, values) security = buildSecurityRequirements(root.API.Requirements) - tags = buildTags(root.API, ver) + tags = buildTags(root.API, ver, values) ) return &OpenAPI{ @@ -67,14 +82,14 @@ func New(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenera } // buildInfo builds the OpenAPI Info object. -func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { - title := api.Title +func buildInfo(api *expr.APIExpr, ver openapi.Version, values openapi.Values) *Info { + title := values.Title(api, api.Title) if title == "" { title = "Goa API" // cannot be empty as per OpenAPI spec } info := &Info{ Title: title, - Description: api.Description, + Description: values.Description(api, api.Description), TermsOfService: api.TermsOfService, Version: api.Version, Extensions: openapi.ExtensionsFromExpr(api.Meta), @@ -101,16 +116,22 @@ func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { } // buildComponents builds the OpenAPI Components object. -func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Components { +func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema, values openapi.Values) *Components { var schemesRef map[string]*SecuritySchemeRef { schemesRef = make(map[string]*SecuritySchemeRef) for _, s := range root.API.HTTP.Services { + if !openapi.MustGenerate(s.Meta) || !openapi.MustGenerate(s.ServiceExpr.Meta) { + continue + } for _, e := range s.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, r := range e.Requirements { for _, sch := range r.Schemes { schemesRef[sch.Hash()] = &SecuritySchemeRef{ - Value: buildSecurityScheme(sch), + Value: buildSecurityScheme(sch, values), } } } @@ -125,7 +146,7 @@ func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Com // buildPaths builds the OpenAPI Paths map with key as the HTTP path string and // the value as the corresponding PathItem object. -func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version, generator *expr.ExampleGenerator) map[string]*PathItem { +func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) map[string]*PathItem { var paths = make(map[string]*PathItem) for _, svc := range h.Services { if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { @@ -144,7 +165,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildOperation(key, r, sbod[e.Name()], generator, api.Meta, ver) + operation := buildOperation(key, r, sbod[e.Name()], generator, api.Meta, ver, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -185,7 +206,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Replace wildcards in the path to OpenAPI path parameter form // e.g. "/ui/{*filepath}" -> "/ui/{filepath}" key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildFileServerOperation(key, f, api) + operation := buildFileServerOperation(key, f, api, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -199,7 +220,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, } // buildOperation builds the OpenAPI Operation object for the given path. -func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version) *Operation { +func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version, values openapi.Values) *Operation { e := r.Endpoint m := e.MethodExpr svc := e.Service @@ -248,9 +269,9 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand ct = "multipart/form-data" } mt := &MediaType{Schema: bodies.RequestBody} - initExamples(mt, e.Body, rand.At(expr.RequestBodyExampleIdentity(e))) + initExamples(mt, e.Body, rand.At(expr.RequestBodyExampleIdentity(e)), values) requestBody = &RequestBodyRef{Value: &RequestBody{ - Description: requestBodyDescription(e), + Description: requestBodyDescription(e, values), Required: e.Body.Type != expr.Empty, Content: map[string]*MediaType{ct: mt}, Extensions: openapi.ExtensionsFromExpr(e.Body.Meta), @@ -260,15 +281,15 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // parameters var params []*ParameterRef { - ps := paramsFromPath(e, key, rand) - ps = append(ps, paramsFromHeadersAndCookies(e, rand)...) + ps := paramsFromPath(e, key, rand, values) + ps = append(ps, paramsFromHeadersAndCookies(e, rand, values)...) if ver == openapi.Version32 && e.UsesSSE() && e.SSE.RequestIDField != "" { // The generated handler reads the Last-Event-ID header directly so // the header does not appear in the endpoint headers expression. att := expr.AsObject(m.Payload.Type).Attribute(e.SSE.RequestIDField) owner := expr.MethodPayloadExampleIdentity(m) identity := exampleFieldIdentity(m.Payload, e.SSE.RequestIDField, owner) - ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand, identity)) + ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand, identity, values)) } if e.MapQueryParams != nil { name := *e.MapQueryParams @@ -311,7 +332,7 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand bodies.ResponseBodies[r.StatusCode] = b } case e.UsesSSE(): - resultCT = responseContentType(r) + resultCT = openapi.ResponseContentType(r) r = r.Dup() r.ContentType = "text/event-stream" } @@ -323,16 +344,13 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand } owner := expr.MethodResultExampleIdentity(m) bodyOwner := expr.ResponseBodyExampleIdentity(e, e.Responses[i]) - resp := responseFromExpr(r, body, rand, m.Result, owner, bodyOwner) + resp := responseFromExpr(r, body, rand, m.Result, owner, bodyOwner, "", values) if ver == openapi.Version32 && e.UsesSSE() { setSSEContent(resp, bodies, resultCT, m.HasMixedResults()) } responses[strconv.Itoa(r.StatusCode)] = &ResponseRef{Value: resp} } for _, er := range e.HTTPErrors { - if er.Description != "" && er.Response.Description == "" { - er.Response.Description = er.Description - } var body *openapi.Schema if er.Response.Body.Type != expr.Empty { bodyIndex := responseBodyIndexes[er.Response.StatusCode] @@ -341,15 +359,16 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand } owner := expr.MethodErrorExampleIdentity(m, er.ErrorExpr) bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, er) - resp := responseFromExpr(er.Response, body, rand, er.AttributeExpr, owner, bodyOwner) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseFromExpr(er.Response, body, rand, er.AttributeExpr, owner, bodyOwner, errorDescription, values) desc := er.Name if resp.Description != nil { desc += ": " + *resp.Description } resp.Description = &desc - if er.Type == expr.ErrorResult && len(er.Response.Body.ExtractUserExamples()) == 0 { + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, rand.At(bodyOwner), values); ok { for _, content := range resp.Content { - content.Example = nil + content.Example = example } } responses[strconv.Itoa(er.Response.StatusCode)] = &ResponseRef{Value: resp} @@ -382,14 +401,14 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand return &Operation{ Tags: tagNames, Summary: summary, - Description: e.Description(), + Description: values.Description(e.MethodExpr, e.Description()), OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), e.Name(), routeIndex), Parameters: params, RequestBody: requestBody, Responses: responses, Security: security, Deprecated: deprecated, - ExternalDocs: openapi.DocsFromExpr(m.Docs, m.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(m.Docs, m.Meta, values), Extensions: openapi.ExtensionsFromMethod(m), } } @@ -398,17 +417,19 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // HTTP body, payload, or referenced type. It uses a deterministic default for // computed bodies so generated OpenAPI requestBody objects are always // self-describing. -func requestBodyDescription(e *expr.HTTPEndpointExpr) string { - if e.Body.Description != "" { - return e.Body.Description +func requestBodyDescription(e *expr.HTTPEndpointExpr, values openapi.Values) string { + if description := values.Description(e.Body.AuthoredAttribute(), e.Body.Description); description != "" { + return description } if ut, ok := e.Body.Type.(expr.UserType); ok { - if desc := ut.Attribute().Description; desc != "" { + if desc := values.Description(ut.Attribute().AuthoredAttribute(), ut.Attribute().Description); desc != "" { return desc } } - if e.MethodExpr.Payload != nil && e.MethodExpr.Payload.Description != "" { - return e.MethodExpr.Payload.Description + if e.MethodExpr.Payload != nil { + if desc := values.Description(e.MethodExpr.Payload.AuthoredAttribute(), e.MethodExpr.Payload.Description); desc != "" { + return desc + } } return defaultRequestBodyDescription(e) } @@ -421,7 +442,7 @@ func defaultRequestBodyDescription(e *expr.HTTPEndpointExpr) string { } // buildFileServerOperation builds the OpenAPI Operation object for the given file server. -func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr) *Operation { +func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr, values openapi.Values) *Operation { wildcards := expr.ExtractHTTPWildcards(key) svc := fs.Service @@ -502,14 +523,14 @@ func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr return &Operation{ OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), key, 0), - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summary, Parameters: params, Responses: responses, Tags: tagNames, Security: buildSecurityRequirements(api.Requirements), Deprecated: false, - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), Extensions: openapi.ExtensionsFromExpr(fs.Meta), } } @@ -543,7 +564,7 @@ func parseOperationIDTemplate(template, service, method string, routeIndex int) // buildServers builds the OpenAPI Server objects from the given server // expressions. -func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { +func buildServers(servers []*expr.ServerExpr, ver openapi.Version, values openapi.Values) []*Server { var svrs []*Server for _, svr := range servers { if !openapi.MustGenerate(svr.Meta) { @@ -555,11 +576,7 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { continue } - var ( - serverVariable = make(map[string]*ServerVariable) - defaultValue any - validationValues []any - ) + serverVariable := make(map[string]*ServerVariable) // Get the first URL expression in the host by default. // Host expression must have at least one URI (validations would have failed @@ -577,10 +594,11 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { // retrieve host variables vars := expr.AsObject(host.Variables.Type) for _, v := range *vars { - defaultValue = v.Attribute.DefaultValue + defaultValue := v.Attribute.DefaultValue + var validationValues []any if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { - validationValues = append(validationValues, v.Attribute.Validation.Values...) + validationValues = append([]any(nil), v.Attribute.Validation.Values...) if defaultValue == nil { defaultValue = v.Attribute.Validation.Values[0] } @@ -590,14 +608,14 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { serverVariable[v.Name] = &ServerVariable{ Enum: validationValues, Default: defaultValue, - Description: host.Variables.Description, + Description: values.Description(v.Attribute.AuthoredAttribute(), v.Attribute.Description), } } } server = &Server{ URL: string(uExpr), - Description: svr.Description, + Description: values.Description(svr, svr.Description), Variables: serverVariable, } if ver == openapi.Version32 { @@ -645,20 +663,21 @@ func buildSecurityRequirements(reqs []*expr.SecurityExpr) SecurityRequirements { // buildSecurityScheme builds the OpenAPI SecurityScheme object from the // top-level security scheme definition. -func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { +func buildSecurityScheme(se *expr.SchemeExpr, values openapi.Values) *SecurityScheme { + description := values.Description(se.AuthoredScheme(), se.Description) var scheme *SecurityScheme switch se.Kind { case expr.BasicAuthKind: scheme = &SecurityScheme{ Type: "http", Scheme: "basic", - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.APIKeyKind: scheme = &SecurityScheme{ Type: "apiKey", - Description: se.Description, + Description: description, In: se.In, Name: se.Name, Extensions: openapi.ExtensionsFromExpr(se.Meta), @@ -672,7 +691,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { Type: "http", Scheme: "bearer", BearerFormat: bearerFormat, - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.OAuth2Kind: @@ -712,7 +731,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } scheme = &SecurityScheme{ Type: "oauth2", - Description: se.Description, + Description: description, Flows: &flows, Extensions: openapi.ExtensionsFromExpr(se.Meta), } @@ -721,7 +740,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } // buildTags builds the OpenAPI Tag object from the API expression. -func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { +func buildTags(api *expr.APIExpr, ver openapi.Version, values openapi.Values) []*openapi.Tag { m := make(map[string]*openapi.Tag) for _, t := range openapi.TagsFromExpr(api.Meta, ver) { m[t.Name] = t @@ -756,7 +775,7 @@ func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { } tags = append(tags, &openapi.Tag{ Name: s.Name(), - Description: s.Description(), + Description: values.Description(s.ServiceExpr, s.Description()), }) } } diff --git a/http/codegen/openapi/v3/builder_test.go b/http/codegen/openapi/v3/builder_test.go index 4dffd2dc07..3d8499df18 100644 --- a/http/codegen/openapi/v3/builder_test.go +++ b/http/codegen/openapi/v3/builder_test.go @@ -69,7 +69,7 @@ func TestBuildInfo(t *testing.T) { License: &expr.LicenseExpr{Name: licenseName, URL: licenseURL}, } - info := buildInfo(api, openapi.Version30) + info := buildInfo(api, openapi.Version30, openapi.Values{}) expected := c.Title if api.Title == "" { @@ -94,9 +94,49 @@ func TestBuildInfo(t *testing.T) { } } +func TestNewWithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec := NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + require.Equal(t, "Localized method description", spec.Paths["/messages"].Get.Description) + require.Contains(t, spec.Paths["/messages"].Get.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) - spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec := New(root, openapi.Version30) cases := map[string]struct { marshal func(any) ([]byte, error) @@ -133,7 +173,7 @@ func TestNoSecurityOverridesAPISecurity(t *testing.T) { func TestNoSecurityOverridesServiceSecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesServiceSecurityDSL) - spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec := New(root, openapi.Version30) cases := map[string]struct { marshal func(any) ([]byte, error) @@ -170,7 +210,7 @@ func TestNoSecurityOverridesServiceSecurity(t *testing.T) { func TestStreamingResponseStatusCodes(t *testing.T) { root := codegen.RunDSL(t, streamingResponseStatusDSL) - spec := New(root, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + spec := New(root, openapi.Version30) sseResponses := spec.Paths["/sse"].Get.Responses require.Contains(t, sseResponses, "200") @@ -183,6 +223,62 @@ func TestStreamingResponseStatusCodes(t *testing.T) { require.NotContains(t, websocketResponses, "200") } +// TestSSEItemSchemaMatchesDataContract verifies OpenAPI describes the same +// presence and encoding used by generated SSE clients and servers. +func TestSSEItemSchemaMatchesDataContract(t *testing.T) { + root := codegen.RunDSL(t, func() { + count := dsl.Type("EventCount", dsl.Int) + dsl.Service("Events", func() { + dsl.Method("Optional", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("RequiredAlias", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", count) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/required-alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("Structured", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/structured") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + spec := New(root, openapi.Version32) + + optional := spec.Paths["/optional"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.NotContains(t, optional.Required, "data") + require.Equal(t, openapi.Type(openapi.String), optional.Properties["data"].Type) + require.Empty(t, optional.Properties["data"].ContentMediaType) + + requiredAlias := spec.Paths["/required-alias"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, requiredAlias.Required, "data") + require.Empty(t, requiredAlias.Properties["data"].ContentMediaType) + + structured := spec.Paths["/structured"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, structured.Required, "data") + require.Equal(t, "application/json", structured.Properties["data"].ContentMediaType) + require.NotNil(t, structured.Properties["data"].ContentSchema) +} + func TestOperationSecurityMarshal(t *testing.T) { securityCases := map[string]struct { operation Operation @@ -229,6 +325,34 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecuritySchemesIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec := New(root, openapi.Version30) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.Components.SecuritySchemes, visible) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenMethod) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenService) +} + +func TestBuildServersKeepsVariableValuesSeparate(t *testing.T) { + root := codegen.RunDSL(t, serverVariablesDSL) + servers := buildServers(root.API.Servers, openapi.Version30, openapi.Values{}) + require.Len(t, servers, 1) + + region := servers[0].Variables["region"] + require.Equal(t, []any{"west", "east"}, region.Enum) + require.Equal(t, "west", region.Default) + require.Equal(t, "Deployment region", region.Description) + + stage := servers[0].Variables["stage"] + require.Equal(t, []any{"test", "production"}, stage.Enum) + require.Equal(t, "production", stage.Default) + require.Equal(t, "Deployment stage", stage.Description) +} + type param struct { Name string In string @@ -238,6 +362,75 @@ type param struct { Type typ } +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + +var serverVariablesDSL = func() { + dsl.API("server variables", func() { + dsl.Server("public", func() { + dsl.Host("production", func() { + dsl.URI("https://{region}.{stage}.example.com") + dsl.Variable("region", dsl.String, "Deployment region", func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + dsl.Variable("stage", dsl.String, "Deployment stage", func() { + dsl.Default("production") + dsl.Enum("test", "production") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) +} + type requestBody struct { Description string Type typ @@ -336,7 +529,7 @@ func TestBuildOperation(t *testing.T) { var types map[string]*openapi.Schema { var bds map[string]map[string]*EndpointBodies - bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) if svc, ok := bds[svcName]; ok { bodies, ok = svc[c.Name] if !ok { @@ -369,7 +562,7 @@ func TestBuildOperation(t *testing.T) { } generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) - op := buildOperation(c.Name, route, bodies, generator, root.API.Meta, openapi.Version30) + op := buildOperation(c.Name, route, bodies, generator, root.API.Meta, openapi.Version30, openapi.Values{}) if op.Description != c.ExpectedDescription { t.Errorf("got description %q for method %q, expected %q", op.Description, c.Name, c.ExpectedDescription) @@ -459,7 +652,7 @@ func TestBuildOperationID(t *testing.T) { for _, e := range s.HTTPEndpoints { for i, r := range e.Routes { generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) - op := buildOperation(c.Name, r, &EndpointBodies{}, generator, api.Meta, openapi.Version30) + op := buildOperation(c.Name, r, &EndpointBodies{}, generator, api.Meta, openapi.Version30, openapi.Values{}) if len(c.ExpectedOperationIDs) == 0 { t.Error("no expected operation IDs") diff --git a/http/codegen/openapi/v3/description_ownership_test.go b/http/codegen/openapi/v3/description_ownership_test.go new file mode 100644 index 0000000000..38e379b709 --- /dev/null +++ b/http/codegen/openapi/v3/description_ownership_test.go @@ -0,0 +1,98 @@ +// This file verifies that shared OpenAPI v3 components use the named Goa type +// description while each response keeps its own error description. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorComponentDescription(t *testing.T) { + versions := []struct { + name string + version openapi.Version + }{ + {"3.0", openapi.Version30}, + {"3.2", openapi.Version32}, + } + designs := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, version := range versions { + t.Run(version.name, func(t *testing.T) { + for _, design := range designs { + t.Run(design.name, func(t *testing.T) { + root := codegen.RunDSL(t, design.dsl) + spec := New( + root, + version.version, + ) + require.Equal(t, design.description, spec.Components.Schemas["SharedError"].Description) + }) + } + }) + } +} + +func TestSharedErrorResponseDescriptions(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + spec := New(root, version) + + first := spec.Paths["/first"].Get.Responses["400"].Value.Description + second := spec.Paths["/second"].Get.Responses["400"].Value.Description + require.NotNil(t, first) + require.NotNil(t, second) + require.Equal(t, "first_error: First failure", *first) + require.Equal(t, "second_error: Second failure", *second) + } +} + +func TestSharedErrorComponentLocalizedDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Equal(t, "Localized shared error", spec.Components.Schemas["SharedError"].Description) + } +} + +func TestUndescribedSharedErrorIgnoresLocalizedResponseDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.UndescribedSharedErrorDSL) + firstError := root.Service("errors").Method("first").Error("first_error") + values := (openapi.Values{}). + WithDescription(firstError, "Localized first failure"). + WithDescription(firstError.AttributeExpr, "Localized first failure") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Empty(t, spec.Components.Schemas["SharedError"].Description) + response := spec.Paths["/first"].Get.Responses["400"].Value.Description + require.NotNil(t, response) + require.Equal(t, "first_error: Localized first failure", *response) + } +} diff --git a/http/codegen/openapi/v3/example.go b/http/codegen/openapi/v3/example.go index 6c1e0291c7..a7eae4c7d7 100644 --- a/http/codegen/openapi/v3/example.go +++ b/http/codegen/openapi/v3/example.go @@ -1,3 +1,4 @@ +// This file adds authored or generated examples to OpenAPI 3 values. package openapiv3 import ( @@ -15,8 +16,13 @@ type ( ) // initExample sets the example or examples of the given object. -func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator) { - examples := attr.ExtractUserExamples() +func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator, values openapi.Values) { + selected := values.Example(attr, r) + if selected == nil { + obj.setExample(nil) + return + } + examples := values.Examples(attr, attr.ExtractUserExamples()) switch { case len(examples) > 1: refs := make(map[string]*ExampleRef, len(examples)) @@ -33,6 +39,6 @@ func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerat case len(examples) > 0: obj.setExample(openapi.ProjectExample(attr, examples[0].Value)) default: - obj.setExample(openapi.Example(attr, r)) + obj.setExample(openapi.ProjectExample(attr, selected)) } } diff --git a/http/codegen/openapi/v3/example_test.go b/http/codegen/openapi/v3/example_test.go new file mode 100644 index 0000000000..8c90ee434a --- /dev/null +++ b/http/codegen/openapi/v3/example_test.go @@ -0,0 +1,33 @@ +// This file checks how OpenAPI 3 objects receive authored and replacement +// examples without changing the evaluated attribute. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestInitExamplesUsesReplacementDescription(t *testing.T) { + first := &expr.ExampleExpr{Summary: "first", Description: "original", Value: "one"} + second := &expr.ExampleExpr{Summary: "second", Description: "unchanged", Value: "two"} + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{first, second}, + } + values := (openapi.Values{}).WithDescription(first, "translated") + media := new(MediaType) + userType := &expr.UserTypeExpr{AttributeExpr: attribute, TypeName: "Message"} + generator := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(userType), + ) + + initExamples(media, attribute, generator, values) + + require.Equal(t, "translated", media.Examples["first"].Value.Description) + require.Equal(t, "unchanged", media.Examples["second"].Value.Description) + require.Equal(t, "original", first.Description) +} diff --git a/http/codegen/openapi/v3/files.go b/http/codegen/openapi/v3/files.go index ae05ddb409..80f63037e0 100644 --- a/http/codegen/openapi/v3/files.go +++ b/http/codegen/openapi/v3/files.go @@ -1,6 +1,5 @@ -// This file renders a prepared HTTP design as OpenAPI 3 JSON and YAML files. -// Callers provide the run-owned example coordinator, and the builder derives -// every example stream from the HTTP expression represented in the document. +// This file builds OpenAPI 3 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv3 import ( @@ -13,6 +12,19 @@ import ( // version (openapi.Version30 or openapi.Version32) in JSON and YAML formats. // path is the output path of the files relative to the gen directory, without // extension. -func Files(root *expr.RootExpr, ver openapi.Version, path string, generator *expr.ExampleGenerator) []*codegen.File { - return openapi.Files(New(root, ver, generator), root.API.Meta, "openapi_v3", path) +func Files(root *expr.RootExpr, ver openapi.Version, path string) []*codegen.File { + return FilesWithValues( + root, + ver, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns OpenAPI files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, ver openapi.Version, path string, generator *expr.ExampleGenerator, values openapi.Values) []*codegen.File { + return openapi.Files(NewWithValues(root, ver, generator, values), root.API.Meta, "openapi_v3", path) } diff --git a/http/codegen/openapi/v3/files_test.go b/http/codegen/openapi/v3/files_test.go index 27f415135e..c2e48e1834 100644 --- a/http/codegen/openapi/v3/files_test.go +++ b/http/codegen/openapi/v3/files_test.go @@ -37,6 +37,7 @@ func TestFiles(t *testing.T) { {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"bearer-security", testdata.BearerSecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, @@ -69,6 +70,7 @@ func TestFiles(t *testing.T) { {"array", testdata.ArrayValidationDSL}, // Error examples {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, // Streaming endpoints: OpenAPI 3.2 constructs must not leak into // 3.0 documents. {"sse-string", testdata.SSEStringDSL}, @@ -80,10 +82,8 @@ func TestFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) for i, o := range oFiles { tname := fmt.Sprintf("file%d", i) s := o.SectionTemplates @@ -136,13 +136,12 @@ func TestFilesV32(t *testing.T) { {"sse-mixed-results", testdata.MixedResultsDSL}, {"websocket", testdata.StreamingResultDSL}, {"alias-type", testdata.AliasTypeDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32, expr.NewExampleGenerator(root.API.RandomizerFactory)) + oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32) wantPaths := []string{ filepath.Join("gen", "http", "openapi3.2.json"), filepath.Join("gen", "http", "openapi3.2.yaml"), @@ -185,6 +184,68 @@ func TestFilesV32(t *testing.T) { } } +// TestAuthoredExampleFixturesIgnoreRandomizer verifies that golden designs +// which do not test generated examples describe every displayed value. +func TestAuthoredExampleFixturesIgnoreRandomizer(t *testing.T) { + cases := []struct { + Name string + DSL func() + Version openapi.Version + }{ + {"alias-type", testdata.AliasTypeDSL, openapi.Version30}, + {"array", testdata.ArrayValidationDSL, openapi.Version30}, + {"headers", testdata.HeadersDSL, openapi.Version30}, + {"not-generate-host", testdata.NotGenerateHostDSL, openapi.Version30}, + {"not-generate-server", testdata.NotGenerateServerDSL, openapi.Version30}, + {"path-with-wildcards", testdata.PathWithWildcardDSL, openapi.Version30}, + {"path-with-multiple-wildcards", testdata.PathWithMultipleWildcardDSL, openapi.Version30}, + {"path-with-multiple-explicit-wildcards", testdata.PathWithMultipleExplicitWildcardDSL, openapi.Version30}, + {"sse-all-fields", testdata.SSEAllFieldsDSL, openapi.Version32}, + {"sse-data-field", testdata.SSEDataFieldDSL, openapi.Version32}, + {"sse-mixed-results", testdata.MixedResultsDSL, openapi.Version32}, + {"sse-object", testdata.SSEObjectDSL, openapi.Version32}, + {"sse-request-id", testdata.SSERequestIDDSL, openapi.Version32}, + {"sse-string", testdata.SSEStringDSL, openapi.Version32}, + {"type-extension", testdata.TypeExtensionDSL, openapi.Version30}, + {"websocket", testdata.StreamingResultDSL, openapi.Version32}, + {"with-any", testdata.WithAnyDSL, openapi.Version30}, + {"with-map", testdata.WithMapDSL, openapi.Version30}, + {"with-spaces", testdata.WithSpacesDSL, openapi.Version30}, + {"with-tags", testdata.WithTagsDSL, openapi.Version32}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + firstRoot := expr.RunDSL(t, c.DSL) + first := openapiv3.NewWithValues( + firstRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("first")), + openapi.Values{}, + ) + firstJSON, err := json.Marshal(first) + if err != nil { + t.Fatalf("failed to encode first document: %s", err) + } + + secondRoot := expr.RunDSL(t, c.DSL) + second := openapiv3.NewWithValues( + secondRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("second")), + openapi.Values{}, + ) + secondJSON, err := json.Marshal(second) + if err != nil { + t.Fatalf("failed to encode second document: %s", err) + } + + if !bytes.Equal(firstJSON, secondJSON) { + t.Error("OpenAPI examples changed with the randomizer") + } + }) + } +} + func validateSwagger(t *testing.T, b []byte) { swagger, err := openapi3.NewLoader().LoadFromData(b) if err == nil { diff --git a/http/codegen/openapi/v3/parameters.go b/http/codegen/openapi/v3/parameters.go index 92321330de..cfc898e60a 100644 --- a/http/codegen/openapi/v3/parameters.go +++ b/http/codegen/openapi/v3/parameters.go @@ -1,5 +1,6 @@ // This file converts HTTP parameters, headers, and cookies into OpenAPI v3 -// values whose schema and displayed examples use the same fresh identity. +// values. Each schema and its displayed example use the same new repeatable +// example key. package openapiv3 import ( @@ -13,7 +14,7 @@ import ( // paramsFromPath computes the OpenAPI spec parameters for the given endpoint // HTTP path and query parameters. -func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var ( res []*Parameter params = endpoint.Params @@ -30,7 +31,7 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa return nil } identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, n, owner) - res = append(res, paramFor(at, pn, in, required, rand, identity)) + res = append(res, paramFor(at, pn, in, required, rand, identity, values)) return nil }) return res @@ -38,7 +39,7 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa // paramsFromHeadersAndCookies computes the OpenAPI spec parameters for the // given endpoint HTTP headers and cookies. -func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var params []*Parameter owner := expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) @@ -48,7 +49,7 @@ func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.Exa } required := endpoint.Headers.IsRequiredNoDefault(name) identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) - params = append(params, paramFor(att, elem, "header", required, rand, identity)) + params = append(params, paramFor(att, elem, "header", required, rand, identity, values)) return nil }) expr.WalkMappedAttr(endpoint.Cookies, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -57,15 +58,16 @@ func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.Exa } required := endpoint.Cookies.IsRequiredNoDefault(name) identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) - params = append(params, paramFor(att, elem, "cookie", required, rand, identity)) + params = append(params, paramFor(att, elem, "cookie", required, rand, identity, values)) return nil }) return params } -// exampleFieldGenerator anchors a detached transport field to its named user -// type or to the explicit semantic owner of an anonymous parent. +// exampleFieldIdentity returns the repeatable example key for a named field. +// Named user types use a key derived from their type; anonymous objects append +// the field name to the key supplied for their parent. func exampleFieldIdentity(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) expr.ExampleIdentity { if typ, ok := parent.Type.(expr.UserType); ok { owner = expr.UserTypeExampleIdentity(typ) @@ -74,16 +76,16 @@ func exampleFieldIdentity(parent *expr.AttributeExpr, name string, owner expr.Ex } // paramFor converts the given attribute into a OpenAPI spec parameter. -func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator, identity expr.ExampleIdentity) *Parameter { +func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator, identity expr.ExampleIdentity, values openapi.Values) *Parameter { param := &Parameter{ Name: name, In: in, - Description: att.Description, + Description: values.Description(att.AuthoredAttribute(), att.Description), AllowEmptyValue: in == "query", Required: required, - Schema: newSchemafier(rand.At(identity)).schemafy(att), + Schema: newSchemafier(rand.At(identity), values).schemafy(att), Extensions: openapi.ExtensionsFromExpr(att.Meta), } - initExamples(param, att, rand.At(identity)) + initExamples(param, att, rand.At(identity), values) return param } diff --git a/http/codegen/openapi/v3/parameters_test.go b/http/codegen/openapi/v3/parameters_test.go index 7981806ac5..52b5d2aa5c 100644 --- a/http/codegen/openapi/v3/parameters_test.go +++ b/http/codegen/openapi/v3/parameters_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" ) func TestParamForAllowEmptyValue(t *testing.T) { @@ -35,6 +36,7 @@ func TestParamForAllowEmptyValue(t *testing.T) { false, generator, identity, + openapi.Values{}, ) require.Equal(t, test.want, param.AllowEmptyValue) @@ -58,6 +60,7 @@ func TestHeaderSchemaAndDisplayedExampleShareIdentity(t *testing.T) { parent, expr.MethodResultExampleIdentity(method), generator, + openapi.Values{}, )["request-id"].Value require.Equal(t, actual.Schema.Example, actual.Example) diff --git a/http/codegen/openapi/v3/public_api_test.go b/http/codegen/openapi/v3/public_api_test.go new file mode 100644 index 0000000000..110e52286c --- /dev/null +++ b/http/codegen/openapi/v3/public_api_test.go @@ -0,0 +1,95 @@ +// This file protects the released OpenAPI v3 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv3_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" +) + +var ( + _ func(*expr.RootExpr, openapi.Version) *openapiv3.OpenAPI = openapiv3.New + _ func(*expr.RootExpr, openapi.Version, string) []*codegen.File = openapiv3.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + + gotSpec := openapiv3.New(root, openapi.Version30) + wantSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, wantSpec, gotSpec) + otherSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) + wantFiles := openapiv3.FilesWithValues( + root, + openapi.Version30, + openapi.DefaultPath30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v3/response.go b/http/codegen/openapi/v3/response.go index 2196c5f6d5..9342dff650 100644 --- a/http/codegen/openapi/v3/response.go +++ b/http/codegen/openapi/v3/response.go @@ -11,7 +11,7 @@ import ( "goa.design/goa/v3/http/codegen/openapi" ) -func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, owner expr.ExampleIdentity, rand *expr.ExampleGenerator) map[string]*HeaderRef { +func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, owner expr.ExampleIdentity, rand *expr.ExampleGenerator, values openapi.Values) map[string]*HeaderRef { o := expr.AsObject(attr.Type) if len(*o) == 0 { return nil @@ -22,22 +22,22 @@ func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, // example survives generator reorderings. identity := exampleFieldIdentity(parent, name, owner) header := &Header{ - Description: hattr.Description, + Description: values.Description(hattr.AuthoredAttribute(), hattr.Description), Required: hattr.IsRequiredNoDefault(name), - Schema: newSchemafier(rand.At(identity)).schemafy(hattr), + Schema: newSchemafier(rand.At(identity), values).schemafy(hattr), Extensions: openapi.ExtensionsFromExpr(hattr.Meta), } - initExamples(header, hattr, rand.At(identity)) + initExamples(header, hattr, rand.At(identity), values) headers[elem] = &HeaderRef{Value: header} return nil }) return headers } -func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr.ExampleGenerator, parent *expr.AttributeExpr, fieldOwner, bodyOwner expr.ExampleIdentity) *Response { - ct := responseContentType(r) - headers := headersFromAttr(r.Headers, parent, fieldOwner, rand) - cookies := headersFromAttr(r.Cookies, parent, fieldOwner, rand) +func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr.ExampleGenerator, parent *expr.AttributeExpr, fieldOwner, bodyOwner expr.ExampleIdentity, fallbackDescription string, values openapi.Values) *Response { + ct := openapi.ResponseContentType(r) + headers := headersFromAttr(r.Headers, parent, fieldOwner, rand, values) + cookies := headersFromAttr(r.Cookies, parent, fieldOwner, rand, values) if len(cookies) > 0 { if headers == nil { headers = make(map[string]*HeaderRef) @@ -68,7 +68,7 @@ func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr Schema: body, Extensions: openapi.ExtensionsFromExpr(r.Body.Meta), } - initExamples(content[ct], staticViewBody(r), rand.At(bodyOwner)) + initExamples(content[ct], staticViewBody(r), rand.At(bodyOwner), values) } else if r.StatusCode != expr.StatusNoContent && isSkipResponseBodyEncodeDecode(r.Parent) { // When SkipResponseBodyEncodeDecode is declared, the response type @@ -83,7 +83,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr } } } - desc := r.Description + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -95,28 +98,12 @@ func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr } } -// responseContentType computes the content type of the given response: the -// explicitly defined content type if any, the content type of the response -// result type otherwise, defaulting to application/json. The result type is -// the view-projected one when the design pins the response to a single view; -// projected result types carry no content type. -func responseContentType(r *expr.HTTPResponseExpr) string { - if r.ContentType != "" { - return r.ContentType - } - if rt, ok := staticViewBody(r).Type.(*expr.ResultTypeExpr); ok && rt.ContentType != "" { - return rt.ContentType - } - return "application/json" -} - // setSSEContent rewrites the content of a successful server-sent events // response for OpenAPI 3.2 documents. The text/event-stream media type // describes each streamed event with an itemSchema instead of a whole-stream -// schema. When the method defines mixed results (distinct unary and streaming -// result types) the unary result is documented under its own content type -// (ct) next to the event stream to reflect the content negotiation performed -// by the generated handler. +// schema. When the method defines separate normal and streaming results, the +// normal result is documented under its own content type next to the event +// stream to match the response selected by the generated handler. func setSSEContent(resp *Response, bodies *EndpointBodies, ct string, mixed bool) { sse := &MediaType{ItemSchema: bodies.SSEItemSchema} if !mixed { diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden index 606b253abe..0ce9b4b10b 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden @@ -5,18 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "properties": { "completed": { "example": [ - "what", - "what", - "what" + "when" ], "items": { "description": "Setup stage.", @@ -26,7 +22,7 @@ "where", "what" ], - "example": "what", + "example": "who", "type": "string" }, "type": "array" @@ -39,7 +35,7 @@ "where", "what" ], - "example": "what", + "example": "who", "type": "string" } }, @@ -61,11 +57,9 @@ "application/json": { "example": { "completed": [ - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" @@ -81,12 +75,9 @@ "application/json": { "example": { "completed": [ - "what", - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden index 4e6b141b14..8d990da2be 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden @@ -21,10 +21,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - what - - what - - what - current: what + - when + current: who responses: "200": description: OK response. @@ -34,11 +32,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - what - - what - - what - - what - current: what + - when + current: who components: schemas: Setup: @@ -49,20 +44,18 @@ components: items: type: string description: Setup stage. - example: what + example: who enum: - who - when - where - what example: - - what - - what - - what + - when current: type: string description: Setup stage. - example: what + example: who enum: - who - when @@ -71,9 +64,7 @@ components: description: Request body for testEndpoint. example: completed: - - what - - what - - what - current: what + - when + current: who tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/array_file0.golden b/http/codegen/openapi/v3/testdata/golden/array_file0.golden index bb1182167c..279166bec7 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -19,24 +19,18 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": [ - "Molestiae labore nihil sunt." + "item" ] }, "properties": { "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { @@ -48,10 +42,10 @@ }, "foo": { "example": [ - "Molestiae labore nihil sunt." + "item" ], "items": { - "example": "Molestiae labore nihil sunt.", + "example": "item", "type": "string" }, "maxItems": 42, @@ -79,53 +73,11 @@ { "bar": [ { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": [ - "Molestiae labore nihil sunt." + "item" ] } ], @@ -134,53 +86,11 @@ { "bar": [ { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" - } - ], - "foo": [ - "Molestiae labore nihil sunt." - ] - }, - { - "bar": [ - { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": [ - "Molestiae labore nihil sunt." + "item" ] } ], diff --git a/http/codegen/openapi/v3/testdata/golden/array_file1.golden b/http/codegen/openapi/v3/testdata/golden/array_file1.golden index e99822397a..0464761079 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file1.golden @@ -22,46 +22,14 @@ paths: $ref: '#/components/schemas/Foobar' example: - bar: - - string: "" - - string: "" + - string: item foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. + - item example: - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" - foo: - - Molestiae labore nihil sunt. - - bar: - - string: "" - - string: "" + - string: item foo: - - Molestiae labore nihil sunt. + - item responses: "200": description: OK response. @@ -80,11 +48,11 @@ components: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: type: object properties: @@ -93,24 +61,22 @@ components: items: $ref: '#/components/schemas/Bar' example: - - string: "" - - string: "" + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae labore nihil sunt. + example: item example: - - Molestiae labore nihil sunt. + - item minItems: 0 maxItems: 42 example: bar: - - string: "" - - string: "" + - string: item foo: - - Molestiae labore nihil sunt. + - item tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden index bc13e7d0aa..d791d2c180 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden @@ -110,6 +110,14 @@ "404": { "content": { "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + }, "schema": { "$ref": "#/components/schemas/Error" } @@ -130,6 +138,78 @@ } }, "description": "custom: Conflict response." + }, + "429": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry: Too Many Requests response." + }, + "500": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "internal: Internal Server Error response." + }, + "503": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry_deadline: Service Unavailable response." + }, + "504": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "deadline: Gateway Timeout response." } }, "summary": "Error Errors", diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden index 1622910033..86b5426dfd 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden @@ -34,6 +34,13 @@ paths: application/vnd.goa.error: schema: $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false "409": description: 'custom: Conflict response.' content: @@ -43,6 +50,58 @@ paths: example: message: error message name: custom + "429": + description: 'retry: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true components: schemas: Error: diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden index 35fb96f744..ff8d2ff822 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden @@ -11,21 +11,21 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 2401899298232532500, + "example": 1, "in": "header", "name": "foo", "schema": { - "example": 2401899298232532500, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 2467648858559780400, + "example": 2, "in": "header", "name": "bar", "schema": { - "example": 2467648858559780400, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden index 501e551175..980d5664b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden @@ -17,16 +17,16 @@ paths: in: header schema: type: integer - example: 2401899298232532419 + example: 1 format: int64 - example: 2401899298232532419 + example: 1 - name: bar in: header schema: type: integer - example: 2467648858559780444 + example: 2 format: int64 - example: 2467648858559780444 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden index a8dcbe7f70..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "A et aut.", + "example": "ok", "schema": { - "example": "A et aut.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden index 9adbcf5d52..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: A et aut. - example: A et aut. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden index a8dcbe7f70..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "A et aut.", + "example": "ok", "schema": { - "example": "A et aut.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden index 9adbcf5d52..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: A et aut. - example: A et aut. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden index a2ac1e19ea..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 2401899298232532500, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 2401899298232532500, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 2467648858559780400, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 2467648858559780400, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden index c4bc342ad4..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 2401899298232532419 + example: 1 format: int64 - example: 2401899298232532419 + example: 1 - name: bar in: path required: true schema: type: integer - example: 2467648858559780444 + example: 2 format: int64 - example: 2467648858559780444 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden index a2ac1e19ea..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 2401899298232532500, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 2401899298232532500, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 2467648858559780400, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 2467648858559780400, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden index c4bc342ad4..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 2401899298232532419 + example: 1 format: int64 - example: 2401899298232532419 + example: 1 - name: bar in: path required: true schema: type: integer - example: 2467648858559780444 + example: 2 format: int64 - example: 2467648858559780444 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden index 1950e852f7..f9d8ece6a3 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 6827506417626806000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 6827506417626806000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden index 3f9f3aee84..e25a083b6d 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 6827506417626806316 + example: 1 format: int64 - example: 6827506417626806316 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..d071c8544e --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden @@ -0,0 +1,175 @@ +{ + "components": { + "schemas": { + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "type": "object" + }, + "StoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponse" + }, + "type": "array" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "StoredBottleResponseTinyCollection": { + "description": "list_tiny_response_body is the result type for an array of StoredBottle (tiny view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponseTiny" + }, + "type": "array" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseTinyCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "storage" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..7cf5b12f9a --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden @@ -0,0 +1,100 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseCollection' + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseTinyCollection' + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +components: + schemas: + StoredBottleResponse: + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StoredBottleResponseTiny: + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) + example: + name: Blue's Cuvee + required: + - name + StoredBottleResponseTinyCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponseTiny' + description: list_tiny_response_body is the result type for an array of StoredBottle (tiny view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +tags: + - name: storage diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden index 675ba8cf8b..438bf890a6 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden @@ -26,7 +26,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden index 29e3c33130..b13193e3f8 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden @@ -7,6 +7,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden new file mode 100644 index 0000000000..ae875bb90c --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden @@ -0,0 +1,95 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden new file mode 100644 index 0000000000..f351f7d4f5 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden index d29cf27737..dd5305c3da 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Voluptatem deleniti." + "id": "request" }, "properties": { "id": { - "example": "Voluptatem deleniti.", + "example": "request", "type": "string" } }, @@ -67,7 +67,7 @@ "content": { "application/json": { "example": { - "id": "Voluptatem deleniti." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden index 0ed3d10669..25de2eaf3c 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Voluptatem deleniti. + id: request responses: "200": description: OK response. @@ -41,10 +41,10 @@ components: properties: id: type: string - example: Voluptatem deleniti. + example: request description: Request body for SSEAllFieldsMethod. example: - id: Voluptatem deleniti. + id: request SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden index 57f431dacc..82dbcf0fe9 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden @@ -4,11 +4,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Quis distinctio vitae ut." + "x": "request" }, "properties": { "x": { - "example": "Quis distinctio vitae ut.", + "example": "request", "type": "string" } }, @@ -19,11 +19,11 @@ }, "Result": { "example": { - "id": "Delectus eum inventore illum velit et." + "id": "result" }, "properties": { "id": { - "example": "Delectus eum inventore illum velit et.", + "example": "result", "type": "string" } }, @@ -47,7 +47,7 @@ "content": { "application/json": { "example": { - "x": "Quis distinctio vitae ut." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -62,7 +62,7 @@ "content": { "text/event-stream": { "example": { - "id": "Delectus eum inventore illum velit et." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden index eefc2d65e1..9700c24d35 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Quis distinctio vitae ut. + x: request responses: "200": description: OK response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Delectus eum inventore illum velit et. + id: result components: schemas: Payload: @@ -37,10 +37,10 @@ components: properties: x: type: string - example: Quis distinctio vitae ut. + example: request description: Request body for Create. example: - x: Quis distinctio vitae ut. + x: request required: - x Result: @@ -48,9 +48,9 @@ components: properties: id: type: string - example: Delectus eum inventore illum velit et. + example: result example: - id: Delectus eum inventore illum velit et. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden index 82cd2d5729..1c30e65baf 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "text/event-stream": { - "example": "Voluptatem non provident rem consequatur.", + "example": "event", "schema": { - "example": "Voluptatem non provident rem consequatur.", + "example": "event", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden index cb3dfd290b..2eb08cbbbe 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden @@ -19,8 +19,8 @@ paths: text/event-stream: schema: type: string - example: Voluptatem non provident rem consequatur. - example: Voluptatem non provident rem consequatur. + example: event + example: event components: {} tags: - name: SSEStringService diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden index bb1521e0e4..7129eb7ec8 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden @@ -4,11 +4,11 @@ "Notification": { "description": "Request body for testEndpoint.", "example": { - "id": "Aliquam quia quisquam ab alias atque." + "id": "notice" }, "properties": { "id": { - "example": "Aliquam quia quisquam ab alias atque.", + "example": "notice", "type": "string" } }, @@ -30,7 +30,7 @@ "content": { "application/json": { "example": { - "id": "Aliquam quia quisquam ab alias atque." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" @@ -45,7 +45,7 @@ "content": { "application/json": { "example": { - "id": "Dolor et voluptas." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden index bf083e5d17..03415ac180 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Aliquam quia quisquam ab alias atque. + id: notice responses: "200": description: OK response. @@ -29,16 +29,16 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Dolor et voluptas. + id: notice components: schemas: Notification: description: Request body for testEndpoint. example: - id: Aliquam quia quisquam ab alias atque. + id: notice properties: id: - example: Aliquam quia quisquam ab alias atque. + example: notice type: string type: object x-test-include: true diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden index f3b0fd2b86..7d3973366b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden @@ -5,18 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "properties": { "completed": { "example": [ - "what", - "what", - "what" + "when" ], "items": { "$ref": "#/components/schemas/Stage" @@ -37,7 +33,7 @@ "where", "what" ], - "example": "what", + "example": "who", "type": "string" } } @@ -56,11 +52,9 @@ "application/json": { "example": { "completed": [ - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" @@ -76,12 +70,9 @@ "application/json": { "example": { "completed": [ - "what", - "what", - "what", - "what" + "when" ], - "current": "what" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden index e5c5c20f88..f768eeb2fe 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden @@ -22,10 +22,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - what - - what - - what - current: what + - when + current: who responses: "200": description: OK response. @@ -35,11 +33,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - what - - what - - what - - what - current: what + - when + current: who components: schemas: Setup: @@ -50,22 +45,18 @@ components: items: $ref: '#/components/schemas/Stage' example: - - what - - what - - what + - when current: $ref: '#/components/schemas/Stage' description: Request body for testEndpoint. example: completed: - - what - - what - - what - current: what + - when + current: who Stage: type: string description: Setup stage. - example: what + example: who enum: - who - when diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden index b8739c4fa3..fe2fc87557 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden @@ -27,7 +27,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden index 41168294ca..6b3dd5b102 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden @@ -8,6 +8,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden new file mode 100644 index 0000000000..ca953baf3f --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden @@ -0,0 +1,96 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "name": "test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden new file mode 100644 index 0000000000..96583d0e28 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden @@ -0,0 +1,59 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + name: test api + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden index d99d41dc94..971763a0a3 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Voluptatem deleniti." + "id": "request" }, "properties": { "id": { - "example": "Voluptatem deleniti.", + "example": "request", "type": "string" } }, @@ -65,11 +65,11 @@ "operationId": "SSEAllFieldsService#SSEAllFieldsMethod", "parameters": [ { - "example": "Corrupti possimus quas ut.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Corrupti possimus quas ut.", + "example": "request", "type": "string" } } @@ -78,7 +78,7 @@ "content": { "application/json": { "example": { - "id": "Voluptatem deleniti." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" @@ -124,9 +124,6 @@ "type": "integer" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden index 344d4727e2..ad37e7772f 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Corrupti possimus quas ut. - example: Corrupti possimus quas ut. + example: request + example: request requestBody: description: Request body for SSEAllFieldsMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Voluptatem deleniti. + id: request responses: "200": description: OK response. @@ -58,8 +58,6 @@ paths: type: integer example: 3000 format: int64 - required: - - data components: schemas: SSEAllFieldsMethodRequestBody: @@ -67,10 +65,10 @@ components: properties: id: type: string - example: Voluptatem deleniti. + example: request description: Request body for SSEAllFieldsMethod. example: - id: Voluptatem deleniti. + id: request SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden index fa70570716..3bcde15c46 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden @@ -3,12 +3,12 @@ "schemas": { "SSEDataFieldMethodResponseBody": { "example": { - "data": "Consequuntur velit in amet et dolorem iste.", + "data": "event", "flag": true }, "properties": { "data": { - "example": "Consequuntur velit in amet et dolorem iste.", + "example": "event", "type": "string" }, "flag": { @@ -36,13 +36,10 @@ "itemSchema": { "properties": { "data": { - "example": "Officia iure ut qui voluptas id velit.", + "example": "event", "type": "string" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden index 01e8cda37e..750c350da1 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden @@ -23,9 +23,7 @@ paths: properties: data: type: string - example: Officia iure ut qui voluptas id velit. - required: - - data + example: event components: schemas: SSEDataFieldMethodResponseBody: @@ -33,12 +31,12 @@ components: properties: data: type: string - example: Consequuntur velit in amet et dolorem iste. + example: event flag: type: boolean example: true example: - data: Consequuntur velit in amet et dolorem iste. + data: event flag: true tags: - name: SSEDataFieldService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden index 91251cf44c..6814d282ca 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Event": { "example": { - "message": "Accusantium voluptatibus inventore." + "message": "event" }, "properties": { "message": { - "example": "Accusantium voluptatibus inventore.", + "example": "event", "type": "string" } }, @@ -19,11 +19,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Quis distinctio vitae ut." + "x": "request" }, "properties": { "x": { - "example": "Quis distinctio vitae ut.", + "example": "request", "type": "string" } }, @@ -34,11 +34,11 @@ }, "Result": { "example": { - "id": "Delectus eum inventore illum velit et." + "id": "result" }, "properties": { "id": { - "example": "Delectus eum inventore illum velit et.", + "example": "result", "type": "string" } }, @@ -62,7 +62,7 @@ "content": { "application/json": { "example": { - "x": "Quis distinctio vitae ut." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -77,7 +77,7 @@ "content": { "application/json": { "example": { - "id": "Delectus eum inventore illum velit et." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden index 384c32fb75..12254d17f7 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden @@ -21,7 +21,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Quis distinctio vitae ut. + x: request responses: "200": description: OK response. @@ -30,7 +30,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Delectus eum inventore illum velit et. + id: result text/event-stream: itemSchema: type: object @@ -49,9 +49,9 @@ components: properties: message: type: string - example: Accusantium voluptatibus inventore. + example: event example: - message: Accusantium voluptatibus inventore. + message: event required: - message Payload: @@ -59,10 +59,10 @@ components: properties: x: type: string - example: Quis distinctio vitae ut. + example: request description: Request body for Create. example: - x: Quis distinctio vitae ut. + x: request required: - x Result: @@ -70,9 +70,9 @@ components: properties: id: type: string - example: Delectus eum inventore illum velit et. + example: result example: - id: Delectus eum inventore illum velit et. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden index ce069edb9c..c185b5d794 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden @@ -4,8 +4,8 @@ "SSEObjectMethodResponseBody": { "example": { "flag": true, - "id": "Aut non.", - "value": 8434029765132757000 + "id": "event", + "value": 1 }, "properties": { "flag": { @@ -13,11 +13,11 @@ "type": "boolean" }, "id": { - "example": "Aut non.", + "example": "event", "type": "string" }, "value": { - "example": 8434029765132757000, + "example": 1, "format": "int64", "type": "integer" } @@ -46,8 +46,8 @@ "contentSchema": { "example": { "flag": true, - "id": "Ipsa sed perferendis rerum.", - "value": 5784889851462557000 + "id": "event", + "value": 1 }, "properties": { "flag": { @@ -55,11 +55,11 @@ "type": "boolean" }, "id": { - "example": "Ipsa sed perferendis rerum.", + "example": "event", "type": "string" }, "value": { - "example": 5784889851462557000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden index 87e2bf1f17..8878e1bf5a 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden @@ -32,15 +32,15 @@ paths: example: true id: type: string - example: Ipsa sed perferendis rerum. + example: event value: type: integer - example: 5784889851462556968 + example: 1 format: int64 example: flag: true - id: Ipsa sed perferendis rerum. - value: 5784889851462556968 + id: event + value: 1 required: - data components: @@ -53,14 +53,14 @@ components: example: true id: type: string - example: Aut non. + example: event value: type: integer - example: 8434029765132757469 + example: 1 format: int64 example: flag: true - id: Aut non. - value: 8434029765132757469 + id: event + value: 1 tags: - name: SSEObjectService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden index 88556df1ce..a8a12c2613 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden @@ -4,11 +4,11 @@ "SSERequestIDMethodRequestBody": { "description": "Request body for SSERequestIDMethod.", "example": { - "id": "Fugit totam mollitia perspiciatis sit sit." + "id": "request" }, "properties": { "id": { - "example": "Fugit totam mollitia perspiciatis sit sit.", + "example": "request", "type": "string" } }, @@ -27,11 +27,11 @@ "operationId": "SSERequestIDService#SSERequestIDMethod", "parameters": [ { - "example": "Mollitia saepe expedita quas sed maxime.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Mollitia saepe expedita quas sed maxime.", + "example": "request", "type": "string" } } @@ -40,7 +40,7 @@ "content": { "application/json": { "example": { - "id": "Fugit totam mollitia perspiciatis sit sit." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSERequestIDMethodRequestBody" @@ -57,7 +57,7 @@ "itemSchema": { "properties": { "data": { - "example": "Nam odio.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden index 7fcbc41057..7f4618ddcc 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Mollitia saepe expedita quas sed maxime. - example: Mollitia saepe expedita quas sed maxime. + example: request + example: request requestBody: description: Request body for SSERequestIDMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSERequestIDMethodRequestBody' example: - id: Fugit totam mollitia perspiciatis sit sit. + id: request responses: "200": description: OK response. @@ -39,7 +39,7 @@ paths: properties: data: type: string - example: Nam odio. + example: event required: - data components: @@ -49,9 +49,9 @@ components: properties: id: type: string - example: Fugit totam mollitia perspiciatis sit sit. + example: request description: Request body for SSERequestIDMethod. example: - id: Fugit totam mollitia perspiciatis sit sit. + id: request tags: - name: SSERequestIDService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden index a2ccb55bc0..be45a26deb 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden @@ -16,7 +16,7 @@ "itemSchema": { "properties": { "data": { - "example": "Ipsa libero est ipsum blanditiis.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden index 0c1b4511e1..12076372c5 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden @@ -23,7 +23,7 @@ paths: properties: data: type: string - example: Ipsa libero est ipsum blanditiis. + example: event required: - data components: {} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden index 91e7f88221..aa1883c165 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Voluptas molestiae aliquam at." + "a": "event" }, "properties": { "a": { - "example": "Voluptas molestiae aliquam at.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Aut deleniti enim veritatis asperiores sit.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Aut deleniti enim veritatis asperiores sit.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Voluptas molestiae aliquam at." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden index 9a98580856..c4d34fc79b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden @@ -19,8 +19,8 @@ paths: required: true schema: type: string - example: Aut deleniti enim veritatis asperiores sit. - example: Aut deleniti enim veritatis asperiores sit. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Voluptas molestiae aliquam at. + a: event components: schemas: UserType: @@ -37,8 +37,8 @@ components: properties: a: type: string - example: Voluptas molestiae aliquam at. + example: event example: - a: Voluptas molestiae aliquam at. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden index 1789fd7179..270b353dce 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 6827506417626806000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 6827506417626806000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden index 61d8d62f1e..caa349923d 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden @@ -19,9 +19,9 @@ paths: required: true schema: type: integer - example: 6827506417626806316 + example: 1 format: int64 - example: 6827506417626806316 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden index ec2d7dc6dc..d139d0cf91 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Voluptas molestiae aliquam at." + "a": "event" }, "properties": { "a": { - "example": "Voluptas molestiae aliquam at.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Aut deleniti enim veritatis asperiores sit.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Aut deleniti enim veritatis asperiores sit.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Voluptas molestiae aliquam at." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden index 97b69d2460..f16d59a634 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden @@ -18,8 +18,8 @@ paths: required: true schema: type: string - example: Aut deleniti enim veritatis asperiores sit. - example: Aut deleniti enim veritatis asperiores sit. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Voluptas molestiae aliquam at. + a: event components: schemas: UserType: @@ -36,8 +36,8 @@ components: properties: a: type: string - example: Voluptas molestiae aliquam at. + example: event example: - a: Voluptas molestiae aliquam at. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden index cef3622ae5..46df3c525b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden @@ -6,12 +6,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -20,8 +18,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -32,7 +28,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -56,12 +52,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { @@ -79,12 +73,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden index d00f4912cc..dac21362b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden @@ -23,10 +23,8 @@ paths: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" responses: "200": description: OK response. @@ -38,10 +36,8 @@ paths: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" components: schemas: TestEndpointRequestBody: @@ -55,21 +51,17 @@ components: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true description: Request body for testEndpoint. example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden index e62a6c4b92..198823c0c4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden @@ -4,7 +4,7 @@ "Bar": { "description": "Request body for test endpoint.", "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -18,10 +18,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -30,10 +27,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { @@ -63,7 +57,7 @@ "content": { "application/json": { "example": { - "string": "" + "string": "item" }, "schema": { "$ref": "#/components/schemas/Bar" @@ -77,16 +71,18 @@ "200": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ], + "foo": "" } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" @@ -98,22 +94,18 @@ "404": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ], + "foo": "" } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden index 7c33539b45..d4b1d7e703 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Bar' example: - string: "" + string: item responses: "200": description: OK response. @@ -28,24 +28,26 @@ paths: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item + foo: "" "404": description: Not Found response. content: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item + foo: "" components: schemas: Bar: @@ -56,7 +58,7 @@ components: example: "" description: Request body for test endpoint. example: - string: "" + string: item GoaFoobar: type: object properties: @@ -65,15 +67,13 @@ components: items: $ref: '#/components/schemas/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" example: bar: - - string: "" - - string: "" + - string: item foo: "" tags: - name: test service diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden index fc57572c86..b17a1563d4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 6827506417626806000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 6827506417626806000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden index a824519a27..cb444d1caf 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 6827506417626806316 + example: 1 format: int64 - example: 6827506417626806316 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go index 18a9a964a6..2cef6e07e4 100644 --- a/http/codegen/openapi/v3/types.go +++ b/http/codegen/openapi/v3/types.go @@ -7,6 +7,7 @@ import ( "fmt" "hash" "hash/fnv" + "slices" "strconv" "strings" @@ -43,7 +44,10 @@ type ( schemas map[string]*openapi.Schema // type names indexed by hashes hashes map[uint64][]string - rand *expr.ExampleGenerator + // released response names indexed by schema hash + preferredNames map[uint64][]string + rand *expr.ExampleGenerator + values openapi.Values // nameAliases generates named component schemas for primitive alias // types instead of inlining them. Only set when the schemas map feeds // the document components (OpenAPI 3.2 documents): schemafiers whose @@ -53,15 +57,16 @@ type ( } ) -// at returns a schemafier drawing example values from the semantic owner. +// at returns a schemafier that draws example values from the sequence selected +// by identity. func (sf *schemafier) at(identity expr.ExampleIdentity) *schemafier { c := *sf c.rand = sf.rand.At(identity) return &c } -// member returns a schemafier drawing examples for an object member below the -// current semantic owner. +// member returns a schemafier that draws examples from the named object's +// field sequence below the current example key. func (sf *schemafier) member(name string) *schemafier { c := *sf c.rand = sf.rand.Member(name) @@ -90,7 +95,8 @@ func (sf *schemafier) unionMember(name string) *schemafier { } // field returns a schemafier for a field extracted from parent. Named user -// types retain their global field identity; anonymous parents use owner. +// types use the repeatable key derived from their type; anonymous parents use +// the caller's key. func (sf *schemafier) field(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) *schemafier { c := *sf c.rand = sf.rand.At(exampleFieldIdentity(parent, name, owner)) @@ -98,11 +104,13 @@ func (sf *schemafier) field(parent *expr.AttributeExpr, name string, owner expr. } // newSchemafier initializes a schemafier. -func newSchemafier(rand *expr.ExampleGenerator) *schemafier { +func newSchemafier(rand *expr.ExampleGenerator, values openapi.Values) *schemafier { return &schemafier{ - schemas: make(map[string]*openapi.Schema), - hashes: make(map[uint64][]string), - rand: rand, + schemas: make(map[string]*openapi.Schema), + hashes: make(map[uint64][]string), + preferredNames: make(map[uint64][]string), + rand: rand, + values: values, } } @@ -118,11 +126,12 @@ func newSchemafier(rand *expr.ExampleGenerator) *schemafier { // value indexed by type name. // // NOTE: entries are nil when the corresponding type is Empty. -func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version, generator *expr.ExampleGenerator) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { +func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { bodies := make(map[string]map[string]*EndpointBodies) - sf := newSchemafier(generator) + sf := newSchemafier(generator, values) sf.nameAliases = ver == openapi.Version32 services := openAPIGeneratedServices(api) + sf.collectPreferredResponseNames(api) // Generates the types referenced from the endpoints. for _, t := range types { @@ -201,14 +210,48 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp return bodies, sf.schemas } +// collectPreferredResponseNames records released component names before any +// equal authored type can claim the same schema. +func (sf *schemafier) collectPreferredResponseNames(api *expr.APIExpr) { + for _, service := range api.HTTP.Services { + for _, endpoint := range service.HTTPEndpoints { + for _, response := range endpoint.Responses { + sf.collectPreferredResponseName(response) + } + for _, transportError := range endpoint.HTTPErrors { + sf.collectPreferredResponseName(transportError.Response) + } + } + } + for _, names := range sf.preferredNames { + slices.Sort(names) + } +} + +// collectPreferredResponseName records each unique released name for one +// response schema shape. Shapes with several names keep their shared name. +func (sf *schemafier) collectPreferredResponseName(response *expr.HTTPResponseExpr) { + _, preferred := responseBodyProjection(response) + for _, userType := range preferred { + if _, explicit := userType.Attribute().Meta["openapi:typename"]; explicit { + continue + } + attribute := &expr.AttributeExpr{Type: userType} + hash := sf.hashAttribute(attribute, fnv.New64()) + name := codegen.Goify(userType.Name(), true) + if !slices.Contains(sf.preferredNames[hash], name) { + sf.preferredNames[hash] = append(sf.preferredNames[hash], name) + } + } +} + // buildSSEItemSchema returns the JSON schema describing a single event // streamed by the given server-sent events endpoint as defined by the OpenAPI // 3.2 sequential media types. The schema is an object whose properties mirror -// the SSE event fields mapped by the design: data is always present, event, -// id and retry only when the design maps them. String and bytes data is -// written raw on the wire while other types are JSON-encoded, which the data -// property reflects using the JSON schema contentMediaType and contentSchema -// keywords. +// the SSE event fields mapped by the design. A selected optional data field may +// be absent; the full streaming result and required fields are always present. +// Primitive values are written as raw text while structured values are JSON, +// which the data property describes with contentMediaType and contentSchema. func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Schema { sse := e.SSE sr := e.MethodExpr.StreamingResult @@ -220,16 +263,19 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche dsf = sf.field(sr, sse.DataField, owner) } var dataSchema *openapi.Schema - switch data.Type { - case expr.String, expr.Bytes: + if expr.IsPrimitive(data.Type) { dataSchema = dsf.schemafy(data) - default: + } else { dataSchema = &openapi.Schema{ Type: openapi.String, ContentMediaType: "application/json", ContentSchema: dsf.schemafy(data), } } + var required []string + if sse.DataField == "" || sr.IsRequired(sse.DataField) { + required = []string{"data"} + } props := map[string]*openapi.Schema{"data": dataSchema} if sse.EventField != "" { props["event"] = sf.field(sr, sse.EventField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) @@ -243,7 +289,7 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche return &openapi.Schema{ Type: openapi.Object, Properties: props, - Required: []string{"data"}, + Required: required, } } @@ -252,17 +298,28 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche // view the result type is projected onto a detached copy of the body: the // design expression tree is read-only for the generators. func staticViewBody(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { - view, ok := resp.Body.Meta.Last(expr.ViewMetaKey) - if !ok || view == "" { - return resp.Body + body, _ := responseBodyProjection(resp) + return body +} + +// responseBodyProjection returns a detached response body and the released +// component names that may describe its schemas. +func responseBodyProjection(resp *expr.HTTPResponseExpr) (*expr.AttributeExpr, []expr.UserType) { + result, ok := resp.Body.Type.(*expr.ResultTypeExpr) + if !ok { + return resp.Body, nil } - body := expr.DupAtt(resp.Body) - rt, err := expr.Project(body.Type.(*expr.ResultTypeExpr), view) - if err != nil { - panic(fmt.Sprintf("failed to project %q to view %q", body.Type.Name(), view)) // bug + view, selected := resp.Body.Meta.Last(expr.ViewMetaKey) + if (!selected || view == "") && expr.AsArray(result.Type) == nil { + return resp.Body, nil } - body.Type = rt - return body + if !selected || view == "" { + view = expr.DefaultView + } + body := expr.DupAtt(resp.Body) + projection := openapi.ProjectResponseResult(result, view) + body.Type = projection.Result + return body, projection.Preferred } func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi.Schema { @@ -383,6 +440,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi name := t.Name() if metaName != "" { name = metaName + } else if preferred := sf.preferredNames[h]; len(preferred) == 1 { + name = preferred[0] } else if n, ok := t.Attribute().Meta["name:original"]; ok { name = n[0] } @@ -392,15 +451,15 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi sf.hashes[h] = append(sf.hashes[h], s.Ref) schema := sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute(), true) if schema.Description == "" { - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } sf.schemas[typeName] = schema return s // All other schema properties are set in the reference default: panic(fmt.Sprintf("unknown type %T", t)) // bug } - if attr.Description != "" { - s.Description = attr.Description + if description := sf.values.Description(attr.AuthoredAttribute(), attr.Description); description != "" { + s.Description = description } if note != "" { s.Description += "\n" + note @@ -408,7 +467,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // Default value, example, extensions s.DefaultValue = toStringMap(attr.DefaultValue) - s.Example = openapi.Example(attr, sf.rand) + s.Example = openapi.ProjectExample(attr, sf.values.Example(attr, sf.rand)) s.Extensions = openapi.ExtensionsFromExpr(attr.Meta) // Validations @@ -462,10 +521,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi return s } -// ensureSchemaDescription updates an existing component schema with the type or -// reference attribute description if the component was first created without -// one. This preserves user type descriptions when structurally equivalent types -// are reused under a component reference. +// ensureSchemaDescription gives an existing component the description owned by +// its Goa type when the component was first created without one. func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr *expr.AttributeExpr) { const prefix = "#/components/schemas/" typeName := strings.TrimPrefix(ref, prefix) @@ -476,16 +533,19 @@ func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr if schema == nil || schema.Description != "" { return } - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } -// userTypeDescription returns the canonical description for a user type schema, -// falling back to the description of the attribute that introduced the type. -func userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { - if desc := t.Attribute().Description; desc != "" { - return desc +// userTypeDescription returns text owned by the Goa type. A generated type may +// use text from its surrounding attribute only when both came from the same +// expression in the design. +func (sf *schemafier) userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { + typeAtt := t.Attribute() + description := typeAtt.Description + if description == "" && typeAtt.AuthoredAttribute() == attr.AuthoredAttribute() { + description = attr.Description } - return attr.Description + return sf.values.Description(typeAtt.AuthoredAttribute(), description) } // uniquify returns n if n is not a known type name. Otherwise uniquify appends diff --git a/http/codegen/openapi/v3/types_test.go b/http/codegen/openapi/v3/types_test.go index 0275c9f5db..239a840db0 100644 --- a/http/codegen/openapi/v3/types_test.go +++ b/http/codegen/openapi/v3/types_test.go @@ -218,7 +218,7 @@ func TestBuildBodyTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc, ok := bodies[svcName] if !ok { @@ -413,7 +413,7 @@ func TestMapTypes(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { // Build the OpenAPI spec root := codegen.RunDSL(t, tc.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) // Find the service and method svcBodies, ok := bodies[svcName] @@ -502,7 +502,7 @@ func validateAdditionalPropsSchema(t *testing.T, ctx string, schema *openapi.Sch func TestTypesOnlyDifferByEnum(t *testing.T) { root := codegen.RunDSL(t, dsls.StringEnumBodyDSL()) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory)) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc1, ok := bodies["svc_enum_1"] if !ok { @@ -551,7 +551,7 @@ func TestBuildBodyTypesPreservesPrimitiveAliasComponents(t *testing.T) { }) }) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32, expr.NewExampleGenerator(root.API.RandomizerFactory)) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) tests := []struct { name string status int @@ -702,7 +702,7 @@ func TestHashAttribute(t *testing.T) { } h := fnv.New64() - sf := newSchemafier(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test"))) + sf := newSchemafier(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")), openapi.Values{}) for _, group := range cases { t.Run(group.name, func(t *testing.T) { diff --git a/http/codegen/openapi/values.go b/http/codegen/openapi/values.go new file mode 100644 index 0000000000..37485458e8 --- /dev/null +++ b/http/codegen/openapi/values.go @@ -0,0 +1,139 @@ +// This file stores alternate OpenAPI text and examples for one specification +// build. Builders read these values without changing the evaluated Goa design. +package openapi + +import ( + "maps" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // storedExample keeps an immutable example value with the exact design + // expression used to find its translated description. + storedExample struct { + value *expr.ExampleExpr + source *expr.ExampleExpr + } + + // Values contains alternate titles, descriptions, and examples for one + // OpenAPI build. The zero value uses the evaluated Goa design unchanged. + // Methods that add values return a new independent Values. + Values struct { + titles map[eval.Expression]string + descriptions map[eval.Expression]string + examples map[*expr.AttributeExpr][]storedExample + } +) + +// WithTitle returns a copy of v that uses title for target. +func (v Values) WithTitle(target eval.Expression, title string) Values { + result := v.copy() + if result.titles == nil { + result.titles = make(map[eval.Expression]string) + } + result.titles[target] = title + return result +} + +// WithDescription returns a copy of v that uses description for target. +func (v Values) WithDescription(target eval.Expression, description string) Values { + result := v.copy() + if result.descriptions == nil { + result.descriptions = make(map[eval.Expression]string) + } + result.descriptions[target] = description + return result +} + +// WithExamples returns a copy of v that uses examples for attribute. Copies +// made from attribute by Goa use the same examples. +func (v Values) WithExamples(attribute *expr.AttributeExpr, examples []*expr.ExampleExpr) Values { + result := v.copy() + if result.examples == nil { + result.examples = make(map[*expr.AttributeExpr][]storedExample) + } + result.examples[attribute.AuthoredAttribute()] = storeExamples(examples) + return result +} + +// Title returns the title stored for target or fallback when none was stored. +func (v Values) Title(target eval.Expression, fallback string) string { + if title, ok := v.titles[target]; ok { + return title + } + return fallback +} + +// Description returns the description stored for target or fallback when none +// was stored. +func (v Values) Description(target eval.Expression, fallback string) string { + if description, ok := v.descriptions[target]; ok { + return description + } + return fallback +} + +// Examples returns the examples stored for attribute or a copy of fallback +// when none were stored. +func (v Values) Examples(attribute *expr.AttributeExpr, fallback []*expr.ExampleExpr) []*expr.ExampleExpr { + if examples, ok := v.examples[attribute.AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if examples, ok := v.examples[userType.Attribute().AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + } + return v.materializeExamples(storeExamples(fallback)) +} + +// Example returns the last stored or authored example, or generates one when +// none exists. A generator configured to suppress examples returns nil. +func (v Values) Example(attribute *expr.AttributeExpr, generator *expr.ExampleGenerator) any { + copy := *attribute + copy.UserExamples = v.Examples(attribute, attribute.ExtractUserExamples()) + return copy.Example(generator) +} + +// copy returns independent maps while retaining the immutable values they +// contain. Example lists are copied again when they are changed or read. +func (v Values) copy() Values { + return Values{ + titles: maps.Clone(v.titles), + descriptions: maps.Clone(v.descriptions), + examples: maps.Clone(v.examples), + } +} + +// storeExamples copies a complete example list while retaining the exact +// expression used to look up each translated description. +func storeExamples(examples []*expr.ExampleExpr) []storedExample { + if examples == nil { + return nil + } + result := make([]storedExample, len(examples)) + for index, example := range examples { + copy := *example + copy.Value = duplicateJSONValue(example.Value) + result[index] = storedExample{value: ©, source: example} + } + return result +} + +// materializeExamples returns a fresh example list with every replacement +// description applied from its exact source expression. +func (v Values) materializeExamples(examples []storedExample) []*expr.ExampleExpr { + if examples == nil { + return nil + } + result := make([]*expr.ExampleExpr, len(examples)) + for index, stored := range examples { + copy := *stored.value + copy.Description = v.Description(stored.source, copy.Description) + copy.Value = duplicateJSONValue(stored.value.Value) + result[index] = © + } + return result +} diff --git a/http/codegen/openapi/values_test.go b/http/codegen/openapi/values_test.go new file mode 100644 index 0000000000..af10e5cb3d --- /dev/null +++ b/http/codegen/openapi/values_test.go @@ -0,0 +1,86 @@ +// This file verifies that OpenAPI text and examples can be replaced for one +// build without changing the evaluated Goa design. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestValues(t *testing.T) { + api := expr.NewAPIExpr("calc", nil) + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{{Summary: "default", Value: "translated"}} + + var empty Values + require.Equal(t, "original title", empty.Title(api, "original title")) + require.Equal(t, "original description", empty.Description(api, "original description")) + require.Equal(t, []*expr.ExampleExpr(nil), empty.Examples(attribute, nil)) + + localized := empty. + WithTitle(api, "localized title"). + WithDescription(api, "localized description"). + WithExamples(attribute, examples) + + require.Equal(t, "localized title", localized.Title(api, "original title")) + require.Equal(t, "localized description", localized.Description(api, "original description")) + require.Equal(t, examples, localized.Examples(attribute, nil)) + require.Equal(t, "original title", empty.Title(api, "original title")) + + // Values owns its example list so neither caller nor reader can change it. + examples[0] = &expr.ExampleExpr{Summary: "changed", Value: "changed"} + firstRead := localized.Examples(attribute, nil) + require.Equal(t, "translated", firstRead[0].Value) + firstRead[0] = &expr.ExampleExpr{Summary: "changed again", Value: "changed again"} + require.Equal(t, "translated", localized.Examples(attribute, nil)[0].Value) +} + +func TestValuesUseAuthoredAttributeForCopies(t *testing.T) { + authored := &expr.AttributeExpr{Type: expr.String} + copy := expr.DupAtt(expr.DupAtt(authored)) + localized := (Values{}).WithExamples(authored, []*expr.ExampleExpr{{Value: "translated"}}) + + require.Equal(t, "translated", localized.Examples(copy, nil)[0].Value) +} + +func TestValuesOwnCompleteExampleLists(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{ + {Summary: "first", Value: map[string]any{"items": []any{"one"}}}, + {Summary: "second", Value: "two"}, + } + values := (Values{}).WithExamples(attribute, examples) + + examples[0].Value.(map[string]any)["items"].([]any)[0] = "changed" + firstRead := values.Examples(attribute, nil) + require.Len(t, firstRead, 2) + require.Equal(t, "one", firstRead[0].Value.(map[string]any)["items"].([]any)[0]) + require.Equal(t, "two", firstRead[1].Value) + + firstRead[0].Value.(map[string]any)["items"].([]any)[0] = "changed again" + require.Equal(t, "one", values.Examples(attribute, nil)[0].Value.(map[string]any)["items"].([]any)[0]) +} + +func TestValuesApplyExampleDescriptionsRegardlessOfCallOrder(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + example := &expr.ExampleExpr{Summary: "default", Description: "original", Value: "value"} + + values := (Values{}). + WithExamples(attribute, []*expr.ExampleExpr{example}). + WithDescription(example, "translated") + + require.Equal(t, "translated", values.Examples(attribute, nil)[0].Description) + require.Equal(t, "original", example.Description) +} + +func TestDocsFromExprWithValues(t *testing.T) { + docs := &expr.DocsExpr{Description: "original", URL: "https://goa.design"} + values := (Values{}).WithDescription(docs, "localized") + + localized := DocsFromExprWithValues(docs, nil, values) + require.Equal(t, "localized", localized.Description) + require.Equal(t, "original", DocsFromExpr(docs, nil).Description) +} diff --git a/http/codegen/openapi_disabled_examples_test.go b/http/codegen/openapi_disabled_examples_test.go index 2f836a49a6..c8d3c9e0e4 100644 --- a/http/codegen/openapi_disabled_examples_test.go +++ b/http/codegen/openapi_disabled_examples_test.go @@ -15,7 +15,6 @@ import ( "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" - "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) @@ -36,9 +35,9 @@ func TestOpenAPIDisabledExamplesDoNotConsumeServiceState(t *testing.T) { payloadExample := method.PayloadEx require.NotNil(t, payloadExample) - openapi.Definitions = make(map[string]*openapi.Schema) - files, err := OpenAPIFiles(root, examples) + plan, err := NewOpenAPIPlan(root, examples) require.NoError(t, err) + files := plan.Files() require.Len(t, files, 6) for _, file := range files { require.Len(t, file.SectionTemplates, 1) diff --git a/http/codegen/openapi_order_independence_test.go b/http/codegen/openapi_order_independence_test.go index ea1466d9de..84a7bce3cf 100644 --- a/http/codegen/openapi_order_independence_test.go +++ b/http/codegen/openapi_order_independence_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) @@ -60,14 +59,13 @@ func TestOpenAPIOrderIndependence(t *testing.T) { } // renderOpenAPI generates and renders all the OpenAPI specification files for -// the given root and returns their content indexed by file path. The global -// schema registry is reset first and the call receives a fresh example -// generator so two identical design trees yield identical documents. +// the given root and returns their content indexed by file path. The call uses +// a fresh example generator so two identical designs yield identical documents. func renderOpenAPI(t *testing.T, root *expr.RootExpr) map[string]string { t.Helper() - openapi.Definitions = make(map[string]*openapi.Schema) - files, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) + files := plan.Files() out := make(map[string]string, len(files)) for _, f := range files { require.Len(t, f.SectionTemplates, 1) diff --git a/http/codegen/openapi_plan_test.go b/http/codegen/openapi_plan_test.go new file mode 100644 index 0000000000..e14603156b --- /dev/null +++ b/http/codegen/openapi_plan_test.go @@ -0,0 +1,142 @@ +// This file checks that an OpenAPI plan keeps the documents it built. +package codegen + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIPlanKeepsBuiltFiles(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Contact = &expr.ContactExpr{Name: "before"} + root.API.License = &expr.LicenseExpr{Name: "before"} + root.API.HTTP.Consumes = []string{"application/before"} + root.API.HTTP.Produces = []string{"application/before"} + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + files := plan.Files() + before := renderOpenAPIFiles(t, files) + + root.API.Title = "changed after planning" + root.API.HTTP.Services = nil + root.API.Contact.Name = "after" + root.API.License.Name = "after" + root.API.HTTP.Consumes[0] = "application/after" + root.API.HTTP.Produces[0] = "application/after" + + filesAgain := plan.Files() + require.Len(t, filesAgain, len(files)) + for i := range files { + require.Same(t, files[i], filesAgain[i]) + } + require.Equal(t, before, renderOpenAPIFiles(t, filesAgain)) +} + +func TestOpenAPIPlanWithValuesDoesNotChangeDesign(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + values := (openapi.Values{}).WithTitle(root.API, "Localized API") + + plan, err := NewOpenAPIPlanWithValues( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + rendered := renderOpenAPIFiles(t, plan.Files()) + for _, document := range rendered { + require.Contains(t, document, "Localized API") + } + require.NotEqual(t, "Localized API", root.API.Title) +} + +func TestNewOpenAPIPlanFromSpecsUsesExactVersionsAndPaths(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + plan, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{ + {Version: openapi.Version20, Path: "docs/api.v2"}, + {Version: openapi.Version32, Path: "reference/api"}, + }, + openapi.Values{}, + ) + require.NoError(t, err) + paths := make([]string, len(plan.Files())) + for index, file := range plan.Files() { + paths[index] = file.Path + } + require.Equal(t, []string{ + "gen/docs/api.v2.json", + "gen/docs/api.v2.yaml", + "gen/reference/api.json", + "gen/reference/api.yaml", + }, paths) +} + +func TestNewOpenAPIPlanFromSpecsRejectsInvalidSpecs(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + tests := []struct { + name string + specs []openapi.Spec + err string + }{ + {name: "unknown version", specs: []openapi.Spec{{Version: "4.0", Path: "http/api"}}, err: `unsupported OpenAPI version "4.0"`}, + {name: "empty path", specs: []openapi.Spec{{Version: openapi.Version30}}, err: "path cannot be empty"}, + {name: "absolute path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "/api"}}, err: "path must be relative"}, + {name: "escaping path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "../api"}}, err: "path must not escape"}, + {name: "json extension", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api.json"}}, err: "path must not include an extension"}, + {name: "same version", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api"}, {Version: openapi.Version30, Path: "other"}}, err: `version "3.0" appears more than once`}, + {name: "same path", specs: []openapi.Spec{{Version: openapi.Version20, Path: "api"}, {Version: openapi.Version30, Path: "api"}}, err: `same output path "api"`}, + {name: "portable path collision", specs: []openapi.Spec{{Version: openapi.Version20, Path: "API"}, {Version: openapi.Version30, Path: "api"}}, err: "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + test.specs, + openapi.Values{}, + ) + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestNewOpenAPIPlanWrappersKeepOrdinaryOutput(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + specs, err := openapi.Specs(root.API.Meta) + require.NoError(t, err) + ordinary, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + explicit, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + specs, + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderOpenAPIFiles(t, ordinary.Files()), renderOpenAPIFiles(t, explicit.Files())) +} + +// renderOpenAPIFiles renders each planned file and returns its text by path. +func renderOpenAPIFiles(t *testing.T, files []*goacodegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var output bytes.Buffer + tmpl := template.Must(template.New(section.Name).Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&output, section.Data)) + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/http/codegen/openapi_test.go b/http/codegen/openapi_test.go index 9a7329ae8c..5d4f211f72 100644 --- a/http/codegen/openapi_test.go +++ b/http/codegen/openapi_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - openapi "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) @@ -23,12 +22,10 @@ func TestOpenAPI(t *testing.T) { "valid": {DSL: testdata.SimpleDSL, NilSpec: false}, } for k, c := range cases { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - spec, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - assert.Equal(t, c.NilSpec, spec == nil, k) + assert.Equal(t, c.NilSpec, len(plan.Files()) == 0, k) } } @@ -74,15 +71,14 @@ func TestOutputPath(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - o, err := OpenAPIFiles(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) if c.Err != "" { require.EqualError(t, err, c.Err) return } require.NoError(t, err) + o := plan.Files() require.Len(t, o, len(c.Paths)) for i, p := range c.Paths { assert.Equal(t, p, o[i].Path) diff --git a/http/codegen/plan.go b/http/codegen/plan.go index 89153a37d9..700877caf1 100644 --- a/http/codegen/plan.go +++ b/http/codegen/plan.go @@ -8,10 +8,12 @@ import ( "fmt" "net/http" "path" + "slices" "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -25,39 +27,73 @@ type ( Service *service.Plan } - // Plan records package names for one design and later builds its HTTP files. + // Plan records the generated Go declarations for one design and later builds + // its HTTP files. Plan struct { - root *expr.RootExpr - servicePlan *service.Plan - generation *codegen.Generation - transport transportKind - constructors map[viewedConstructorKey]*codegen.NameDeclaration - payloads map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration - streams map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration - errors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration - wireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes - symbols map[*expr.HTTPServiceExpr]*httpSymbols - cliParsers map[*expr.ServerExpr]*cli.ParserPlan - services *ServicesData - viewed map[viewedMethodKey]*viewedResultPlan - jsonServices map[string]*jsonRPCServicePlan - server []*codegen.File - client []*codegen.File - serverTypes []*codegen.File - clientTypes []*codegen.File - paths []*codegen.File - clientCLI []*codegen.File - example []*codegen.File - exampleCLI []*codegen.File + root *expr.RootExpr + servicePlan *service.Plan + generation *codegen.Generation + transport transportKind + serverPackages map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage + extensions map[*expr.HTTPServiceExpr]*serverExtensions + constructors map[viewedConstructorKey]*codegen.NameDeclaration + payloads map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streams map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + wireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + symbols map[*expr.HTTPServiceExpr]*httpSymbols + servicePaths map[*expr.HTTPServiceExpr]string + cliParsers map[string]*cli.ParserPlan + fileImports map[string]*plannedFileImports + services *ServicesData + viewed map[viewedMethodKey]*viewedResultPlan + jsonServices map[string]*jsonRPCServicePlan + server []*codegen.File + client []*codegen.File + serverTypes []*codegen.File + clientTypes []*codegen.File + paths []*codegen.File + clientCLI []*codegen.File + } + + // ServerMountPoint describes one route added by a declared server mount. + // Goa includes it in Server.Mounts so logs and startup output list the added + // route with the routes defined in the design. + ServerMountPoint struct { + // Method is the operation name shown for the route. + Method string + // Verb is the HTTP method accepted by the route. + Verb string + // Pattern is the path pattern accepted by the route. + Pattern string + } + + // ServerMount gives server templates the chosen mount function name and + // the routes that function adds. + ServerMount struct { + // Declaration supplies the generated mount function name. + Declaration *codegen.NameDeclaration + // MountPoints lists the routes added by Declaration. + MountPoints []ServerMountPoint + } + + // ExamplePlan builds runnable HTTP programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan } // jsonRPCServicePlan stores the HTTP data copied for the JSON-RPC file writer. jsonRPCServicePlan struct { - data *ServiceData - services *ServicesData - fileImports map[string][]*codegen.ImportSpec - clientCodec *codegen.File - serverCodec *codegen.File + data *ServiceData + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + clientServiceImport *codegen.ImportSpec + serverServiceImport *codegen.ImportSpec + clientViewImport *codegen.ImportSpec + serverViewImport *codegen.ImportSpec } // viewedResultPlan stores the HTTP response data copied for the JSON-RPC file @@ -77,6 +113,15 @@ type ( cookies []*CookieData } + // serverExtensions stores declarations submitted for one HTTP service before + // Generation.Freeze chooses their Go names. Link copies these values into + // template data. + serverExtensions struct { + handlerWrappers []*codegen.NameDeclaration + endpointHandlerWrappers map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration + mounts []*ServerMount + } + // JSONRPCServiceSnapshot holds a separate copy of the HTTP service data used to write // JSON-RPC client and server files. Callers may change it without changing // the HTTP plan or a later copy. @@ -85,23 +130,45 @@ type ( Service JSONRPCServiceData // Endpoints contains the JSON-RPC method data in design order. Endpoints []JSONRPCEndpointSnapshot + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string // ClientStructDeclaration supplies the client type name written in HTTP files. ClientStructDeclaration *codegen.NameDeclaration // ClientInitDeclaration supplies the client constructor name. ClientInitDeclaration *codegen.NameDeclaration + // ServerStruct is the server type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. + ServerStruct string // ServerStructDeclaration supplies the server type name written in HTTP files. ServerStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. + ServerInit string // ServerInitDeclaration supplies the server constructor name written in HTTP files. ServerInitDeclaration *codegen.NameDeclaration + // MountServer is the route mount function name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use MountServerDeclaration.Name() after planning. + MountServer string // MountServerDeclaration supplies the route mounting function name written in HTTP files. MountServerDeclaration *codegen.NameDeclaration // ServerService is the generated function that returns the service implementation. - ServerService string - serviceImport *codegen.ImportSpec - viewImport *codegen.ImportSpec - fileImports map[string][]*codegen.ImportSpec - clientCodec *codegen.File - serverCodec *codegen.File + ServerService string + clientServiceImport *codegen.ImportSpec + serverServiceImport *codegen.ImportSpec + clientViewImport *codegen.ImportSpec + serverViewImport *codegen.ImportSpec + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File } // JSONRPCServiceData contains the service names written in JSON-RPC files. @@ -112,8 +179,6 @@ type ( StructName string // EndpointsDeclaration supplies the service endpoint collection name. EndpointsDeclaration *codegen.NameDeclaration - // StreamDeclaration supplies the shared service stream name. - StreamDeclaration *codegen.NameDeclaration // MethodNamesDeclaration supplies the service method name list. MethodNamesDeclaration *codegen.NameDeclaration // PkgName is the import name of the generated service package. @@ -145,22 +210,45 @@ type ( RequestInit *InitData // EndpointInit is the client method that builds the Goa endpoint. EndpointInit string + // HandlerInit is the handler constructor name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning. + HandlerInit string // HandlerInitDeclaration supplies the server handler constructor name. HandlerInitDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string // ClientStructDeclaration supplies the client type name used by request builders. ClientStructDeclaration *codegen.NameDeclaration + // RequestEncoder is the request encoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request encoder. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning. + RequestEncoder string // RequestEncoderDeclaration supplies the request encoder name written in HTTP files. RequestEncoderDeclaration *codegen.NameDeclaration + // RequestDecoder is the request decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request decoder. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning. + RequestDecoder string // RequestDecoderDeclaration supplies the request decoder name written in HTTP files. RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseDecoder is the response decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning. + ResponseDecoder string // ResponseDecoderDeclaration supplies the response decoder name written in HTTP files. ResponseDecoderDeclaration *codegen.NameDeclaration // SSE contains event-stream values when the method uses server-sent events. SSE *JSONRPCSSEData - // ClientWebSocket contains client stream names when the method uses WebSocket. - ClientWebSocket *JSONRPCWebSocketData - // ServerWebSocket contains server stream names when the method uses WebSocket. - ServerWebSocket *JSONRPCWebSocketData } // JSONRPCMethodData contains the service method values written in JSON-RPC files. @@ -169,11 +257,11 @@ type ( Name string // VarName is the exported Go method name. VarName string - // EventDeclaration supplies the service event interface name used by - // server-sent-event streams. - EventDeclaration *codegen.NameDeclaration // Result is the generated service result type name. Result string + // HasMixedResults reports whether the method returns one synchronous type + // and streams another type. + HasMixedResults bool // Idempotent reports whether the client may retry the same call. Idempotent bool // Errors lists the retry properties of the method errors. @@ -265,8 +353,6 @@ type ( ServerBody *JSONRPCBodyData // PayloadInit builds the service payload from decoded request values. PayloadInit *InitData - // PayloadTypeName is the Goa name for the payload type. - PayloadTypeName string // Headers contains the HTTP request headers read by shared JSON code. Headers []JSONRPCHeaderData // Cookies contains the HTTP request cookies read by shared JSON code. @@ -355,50 +441,32 @@ type ( ClientInitDeclaration *codegen.NameDeclaration // EventTypeRef is the service result type carried by each event. EventTypeRef string + // HasResponseBody reports whether Response converts the service result to JSON. + HasResponseBody bool + // Response is the successful response used to encode stream events. + Response *JSONRPCResponseData // RequestIDField is the payload field that receives Last-Event-ID. RequestIDField string - } - - // JSONRPCWebSocketData contains the stream names read by JSON-RPC WebSocket files. - JSONRPCWebSocketData struct { - // VarName is the generated stream implementation type name. - VarName string - // VarDeclaration supplies the stream implementation type name. - VarDeclaration *codegen.NameDeclaration - // SendName is the method that sends a stream value. - SendName string - // SendDesc documents SendName. - SendDesc string - // SendWithContextName is the send method that accepts a context. - SendWithContextName string - // SendWithContextDesc documents SendWithContextName. - SendWithContextDesc string - // SendTypeName is the sent service type name. - SendTypeName string - // SendTypeRef is the sent service type reference. - SendTypeRef string - // RecvName is the method that receives a stream value. - RecvName string - // RecvDesc documents RecvName. - RecvDesc string - // RecvWithContextName is the receive method that accepts a context. - RecvWithContextName string - // RecvWithContextDesc documents RecvWithContextName. - RecvWithContextDesc string - // RecvTypeName is the received service type name. - RecvTypeName string - // RecvTypeRef is the received service type reference. - RecvTypeRef string + // RequestIDPointer reports whether RequestIDField stores a pointer. + RequestIDPointer bool } // JSONRPCBodyData contains only the JSON body fields read by JSON-RPC files. JSONRPCBodyData struct { + // Declaration supplies the generated body type name. It is nil when the + // body uses a Go type expression that does not declare a named type. + Declaration *codegen.NameDeclaration // VarName is the generated body type name. VarName string // Ref is the generated body type reference. Ref string - // ValidateRef is the validation statement run after decoding. + // ValidateRef is inline validation code run after decoding. ValidateRef string + // ValidatorDeclaration supplies the named validator called after decoding. + ValidatorDeclaration *codegen.NameDeclaration + // ValidationTarget is the decoded value passed to ValidatorDeclaration. It + // is empty when this body does not need a named validator call. + ValidationTarget string // Init converts between the body and the service value. Init *InitData } @@ -535,6 +603,7 @@ type ( // constructor preferences in one generated client package. viewedConstructorOrder struct { transport string + api string service string method string status int @@ -546,12 +615,53 @@ type ( // plannedWireTypes stores each copied request and response field with the // client or server package that defines it. Plan.Link uses the same copies - // after Goa assigns every generated package name. + // after Generation.Freeze chooses every generated Go name. plannedWireTypes struct { - bodies shapedBodies - server *wireTypeCatalog - client *wireTypeCatalog - streamPayloads map[*expr.HTTPEndpointExpr]*wireTypeRecord + bodies shapedBodies + server *wireTypeCatalog + client *wireTypeCatalog + transforms plannedWireTransforms + streamPayloads map[*expr.HTTPEndpointExpr]*wireTypeRecord + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + clientBodyConstructorNames map[clientBodyConstructorKey]string + } + + // plannedFileImports stores the package that writes one generated file and + // every design-derived package path referenced by that file. + plannedFileImports struct { + output *codegen.GeneratedPackage + paths []string + } + + // plannedWireTransforms retains the exact conversion selected while HTTP + // request and response shapes are collected. + plannedWireTransforms struct { + requests map[clientBodyConstructorKey]*plannedRequestTransforms + responses map[viewedConstructorKey]*plannedResponseTransforms + errors map[*expr.HTTPErrorExpr]*plannedResponseTransforms + streamingResults map[*expr.HTTPEndpointExpr]*plannedResponseTransforms + } + + // plannedRequestTransforms contains each direction used by request body and + // streaming payload code. + plannedRequestTransforms struct { + clientEncode wireTransformHandle + serverDecode wireTransformHandle + clientDecode wireTransformHandle + } + + // plannedResponseTransforms contains the server encoder and client decoder + // for one response representation. + plannedResponseTransforms struct { + serverEncode wireTransformHandle + clientDecode wireTransformHandle + clientDecodeDirect bool + } + + // clientBodyConstructorKey identifies an unnamed request body constructor. + clientBodyConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + role wireTypeRole } // transportKind records whether a plan writes HTTP or JSON-RPC files. @@ -576,6 +686,16 @@ func NewJSONRPCPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Pl return newPlans(generation, jsonrpcTransport, inputs) } +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.servicePlan) + if !ok { + return nil, fmt.Errorf("HTTP examples require server data created from the same service design") + } + return &ExamplePlan{root: root, transport: transport}, nil +} + // MatchesHTTP reports whether NewPlans created p for root and servicePlan. func (p *Plan) MatchesHTTP(root *expr.RootExpr, servicePlan *service.Plan) bool { return p.transport == httpTransport && p.root == root && p.servicePlan == servicePlan @@ -587,8 +707,76 @@ func (p *Plan) MatchesJSONRPC(root *expr.RootExpr, servicePlan *service.Plan) bo return p.transport == jsonrpcTransport && p.root == root && p.servicePlan == servicePlan } -// Link reads the assigned package names, builds data for each HTTP service once, and -// builds every file returned by this plan. +// DeclareServerHandlerWrapper records an exported func(http.Handler) +// http.Handler that wraps each designed endpoint handler, file handler, and +// redirect mounted for service. Wrappers are applied in registration order, +// with the first registered function surrounding the others. Routes added by +// DeclareServerMount are not wrapped automatically. +func (p *Plan) DeclareServerHandlerWrapper(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].handlerWrappers = append(p.extensions[service].handlerWrappers, declaration) + return declaration, nil +} + +// DeclareServerEndpointHandlerWrapper records an unexported func(http.Handler) +// http.Handler that wraps the designed routes for endpoint. Service wrappers +// surround endpoint wrappers. File handlers and routes added by plugins are not +// affected. +func (p *Plan) DeclareServerEndpointHandlerWrapper(endpoint *expr.HTTPEndpointExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, service, err := p.serverEndpointExtensionPackage(endpoint) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.UnexportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + extensions := p.extensions[service] + extensions.endpointHandlerWrappers[endpoint] = append(extensions.endpointHandlerWrappers[endpoint], declaration) + return declaration, nil +} + +// DeclareServerMount records an exported func(goahttp.Muxer) that adds routes +// to the HTTP server mux. Goa calls the function after mounting routes from the +// design and includes mountPoints in the server's route list. +func (p *Plan) DeclareServerMount(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder, mountPoints []ServerMountPoint) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + if len(mountPoints) == 0 { + return nil, fmt.Errorf("HTTP server mount requires at least one mount point") + } + for index, mount := range mountPoints { + switch { + case mount.Method == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty method", index) + case mount.Verb == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty verb", index) + case mount.Pattern == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty pattern", index) + } + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].mounts = append(p.extensions[service].mounts, &ServerMount{ + Declaration: declaration, + MountPoints: append([]ServerMountPoint(nil), mountPoints...), + }) + return declaration, nil +} + +// Link reads the chosen Go declaration and import names, builds template data +// for each HTTP service once, and builds every file returned by this plan. func (p *Plan) Link() error { if !p.generation.Frozen() { return fmt.Errorf("HTTP plan cannot link before generation freeze") @@ -596,7 +784,10 @@ func (p *Plan) Link() error { if p.services != nil { return fmt.Errorf("HTTP plan is already linked") } - return p.link() + if err := p.link(); err != nil { + return err + } + return nil } // ServerFiles returns the HTTP server files built by Link. @@ -605,6 +796,17 @@ func (p *Plan) ServerFiles() []*codegen.File { return p.server } +// Service returns the template data built by Link for the supplied HTTP service +// expression. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (*ServiceData, bool) { + p.requireLinked() + if _, ok := p.extensions[service]; !ok { + return nil, false + } + data := p.services.Get(service.Name()) + return data, data != nil +} + // ClientFiles returns the HTTP client files built by Link. func (p *Plan) ClientFiles() []*codegen.File { p.requireLinked() @@ -635,35 +837,35 @@ func (p *Plan) ClientCLIFiles() []*codegen.File { return p.clientCLI } -// ExampleServerFiles returns the runnable HTTP server files built by Link. -func (p *Plan) ExampleServerFiles() []*codegen.File { - p.requireLinked() - return p.example +// ServerFiles builds runnable HTTP servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) } -// ExampleCLIFiles returns the runnable HTTP client files built by Link. -func (p *Plan) ExampleCLIFiles() []*codegen.File { - p.requireLinked() - return p.exampleCLI +// CLIFiles builds runnable HTTP clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) } -// CombinedExampleServerFiles returns new runnable server files containing this -// plan's JSON-RPC services and application's ordinary HTTP services. Pass nil -// when the design has no ordinary HTTP services. -func (p *Plan) CombinedExampleServerFiles(application *Plan) []*codegen.File { - p.requireLinked() - if p.transport != jsonrpcTransport { +// CombinedServerFiles returns new runnable server files containing this plan's +// JSON-RPC services and application's ordinary HTTP services. Pass nil when +// the design has no ordinary HTTP services. +func (p *ExamplePlan) CombinedServerFiles(application *Plan) []*codegen.File { + p.transport.requireLinked() + if p.transport.transport != jsonrpcTransport { panic("combined example servers require a JSON-RPC HTTP plan") } var applicationServices *ServicesData if application != nil { application.requireLinked() - if application.transport != httpTransport || application.root != p.root || application.servicePlan != p.servicePlan { + if application.transport != httpTransport || application.root != p.transport.root || application.servicePlan != p.transport.servicePlan { panic("ordinary HTTP and JSON-RPC plans must use the same design root and service plan") } applicationServices = application.services } - return combinedExampleServerFiles(p.services, applicationServices) + return combinedExampleServerFiles(p.root, p.transport.services, applicationServices) } // ViewedResult returns copied HTTP response data for the named method's result @@ -714,46 +916,60 @@ func (p *Plan) JSONRPCService(name string) (JSONRPCServiceSnapshot, bool) { for filePath, imports := range planned.fileImports { fileImports[filePath] = cloneImportSpecs(imports) } - var viewImport *codegen.ImportSpec - if serviceHasViewedResult(planned.data, nil) { - viewImport = planned.services.ViewImport(planned.data.Service.Name) - } return JSONRPCServiceSnapshot{ Service: JSONRPCServiceData{ Name: planned.data.Service.Name, StructName: planned.data.Service.StructName, EndpointsDeclaration: planned.data.Service.EndpointsDeclaration, - StreamDeclaration: planned.data.Service.StreamDeclaration, MethodNamesDeclaration: planned.data.Service.MethodNamesDeclaration, PkgName: planned.data.Service.PkgName, PathName: planned.data.Service.PathName, }, Endpoints: endpoints, + ClientStruct: planned.data.ClientStructDeclaration.Name(), ClientStructDeclaration: planned.data.ClientStructDeclaration, ClientInitDeclaration: planned.data.ClientInitDeclaration, + ServerStruct: planned.data.ServerStructDeclaration.Name(), ServerStructDeclaration: planned.data.ServerStructDeclaration, + ServerInit: planned.data.ServerInitDeclaration.Name(), ServerInitDeclaration: planned.data.ServerInitDeclaration, + MountServer: planned.data.MountServerDeclaration.Name(), MountServerDeclaration: planned.data.MountServerDeclaration, ServerService: planned.data.ServerService, - serviceImport: cloneImportSpec(planned.services.ServiceImport(planned.data.Service.Name)), - viewImport: cloneImportSpec(viewImport), + clientServiceImport: cloneImportSpec(planned.clientServiceImport), + serverServiceImport: cloneImportSpec(planned.serverServiceImport), + clientViewImport: cloneImportSpec(planned.clientViewImport), + serverViewImport: cloneImportSpec(planned.serverViewImport), fileImports: fileImports, clientCodec: planned.clientCodec, serverCodec: planned.serverCodec, }, true } -// ServiceImport returns the import for the generated Goa service package. -func (p JSONRPCServiceSnapshot) ServiceImport() *codegen.ImportSpec { - return cloneImportSpec(p.serviceImport) +// ClientServiceImport returns the service import used by the JSON-RPC client package. +func (p JSONRPCServiceSnapshot) ClientServiceImport() *codegen.ImportSpec { + return cloneImportSpec(p.clientServiceImport) } -// ViewImport returns the import for the generated result-view package. -func (p JSONRPCServiceSnapshot) ViewImport() *codegen.ImportSpec { - if p.viewImport == nil { +// ServerServiceImport returns the service import used by the JSON-RPC server package. +func (p JSONRPCServiceSnapshot) ServerServiceImport() *codegen.ImportSpec { + return cloneImportSpec(p.serverServiceImport) +} + +// ClientViewImport returns the result-view import used by the JSON-RPC client package. +func (p JSONRPCServiceSnapshot) ClientViewImport() *codegen.ImportSpec { + if p.clientViewImport == nil { + panic("JSON-RPC service does not use result views") + } + return cloneImportSpec(p.clientViewImport) +} + +// ServerViewImport returns the result-view import used by the JSON-RPC server package. +func (p JSONRPCServiceSnapshot) ServerViewImport() *codegen.ImportSpec { + if p.serverViewImport == nil { panic("JSON-RPC service does not use result views") } - return cloneImportSpec(p.viewImport) + return cloneImportSpec(p.serverViewImport) } // FileImports returns a new copy of the service-type imports needed by one @@ -780,68 +996,440 @@ func (p JSONRPCServiceSnapshot) ServerCodecFile() *codegen.File { return cloneJSONRPCCodecFile(p.serverCodec) } -// planImports requests every import name written directly in an HTTP file. -// This happens before generated service packages receive their import names. -func planImports(generation *codegen.Generation, transport transportKind) error { +// serverExtensionPackage returns the generated server package that will contain +// service's extension functions. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverExtensionPackage(service *expr.HTTPServiceExpr) (*codegen.GeneratedPackage, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, err + } + if service == nil { + return nil, fmt.Errorf("HTTP server extension requires a service from this plan") + } + pkg, ok := p.serverPackages[service] + if !ok { + return nil, fmt.Errorf("HTTP service does not belong to this plan") + } + return pkg, nil +} + +// serverEndpointExtensionPackage returns the generated server package and +// service that contain endpoint. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverEndpointExtensionPackage(endpoint *expr.HTTPEndpointExpr) (*codegen.GeneratedPackage, *expr.HTTPServiceExpr, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, nil, err + } + if endpoint == nil { + return nil, nil, fmt.Errorf("HTTP server endpoint wrapper requires an endpoint from this plan") + } + for service, symbols := range p.symbols { + if _, ok := symbols.endpoints[endpoint]; ok { + return p.serverPackages[service], service, nil + } + } + return nil, nil, fmt.Errorf("HTTP endpoint does not belong to this plan") +} + +// validateServerExtensionLifecycle checks that the plan still accepts new Go +// declarations and that it writes ordinary HTTP files. +func (p *Plan) validateServerExtensionLifecycle() error { + if p.transport != httpTransport { + return fmt.Errorf("JSON-RPC HTTP plans do not support server extensions") + } + if p.services != nil { + return fmt.Errorf("HTTP server extension cannot be declared after plan linking") + } + if p.generation.Frozen() { + return fmt.Errorf("HTTP server extension cannot be declared after generation freeze") + } + return nil +} + +// planImports records each import on the generated package that writes the +// reference. NewPlans calls it after those packages have been claimed. +func planImports(generation *codegen.Generation, transport transportKind, plans []*Plan) error { + for _, candidate := range generation.Roots() { + design, ok := candidate.(*expr.RootExpr) + if !ok { + continue + } + expressions := transportExpressions(design, transport) + if len(expressions.Services) == 0 { + continue + } + plan := planForRoot(plans, design) + if plan == nil { + return fmt.Errorf("%s design has no HTTP plan", transportLabel(transport)) + } + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + pathName := plan.servicePaths[transportService] + clientPath := path.Join(generation.GenPkg(), dir, pathName, "client") + serverPath := path.Join(generation.GenPkg(), dir, pathName, "server") + servicePackage, viewsPackage, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + for index, outputPackage := range []*codegen.GeneratedPackage{ + generation.Package(clientPath), + generation.Package(serverPath), + } { + if err := requireHTTPTransportImports(outputPackage, transportService, index == 0); err != nil { + return err + } + if err := outputPackage.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + if viewsPackage != nil { + if err := outputPackage.ReserveGeneratedImport(viewsPackage); err != nil { + return err + } + } + allPaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(transportService.HTTPEndpoints...)...) + if err != nil { + return err + } + side := "server" + if index == 0 { + side = "client" + } + retainPlannedFileImports(plan, outputPackage, allPaths, + path.Join(codegen.Gendir, dir, pathName, side, side+".go"), + path.Join(codegen.Gendir, dir, pathName, side, "encode_decode.go"), + path.Join(codegen.Gendir, dir, pathName, side, "types.go"), + ) + if index == 0 { + retainPlannedFileImports(plan, outputPackage, allPaths, path.Join(codegen.Gendir, dir, pathName, side, "cli.go")) + } + webSocketPaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(httpWebSocketEndpoints(transportService)...)...) + if err != nil { + return err + } + retainPlannedFileImports(plan, outputPackage, webSocketPaths, path.Join(codegen.Gendir, dir, pathName, side, "websocket.go")) + ssePaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(httpSSEEndpoints(transportService)...)...) + if err != nil { + return err + } + sseFile := "sse.go" + if transport == jsonrpcTransport && index == 0 { + sseFile = "stream.go" + } + retainPlannedFileImports(plan, outputPackage, ssePaths, path.Join(codegen.Gendir, dir, pathName, side, sseFile)) + } + } + if transport == httpTransport { + var rootOutput *codegen.GeneratedPackage + for _, transportService := range expressions.Services { + if !serviceHasMultipartRequest(transportService) { + continue + } + rootPath := path.Dir(generation.GenPkg()) + if rootOutput == nil { + var err error + rootOutput, err = generation.ClaimOutputPackage(rootPath, ".") + if err != nil { + return err + } + } + if err := rootOutput.RequireImport(codegen.SimpleImport("mime/multipart")); err != nil { + return err + } + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + servicePath := plan.servicePaths[transportService] + if err := rootOutput.ReserveGeneratedImport(codegen.NewImport( + servicePackage.Name+"svr", + path.Join(generation.GenPkg(), "http", servicePath, "server"), + )); err != nil { + return err + } + var multipartEndpoints []*expr.HTTPEndpointExpr + for _, endpoint := range transportService.HTTPEndpoints { + if endpoint.MultipartRequest { + multipartEndpoints = append(multipartEndpoints, endpoint) + } + } + importPaths, err := planHTTPAttributeImports(generation, rootOutput, serviceReferenceAttributes(multipartEndpoints...)...) + if err != nil { + return err + } + retainPlannedFileImports(plan, rootOutput, importPaths, "multipart.go") + } + } + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliPath := path.Join(generation.GenPkg(), dir, "cli", serverName) + cliPackage := generation.Package(cliPath) + if err := requireHTTPCLIImports(cliPackage); err != nil { + return err + } + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil { + continue + } + pathName := plan.servicePaths[transportService] + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + if err := cliPackage.ReserveGeneratedImport(codegen.NewImport( + servicePackage.Name+"c", + path.Join(generation.GenPkg(), dir, pathName, "client"), + )); err != nil { + return err + } + if len(transportService.ServiceExpr.ClientInterceptors) > 0 { + servicePackage, _, err := plan.servicePlan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return err + } + if err := cliPackage.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + } + } + + rootPath := path.Dir(generation.GenPkg()) + serverOutput, err := generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", serverName), + path.Join("cmd", serverName), + ) + if err != nil { + return err + } + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil { + continue + } + pathName := plan.servicePaths[transportService] + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + preferred := servicePackage.Name + "svr" + if transport == jsonrpcTransport { + preferred = servicePackage.Name + "jssvr" + } + if err := serverOutput.ReserveGeneratedImport(codegen.NewImport( + preferred, + path.Join(generation.GenPkg(), dir, pathName, "server"), + )); err != nil { + return err + } + } + clientOutput, err := generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", serverName+"-cli"), + path.Join("cmd", serverName+"-cli"), + ) + if err != nil { + return err + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport("cli", cliPath)); err != nil { + return err + } + for _, service := range design.Services { + servicePackage, _, err := plan.servicePlan.ServicePackageImports(service) + if err != nil { + return err + } + if err := clientOutput.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport(examplePackageImportName(design), rootPath)); err != nil { + return err + } + for _, service := range design.Services { + if len(service.ClientInterceptors) == 0 { + continue + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { + return err + } + break + } + } + } + return nil +} + +// planForRoot returns the transport plan that owns one evaluated design. +func planForRoot(plans []*Plan, root *expr.RootExpr) *Plan { + for _, plan := range plans { + if plan.root == root { + return plan + } + } + return nil +} + +// servicePackagePreferences returns the service package and optional views +// package recorded for every transport method. All methods in one service must +// agree because their generated files share imports. +func servicePackagePreferences(plan *service.Plan, transportService *expr.HTTPServiceExpr) (*codegen.ImportSpec, *codegen.ImportSpec, error) { + servicePackage, availableViewsPackage, err := plan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, nil, err + } + var viewsPackage *codegen.ImportSpec + for _, endpoint := range transportService.HTTPEndpoints { + methodService, methodViews, err := plan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + return nil, nil, err + } + if *servicePackage != *methodService { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated service packages", transportService.Name()) + } + if methodViews == nil { + continue + } + if *availableViewsPackage != *methodViews { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated views packages", transportService.Name()) + } + viewsPackage = availableViewsPackage + } + return servicePackage, viewsPackage, nil +} + +// retainPlannedFileImports records each package path referenced by the named +// generated files. Repeated calls merge paths when several services contribute +// declarations to one output file such as multipart.go. +func retainPlannedFileImports(plan *Plan, output *codegen.GeneratedPackage, importPaths []string, filePaths ...string) { + for _, filePath := range filePaths { + key := filepathKey(filePath) + retained := plan.fileImports[key] + if retained == nil { + retained = &plannedFileImports{output: output} + plan.fileImports[key] = retained + } + seen := make(map[string]struct{}, len(retained.paths)) + for _, importPath := range retained.paths { + seen[importPath] = struct{}{} + } + for _, importPath := range importPaths { + if _, ok := seen[importPath]; ok { + continue + } + seen[importPath] = struct{}{} + retained.paths = append(retained.paths, importPath) + } + slices.Sort(retained.paths) + } +} + +// examplePackageImportName returns the package name imported by runnable +// examples for the starter service implementations at the module root. +func examplePackageImportName(root *expr.RootExpr) string { + scope := codegen.NewNameScope() + for _, service := range root.Services { + scope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") +} + +// requireHTTPTransportImports records the fixed package names used by one +// service's generated client or server files. +func requireHTTPTransportImports(outputPackage *codegen.GeneratedPackage, service *expr.HTTPServiceExpr, client bool) error { imports := []*codegen.ImportSpec{ - codegen.SimpleImport("bufio"), - codegen.SimpleImport("bytes"), codegen.SimpleImport("context"), codegen.SimpleImport("encoding/json"), codegen.SimpleImport("errors"), - codegen.SimpleImport("flag"), codegen.SimpleImport("fmt"), codegen.SimpleImport("io"), codegen.SimpleImport("mime/multipart"), codegen.SimpleImport("net/http"), - codegen.SimpleImport("net/url"), - codegen.SimpleImport("os"), - codegen.SimpleImport("path"), codegen.SimpleImport("strconv"), codegen.SimpleImport("strings"), - codegen.SimpleImport("sync"), - codegen.SimpleImport("time"), codegen.SimpleImport("unicode/utf8"), - codegen.SimpleImport("github.com/google/uuid"), codegen.SimpleImport("github.com/gorilla/websocket"), - codegen.SimpleImport("goa.design/clue/debug"), - codegen.SimpleImport("goa.design/clue/log"), codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - codegen.GoaImport("middleware"), + } + if client { + imports = append(imports, + codegen.SimpleImport("bytes"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("os"), + codegen.SimpleImport("time"), + ) + } else { + imports = append(imports, + codegen.SimpleImport("bufio"), + codegen.SimpleImport("path"), + ) + } + hasStream := false + for _, endpoint := range service.HTTPEndpoints { + if endpoint.UsesWebSocket() || endpoint.UsesSSE() { + hasStream = true + } + if client && endpoint.IsJSONRPC() { + imports = append(imports, + codegen.SimpleImport("github.com/google/uuid"), + codegen.GoaImport("jsonrpc"), + ) + } + } + if hasStream { + imports = append(imports, codegen.SimpleImport("sync")) + if !client { + imports = append(imports, codegen.SimpleImport("time")) + } } for _, spec := range imports { - if err := generation.RequireImport(spec); err != nil { + if err := outputPackage.RequireImport(spec); err != nil { return err } } - for _, root := range generation.Roots() { - design, ok := root.(*expr.RootExpr) - if !ok { - continue + return nil +} + +// requireHTTPCLIImports records the fixed imports used by a generated command +// parser and its payload builders. +func requireHTTPCLIImports(outputPackage *codegen.GeneratedPackage) error { + for _, spec := range []*codegen.ImportSpec{ + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("os"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + } { + if err := outputPackage.RequireImport(spec); err != nil { + return err } - expressions := transportExpressions(design, transport) - dir := transportDirectory(transport) - for _, service := range expressions.Services { - pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) - packageName := strings.ToLower(codegen.Goify(service.Name(), false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), dir, pathName, "client"))); err != nil { - return err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"svr", path.Join(generation.GenPkg(), dir, pathName, "server"))); err != nil { - return err - } + } + return nil +} + +// serviceHasResultViews reports whether transport files reference the service +// views package. +func serviceHasResultViews(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if _, ok := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); ok { + return true } - if len(expressions.Services) > 0 { - for _, server := range design.API.Servers { - serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) - if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), dir, "cli", serverName))); err != nil { - return err - } - } + } + return false +} + +// serviceHasMultipartRequest reports whether the example package writes a +// multipart callback whose signature uses this service's generated server. +func serviceHasMultipartRequest(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MultipartRequest { + return true } } - return nil + return false } // newPlans validates the full input set and submits names for every plan. @@ -849,6 +1437,9 @@ func newPlans(generation *codegen.Generation, transport transportKind, inputs [] if generation == nil { return nil, fmt.Errorf("HTTP plans require a generation") } + if generation.Frozen() { + return nil, fmt.Errorf("HTTP plans must be collected before generation freeze") + } owned := make(map[*expr.RootExpr]struct{}) for _, candidate := range generation.Roots() { root, ok := candidate.(*expr.RootExpr) @@ -878,9 +1469,6 @@ func newPlans(generation *codegen.Generation, transport transportKind, inputs [] if len(inputs) != len(owned) { return nil, fmt.Errorf("%s planning requires all %d transport roots, got %d", transportLabel(transport), len(owned), len(inputs)) } - if err := planImports(generation, transport); err != nil { - return nil, err - } packages := make(map[string]*wireTypeCatalog) plans := make([]*Plan, len(inputs)) for index, input := range inputs { @@ -890,6 +1478,9 @@ func newPlans(generation *codegen.Generation, transport transportKind, inputs [] } plans[index] = plan } + if err := planImports(generation, transport, plans); err != nil { + return nil, err + } for _, catalog := range packages { if err := catalog.Declare(); err != nil { return nil, err @@ -909,31 +1500,45 @@ func newPlans(generation *codegen.Generation, transport transportKind, inputs [] // that its generated client and server packages will define. func newPlan(generation *codegen.Generation, transport transportKind, input PlanInput, packages map[string]*wireTypeCatalog) (*Plan, error) { plan := &Plan{ - root: input.Root, - servicePlan: input.Service, - generation: generation, - transport: transport, - constructors: make(map[viewedConstructorKey]*codegen.NameDeclaration), - payloads: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), - streams: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), - errors: make(map[*expr.HTTPErrorExpr]*codegen.NameDeclaration), - wireTypes: make(map[*expr.HTTPServiceExpr]*plannedWireTypes), - symbols: make(map[*expr.HTTPServiceExpr]*httpSymbols), - cliParsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + root: input.Root, + servicePlan: input.Service, + generation: generation, + transport: transport, + serverPackages: make(map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage), + extensions: make(map[*expr.HTTPServiceExpr]*serverExtensions), + constructors: make(map[viewedConstructorKey]*codegen.NameDeclaration), + payloads: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + streams: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + errors: make(map[*expr.HTTPErrorExpr]*codegen.NameDeclaration), + wireTypes: make(map[*expr.HTTPServiceExpr]*plannedWireTypes), + symbols: make(map[*expr.HTTPServiceExpr]*httpSymbols), + servicePaths: make(map[*expr.HTTPServiceExpr]string), + cliParsers: make(map[string]*cli.ParserPlan), + fileImports: make(map[string]*plannedFileImports), } expressions := transportExpressions(input.Root, transport) dir := transportDirectory(transport) for _, transportService := range expressions.Services { - clientPath := path.Join(generation.GenPkg(), dir, codegen.SnakeCase(transportService.Name()), "client") + servicePackage, _, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + servicePath := path.Base(servicePackage.Path) + plan.servicePaths[transportService] = servicePath + clientPath := path.Join(generation.GenPkg(), dir, servicePath, "client") clientPackage, err := generation.ClaimPackage(clientPath) if err != nil { return nil, err } - serverPath := path.Join(generation.GenPkg(), dir, codegen.SnakeCase(transportService.Name()), "server") + serverPath := path.Join(generation.GenPkg(), dir, servicePath, "server") serverPackage, err := generation.ClaimPackage(serverPath) if err != nil { return nil, err } + plan.serverPackages[transportService] = serverPackage + plan.extensions[transportService] = &serverExtensions{ + endpointHandlerWrappers: make(map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration), + } clientCatalog := packages[clientPath] if clientCatalog == nil { clientCatalog = newWireTypeCatalog(clientPackage) @@ -945,11 +1550,19 @@ func newPlan(generation *codegen.Generation, transport transportKind, input Plan packages[serverPath] = serverCatalog } planned := &plannedWireTypes{ - server: serverCatalog, - client: clientCatalog, - streamPayloads: make(map[*expr.HTTPEndpointExpr]*wireTypeRecord), + server: serverCatalog, + client: clientCatalog, + transforms: plannedWireTransforms{ + requests: make(map[clientBodyConstructorKey]*plannedRequestTransforms), + responses: make(map[viewedConstructorKey]*plannedResponseTransforms), + errors: make(map[*expr.HTTPErrorExpr]*plannedResponseTransforms), + streamingResults: make(map[*expr.HTTPEndpointExpr]*plannedResponseTransforms), + }, + streamPayloads: make(map[*expr.HTTPEndpointExpr]*wireTypeRecord), + clientBodyConstructors: make(map[clientBodyConstructorKey]*codegen.NameDeclaration), + clientBodyConstructorNames: make(map[clientBodyConstructorKey]string), } - collectPlannedWireTypes(transportService, planned, input.Service) + collectPlannedWireTypes(input.Root.API.Name, transportService, planned, input.Service) plan.wireTypes[transportService] = planned symbols, err := collectHTTPSymbols(plan, transportService, clientPackage, serverPackage) if err != nil { @@ -959,9 +1572,31 @@ func newPlan(generation *codegen.Generation, transport transportKind, input Plan for _, endpoint := range transportService.HTTPEndpoints { order := viewedConstructorOrder{ transport: dir, + api: input.Root.API.Name, service: transportService.Name(), method: endpoint.Name(), } + for _, role := range []wireTypeRole{wireRequestBody, wireStreamPayload} { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + preferred := planned.clientBodyConstructorNames[key] + if preferred == "" { + continue + } + body := planned.bodies.request(endpoint) + if role == wireStreamPayload { + body = planned.bodies.streaming(endpoint) + } + preferred = planned.client.releasedCompositeConstructorName(body, jsonBodyPolicy(true, false, false, "")) + orderRole := "request body" + if role == wireStreamPayload { + orderRole = "streaming body" + } + declaration, err := declareHTTPConstructor(clientPackage, preferred, order.withRole(orderRole)) + if err != nil { + return nil, err + } + planned.clientBodyConstructors[key] = declaration + } if needInit(endpoint.MethodExpr.Payload.Type) { declaration, err := declareHTTPConstructor(serverPackage, endpointPayloadConstructorName(endpoint), order.withRole("payload")) if err != nil { @@ -1049,7 +1684,7 @@ func newPlan(generation *codegen.Generation, transport transportKind, input Plan if err != nil { return nil, err } - plan.cliParsers[server] = parser + plan.cliParsers[server.Name] = parser } return plan, nil } @@ -1071,11 +1706,37 @@ func (p *Plan) link() error { services.plannedWireTypes = p.wireTypes services.plannedSymbols = p.symbols services.cliParsers = p.cliParsers + services.fileImports = make(map[string][]*codegen.ImportSpec, len(p.fileImports)) + for filePath, retained := range p.fileImports { + imports := make([]*codegen.ImportSpec, len(retained.paths)) + for index, importPath := range retained.paths { + imports[index] = retained.output.Import(importPath) + } + services.fileImports[filePath] = imports + } for _, transportService := range services.Expressions.Services { if services.ServicesData.Get(transportService.Name()) == nil { return fmt.Errorf("HTTP service %q has no linked service model", transportService.Name()) } - services.HTTPData[transportService.Name()] = services.analyze(transportService) + data := services.analyze(transportService) + if services.linkErr != nil { + return services.linkErr + } + extensions := p.extensions[transportService] + data.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + for index, endpoint := range data.Endpoints { + endpoint.ServerHandlerWrappers = combinedHandlerWrappers(extensions, transportService.HTTPEndpoints[index]) + } + for _, fileServer := range data.FileServers { + fileServer.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + } + data.ServerMounts = copyServerMounts(extensions.mounts) + services.HTTPData[transportService.Name()] = data + } + for _, planned := range p.wireTypes { + if err := planned.checkTransformsUsed(); err != nil { + return err + } } p.services = services p.viewed = make(map[viewedMethodKey]*viewedResultPlan) @@ -1090,13 +1751,23 @@ func (p *Plan) link() error { } jsonService := &jsonRPCServicePlan{ data: serviceData, - services: services, fileImports: make(map[string][]*codegen.ImportSpec), clientCodec: clientEncodeDecodeFile(transportService, services), serverCodec: serverEncodeDecodeFile(transportService, services), } + servicePackage, viewsPackage, err := servicePackagePreferences(p.servicePlan, transportService) + if err != nil { + return err + } + planned := p.wireTypes[transportService] + jsonService.clientServiceImport = planned.client.pkg.Import(servicePackage.Path) + jsonService.serverServiceImport = planned.server.pkg.Import(servicePackage.Path) + if viewsPackage != nil { + jsonService.clientViewImport = planned.client.pkg.Import(viewsPackage.Path) + jsonService.serverViewImport = planned.server.pkg.Import(viewsPackage.Path) + } if p.transport == jsonrpcTransport { - jsonService.prepareFileImports(transportService, services) + jsonService.prepareFileImports(services) } p.jsonServices[serviceName] = jsonService for _, endpoint := range serviceData.Endpoints { @@ -1128,9 +1799,7 @@ func (p *Plan) link() error { if p.transport == httpTransport { p.server = serverFiles(services) p.client = clientFiles(services) - p.example = exampleServerFiles(services) } - p.exampleCLI = exampleCLIFiles(services) p.serverTypes = serverTypeFiles(services) p.clientTypes = clientTypeFiles(services) p.paths = pathFiles(services) @@ -1138,6 +1807,81 @@ func (p *Plan) link() error { return nil } +// checkTransformsUsed verifies that every conversion retained by this service +// plan was written exactly once while its template data was built. +func (p *plannedWireTypes) checkTransformsUsed() error { + checkRequest := func(transforms *plannedRequestTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.client, transforms.clientEncode}, + {p.server, transforms.serverDecode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + checkResponse := func(transforms *plannedResponseTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.server, transforms.serverEncode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + for _, transforms := range p.transforms.requests { + if err := checkRequest(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.responses { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.errors { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.streamingResults { + if err := checkResponse(transforms); err != nil { + return err + } + } + return nil +} + +// combinedHandlerWrappers copies the service wrappers followed by the wrappers +// declared for one endpoint. Templates nest the first entry outermost. +func combinedHandlerWrappers(extensions *serverExtensions, endpoint *expr.HTTPEndpointExpr) []*codegen.NameDeclaration { + wrappers := make([]*codegen.NameDeclaration, 0, len(extensions.handlerWrappers)+len(extensions.endpointHandlerWrappers[endpoint])) + wrappers = append(wrappers, extensions.handlerWrappers...) + return append(wrappers, extensions.endpointHandlerWrappers[endpoint]...) +} + +// copyServerMounts gives render code its own mount functions and route entries. +func copyServerMounts(source []*ServerMount) []*ServerMount { + result := make([]*ServerMount, len(source)) + for index, mount := range source { + result[index] = &ServerMount{ + Declaration: mount.Declaration, + MountPoints: append([]ServerMountPoint(nil), mount.MountPoints...), + } + } + return result +} + // transportExpressions returns the HTTP or JSON-RPC designs requested // by the caller. func transportExpressions(root *expr.RootExpr, transport transportKind) *expr.HTTPExpr { @@ -1170,44 +1914,33 @@ func (p *Plan) requireLinked() { } } -// prepareFileImports computes the service-type imports for every JSON-RPC file -// that this service can generate. JSON-RPC later reads these lists without -// walking the HTTP endpoint types again. -func (p *jsonRPCServicePlan) prepareFileImports(transportService *expr.HTTPServiceExpr, services *ServicesData) { - var all, sse, websocket []*expr.AttributeExpr - for index, endpoint := range transportService.HTTPEndpoints { - references := serviceReferenceAttributes(endpoint) - all = append(all, references...) - switch { - case p.data.Endpoints[index].SSE != nil: - sse = append(sse, references...) - case IsWebSocketEndpoint(p.data.Endpoints[index]): - websocket = append(websocket, references...) - } - } +// prepareFileImports copies the package paths collected for each JSON-RPC file +// before generation names were frozen. +func (p *jsonRPCServicePlan) prepareFileImports(services *ServicesData) { servicePath := p.data.Service.PathName - clientPackage := path.Join(services.GenPkg(), "jsonrpc", servicePath, "client") - serverPackage := path.Join(services.GenPkg(), "jsonrpc", servicePath, "server") - clientAll := services.AttributeImports(clientPackage, all...) - serverAll := services.AttributeImports(serverPackage, all...) - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "client.go")] = clientAll - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "server.go")] = serverAll + clientPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "client.go") + serverPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "server.go") + p.fileImports[clientPath] = cloneImportSpecs(services.fileImports[filepathKey(clientPath)]) + p.fileImports[serverPath] = cloneImportSpecs(services.fileImports[filepathKey(serverPath)]) if p.clientCodec != nil { - p.fileImports[p.clientCodec.Path] = clientAll + p.fileImports[p.clientCodec.Path] = cloneImportSpecs(services.fileImports[filepathKey(p.clientCodec.Path)]) } if p.serverCodec != nil { - p.fileImports[p.serverCodec.Path] = serverAll - } - if len(sse) > 0 { - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "stream.go")] = services.AttributeImports(clientPackage, sse...) - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "sse.go")] = services.AttributeImports(serverPackage, sse...) + p.fileImports[p.serverCodec.Path] = cloneImportSpecs(services.fileImports[filepathKey(p.serverCodec.Path)]) } - if len(websocket) > 0 { - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "websocket.go")] = services.AttributeImports(clientPackage, websocket...) - if len(sse) == 0 { - p.fileImports[path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "websocket.go")] = services.AttributeImports(serverPackage, websocket...) + hasSSE := false + for _, endpoint := range p.data.Endpoints { + if endpoint.SSE != nil { + hasSSE = true + break } } + if hasSSE { + clientStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "stream.go") + serverStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "sse.go") + p.fileImports[clientStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(clientStreamPath)]) + p.fileImports[serverStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(serverStreamPath)]) + } } // cloneImportSpecs copies an import list and each import value so a caller can @@ -1234,13 +1967,46 @@ func cloneImportSpec(source *codegen.ImportSpec) *codegen.ImportSpec { // viewedResultConstructorName returns the preferred constructor spelling for // one client response body selected by a result view. func viewedResultConstructorName(endpoint *expr.HTTPEndpointExpr, response *expr.HTTPResponseExpr, view string) string { - return "New" + codegen.Goify(endpoint.Name(), true) + "Result" + codegen.Goify(view, true) + codegen.Goify(http.StatusText(response.StatusCode), true) + status := codegen.Goify(http.StatusText(response.StatusCode), true) + if view != "" { + return "New" + codegen.Goify(endpoint.Name(), true) + "Result" + codegen.Goify(view, true) + status + } + return releasedMethodTypeConstructorName(endpoint.Name(), releasedMethodTypeName(endpoint.MethodExpr.Result, "Result"), "Result") + status } // endpointPayloadConstructorName returns the preferred server function name // that builds one method payload from its HTTP request values. func endpointPayloadConstructorName(endpoint *expr.HTTPEndpointExpr) string { - return "New" + codegen.Goify(endpoint.Name(), true) + "Payload" + method := codegen.Goify(endpoint.Name(), true) + payload := codegen.Goify(releasedMethodTypeName(endpoint.MethodExpr.Payload, "Payload"), true) + if strings.HasPrefix(payload, method) { + return "New" + payload + } + return "New" + method + payload +} + +// releasedMethodTypeName returns the service-side spelling used before HTTP +// constructors were planned separately. It specializes arrays and maps from +// their element types, such as ElemType and MapKeyTypeElemType. +func releasedMethodTypeName(attribute *expr.AttributeExpr, role string) string { + name := codegen.NewNameScope().GoTypeName(attribute) + if name == "" { + return role + } + return name +} + +// releasedMethodTypeConstructorName joins a method and its service type while +// avoiding a repeated type stem. For example, FetchCustomer and Customer +// produce NewFetchCustomerResult. +func releasedMethodTypeConstructorName(method, typeName, role string) string { + method = codegen.Goify(method, true) + typeName = codegen.Goify(typeName, true) + stem := strings.TrimSuffix(typeName, role) + if stem != typeName && stem != "" && strings.HasSuffix(method, stem) { + return "New" + method + role + } + return "New" + method + typeName } // declareHTTPConstructor submits one constructor name to the generated package @@ -1265,6 +2031,7 @@ func (o viewedConstructorOrder) ComparePackageName(other codegen.PackageNameOrde right := other.(viewedConstructorOrder) for _, compared := range []int{ cmp.Compare(o.transport, right.transport), + cmp.Compare(o.api, right.api), cmp.Compare(o.service, right.service), cmp.Compare(o.method, right.method), cmp.Compare(o.role, right.role), diff --git a/http/codegen/plan_extensions_test.go b/http/codegen/plan_extensions_test.go new file mode 100644 index 0000000000..e6febfc0fa --- /dev/null +++ b/http/codegen/plan_extensions_test.go @@ -0,0 +1,180 @@ +// This file checks the handler wrappers and extra routes that plugins declare +// before Goa chooses generated package names. +package codegen + +import ( + "cmp" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type extensionNameOrder string + +// ComparePackageName gives extension declarations a stable order in tests. +func (o extensionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(extensionNameOrder))) +} + +func TestPlanDeclaresServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + + first, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("first")) + require.NoError(t, err) + second, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("second")) + require.NoError(t, err) + endpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + secondEndpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("second endpoint")) + require.NoError(t, err) + descriptions := []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}} + mount, err := plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), descriptions) + require.NoError(t, err) + descriptions[0].Method = "changed" + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + data := plan.services.Get("Files") + require.Equal(t, "WrapHandler", first.Name()) + require.Equal(t, "WrapHandler2", second.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second}, data.ServerHandlerWrappers) + require.Equal(t, "wrapEndpoint", endpointWrapper.Name()) + require.Equal(t, "wrapEndpoint2", secondEndpointWrapper.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second, endpointWrapper, secondEndpointWrapper}, data.Endpoints[0].ServerHandlerWrappers) + for _, fileServer := range data.FileServers { + require.Equal(t, []*codegen.NameDeclaration{first, second}, fileServer.ServerHandlerWrappers) + } + require.Equal(t, mount, data.ServerMounts[0].Declaration) + require.Equal(t, []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}}, data.ServerMounts[0].MountPoints) +} + +func TestPlanRejectsInvalidServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + foreignRoot := extensionRoot(t) + foreignService := foreignRoot.API.HTTP.Services[0] + foreignEndpoint := foreignService.HTTPEndpoints[0] + + tests := []struct { + name string + call func() error + want string + }{ + {"nil service", func() error { + _, err := plan.DeclareServerHandlerWrapper(nil, "Wrap", extensionNameOrder("nil")) + return err + }, "HTTP server extension requires a service from this plan"}, + {"foreign service", func() error { + _, err := plan.DeclareServerHandlerWrapper(foreignService, "Wrap", extensionNameOrder("foreign")) + return err + }, "HTTP service does not belong to this plan"}, + {"nil endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(nil, "wrap", extensionNameOrder("nil endpoint")) + return err + }, "HTTP server endpoint wrapper requires an endpoint from this plan"}, + {"foreign endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(foreignEndpoint, "wrap", extensionNameOrder("foreign endpoint")) + return err + }, "HTTP endpoint does not belong to this plan"}, + {"empty preferred name", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "", extensionNameOrder("empty")) + return err + }, "package name must not be empty"}, + {"nil order", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Wrap", nil) + return err + }, `generated package "generated.local/gen/http/files/server" cannot declare preferred function "Wrap": package name order must be a stable concrete named value type`}, + {"no mount descriptions", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("none"), nil) + return err + }, "HTTP server mount requires at least one mount point"}, + {"empty method", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("method"), []ServerMountPoint{{Verb: "OPTIONS", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty method"}, + {"empty verb", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("verb"), []ServerMountPoint{{Method: "Preflight", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty verb"}, + {"empty pattern", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("pattern"), []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS"}}) + return err + }, "HTTP server mount point 0 has an empty pattern"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.EqualError(t, test.call(), test.want) + }) + } + + require.NoError(t, generation.Freeze()) + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Late", extensionNameOrder("late")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "late", extensionNameOrder("late endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Linked", extensionNameOrder("linked")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "linked", extensionNameOrder("linked endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") +} + +func TestJSONRPCPlanRejectsServerExtensions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("RPC", func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + }) + plan, _, _ := plannedHTTPPlan(t, root, true) + _, err := plan.DeclareServerHandlerWrapper(root.API.JSONRPC.Services[0], "Wrap", extensionNameOrder("rpc")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") + _, err = plan.DeclareServerEndpointHandlerWrapper(root.API.JSONRPC.Services[0].HTTPEndpoints[0], "wrap", extensionNameOrder("rpc endpoint")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") +} + +// extensionRoot builds the HTTP service used to test endpoint and file wrappers. +func extensionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Files", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Attribute("id", dsl.String) }) + dsl.HTTP(func() { dsl.GET("/items/{id}") }) + }) + dsl.Files("/assets/{*path}", "assets") + dsl.Files("/old", "old.html", func() { dsl.Redirect("/new", 301) }) + }) + }) +} + +// plannedHTTPPlan creates an HTTP or JSON-RPC plan without linking it so each +// test can add server functions before generated names become final. +func plannedHTTPPlan(t *testing.T, root *expr.RootExpr, jsonrpc bool) (*Plan, *codegen.Generation, *service.Plan) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + input := PlanInput{Root: root, Service: servicePlan} + var plans []*Plan + if jsonrpc { + plans, err = NewJSONRPCPlans(generation, input) + } else { + plans, err = NewPlans(generation, input) + } + require.NoError(t, err) + require.Len(t, plans, 1) + return plans[0], generation, servicePlan +} diff --git a/http/codegen/plan_service_test.go b/http/codegen/plan_service_test.go new file mode 100644 index 0000000000..2047413ba3 --- /dev/null +++ b/http/codegen/plan_service_test.go @@ -0,0 +1,60 @@ +// This file checks that plugins can read only the finalized HTTP service data +// that belongs to the exact service expression used to build a retained plan. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceRequiresLink(t *testing.T) { + root := serviceLookupRoot(t) + plan, _, _ := plannedHTTPPlan(t, root, false) + + require.PanicsWithValue(t, "HTTP render model requested before plan linking", func() { + plan.Service(root.API.HTTP.Services[0]) + }) +} + +func TestPlanServiceUsesExactExpression(t *testing.T) { + root := serviceLookupRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + alpha, ok := plan.Service(root.API.HTTP.Services[0]) + require.True(t, ok) + require.Equal(t, "Alpha", alpha.Service.Name) + + beta, ok := plan.Service(root.API.HTTP.Services[1]) + require.True(t, ok) + require.Equal(t, "Beta", beta.Service.Name) + require.NotSame(t, alpha, beta) + + foreign := serviceLookupRoot(t) + _, ok = plan.Service(foreign.API.HTTP.Services[0]) + require.False(t, ok) +} + +// serviceLookupRoot creates two services so the test can distinguish exact +// service identity from a lookup by a repeated name or position. +func serviceLookupRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/alpha") }) + }) + }) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/beta") }) + }) + }) + }) +} diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 0a87a19e9a..36277b9c1b 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -5,6 +5,8 @@ package codegen import ( "fmt" "path" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -20,20 +22,23 @@ import ( func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { root := expr.RunDSL(t, func() { dsl.Service("Path", func() { - dsl.Method("Read", func() {}) + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) }) }) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) require.NoError(t, err) servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - _, err = NewPlans(generation) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) services := servicePlan.Services() - require.Equal(t, "path2", services.ServiceImport("Path").Name) + clientOutput := "generated.local/gen/http/path/client" + require.Equal(t, "path", services.ServiceImport(clientOutput, "Path").Name) + serverOutput := "generated.local/gen/http/path/server" + require.Equal(t, "path2", services.ServiceImport(serverOutput, "Path").Name) } func TestPlanRejectsFrozenGeneration(t *testing.T) { @@ -45,6 +50,74 @@ func TestPlanRejectsFrozenGeneration(t *testing.T) { require.Error(t, err) } +func TestEndpointPayloadConstructorUsesReleasedTypeName(t *testing.T) { + cases := []struct { + name string + method string + payload string + want string + }{ + { + name: "named payload", + method: "MethodBodyUnion", + payload: "Union", + want: "NewMethodBodyUnionUnion", + }, + { + name: "overlapping payload", + method: "MethodA", + payload: "APayload", + want: "NewMethodAAPayload", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + endpoint := &expr.HTTPEndpointExpr{MethodExpr: &expr.MethodExpr{ + Name: test.method, + Payload: &expr.AttributeExpr{Type: wireCatalogType(test.payload, test.payload, "value", true)}, + }} + require.Equal(t, test.want, endpointPayloadConstructorName(endpoint)) + }) + } +} + +func TestViewedResultConstructorUsesReleasedTypeName(t *testing.T) { + endpoint := &expr.HTTPEndpointExpr{MethodExpr: &expr.MethodExpr{ + Name: "MethodBodyInlineObject", + Result: &expr.AttributeExpr{Type: wireCatalogType("ResultType", "result", "value", true)}, + }} + response := &expr.HTTPResponseExpr{StatusCode: 200} + + require.Equal(t, "NewMethodBodyInlineObjectResultTypeOK", viewedResultConstructorName(endpoint, response, "")) + require.Equal(t, "NewMethodBodyInlineObjectResultTinyOK", viewedResultConstructorName(endpoint, response, "tiny")) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + transport := linkedHTTPPlanForRoot(t, root) + + otherRoot := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "HTTP examples require server data created from the same service design") +} + // TestNewPlansRequiresEveryHTTPRoot proves package names cannot be requested // from only some of the HTTP designs in one generation. func TestNewPlansRequiresEveryHTTPRoot(t *testing.T) { @@ -112,15 +185,15 @@ func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { require.NoError(t, servicePlan.Link()) services := servicePlan.Services() - client := services.PackageImport("generated.local/gen/http/foo/client") - server := services.PackageImport("generated.local/gen/http/foo/server") - cli := services.PackageImport(path.Join( + cliOutput := path.Join( "generated.local/gen/http/cli", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), - )) - require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) - require.NotEqual(t, services.ServiceImport("Foosvr").Name, server.Name) - require.NotEmpty(t, cli.Name) + ) + client := services.PackageImport(cliOutput, "generated.local/gen/http/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/http/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foosvr", server.Name) } // TestPlanLinkEagerlyRetainsHTTPFiles proves Link analyzes every HTTP service @@ -141,15 +214,18 @@ func TestPlanLinkEagerlyRetainsHTTPFiles(t *testing.T) { require.NoError(t, err) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) plan := plans[0] require.NoError(t, plan.Link()) + examples, err := NewExamplePlan(plan, examplePlan) + require.NoError(t, err) _, ok := plan.JSONRPCService("Calc") require.True(t, ok) - require.NotEmpty(t, plan.ExampleServerFiles()) - require.NotEmpty(t, plan.ExampleCLIFiles()) + require.NotEmpty(t, examples.ServerFiles()) + require.NotEmpty(t, examples.CLIFiles()) serverCount := len(plan.ServerFiles()) clientCount := len(plan.ClientFiles()) @@ -180,13 +256,15 @@ func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { require.NoError(t, err) plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) service, ok := plans[0].JSONRPCService("Calc") require.True(t, ok) + serverBody := service.Endpoints[0].Payload.Request.ServerBody + require.NotNil(t, serverBody.Declaration) + require.Equal(t, serverBody.Declaration.Name(), serverBody.VarName) stored := plans[0].jsonServices["Calc"] assertIndependentCodecFile(t, stored.clientCodec, service.ClientCodecFile) assertIndependentCodecFile(t, stored.serverCodec, service.ServerCodecFile) @@ -215,6 +293,131 @@ func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { require.Empty(t, fresh.Endpoints[0].Payload.Request.Headers) } +// TestPlanRetainsAttributeImportsBeforeFreeze proves ordinary HTTP and +// JSON-RPC files use the type packages recorded during planning, even if the +// design expression is changed before linking. +func TestPlanRetainsAttributeImportsBeforeFreeze(t *testing.T) { + for _, transport := range []struct { + name string + plan func(*codegen.Generation, PlanInput) ([]*Plan, error) + file func(*Plan) []*codegen.ImportSpec + }{ + { + name: "HTTP", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + for _, file := range plan.ClientFiles() { + if strings.HasSuffix(filepath.ToSlash(file.Path), "/client/client.go") { + return file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec) + } + } + return nil + }, + }, + { + name: "JSON-RPC", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewJSONRPCPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + service, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + return service.FileImports("gen/jsonrpc/calc/client/client.go") + }, + }, + } { + t.Run(transport.name, func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { + dsl.Attribute("number", dsl.Int, func() { + dsl.Meta("struct:field:type", "values.Number", "example.com/values", "values") + }) + }) + dsl.Result(dsl.Int) + if transport.name == "HTTP" { + dsl.HTTP(func() { dsl.POST("/add") }) + } else { + dsl.JSONRPC(func() {}) + } + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := transport.plan(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + payload := root.Service("Calc").Method("Add").Payload + delete(expr.AsObject(payload.Type).Attribute("number").Meta, "struct:field:type") + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + imports := transport.file(plans[0]) + require.Contains(t, importPaths(imports), "example.com/values") + }) + } +} + +// importPaths returns the package paths from one generated file header. +func importPaths(imports []*codegen.ImportSpec) []string { + paths := make([]string, len(imports)) + for index, spec := range imports { + paths[index] = spec.Path + } + return paths +} + +// TestJSONRPCSnapshotsExposeReleasedNames checks that copied JSON-RPC data +// gives existing plugins the final Go name stored in each declaration. +func TestJSONRPCSnapshotsExposeReleasedNames(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + for _, name := range []string{"read-data", "read_data"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Result(dsl.Int) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + snapshot, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + assertReleasedName(t, snapshot.ServerStruct, snapshot.ServerStructDeclaration) + assertReleasedName(t, snapshot.ServerInit, snapshot.ServerInitDeclaration) + assertReleasedName(t, snapshot.MountServer, snapshot.MountServerDeclaration) + assertReleasedName(t, snapshot.ClientStruct, snapshot.ClientStructDeclaration) + require.Len(t, snapshot.Endpoints, 2) + for index := range snapshot.Endpoints { + endpoint := &snapshot.Endpoints[index] + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + } + require.NotEqual(t, snapshot.Endpoints[0].HandlerInit, snapshot.Endpoints[1].HandlerInit) +} + // TestViewedResultSnapshotsPreserveMissingBodies checks that a successful // response containing only a mapped header keeps both body values absent. It // also changes the returned header and confirms a later copy is unchanged. @@ -240,7 +443,6 @@ func TestViewedResultSnapshotsPreserveMissingBodies(t *testing.T) { require.NoError(t, err) plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -289,7 +491,6 @@ func TestViewedResultCopiesBodyFieldSelection(t *testing.T) { require.NoError(t, err) plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -319,11 +520,10 @@ func TestViewedResultCopiesBodyFieldSelection(t *testing.T) { func TestEndpointConstructorsUsePackageDeclarations(t *testing.T) { root := expr.RunDSL(t, func() { dsl.Service("Calc", func() { - dsl.Error("BadInput", func() { dsl.Attribute("message", dsl.String) }) dsl.Method("Add", func() { dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) - dsl.Error("BadInput") + dsl.Error("BadInput", func() { dsl.Attribute("message", dsl.String) }) dsl.HTTP(func() { dsl.POST("/add") dsl.Response("BadInput", dsl.StatusBadRequest) @@ -337,7 +537,6 @@ func TestEndpointConstructorsUsePackageDeclarations(t *testing.T) { require.NoError(t, err) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -372,7 +571,6 @@ func TestHTTPTypeAndConstructorNamesShareOnePackage(t *testing.T) { require.NoError(t, err) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -390,9 +588,10 @@ func TestHTTPTypeAndConstructorNamesShareOnePackage(t *testing.T) { // resolve to the same generated directory. NewPlans must submit both sets of // function names together so definitions and calls remain distinct. func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { - makeRoot := func(serviceName string) *expr.RootExpr { + makeRoot := func(apiName string) *expr.RootExpr { return expr.RunDSL(t, func() { - dsl.Service(serviceName, func() { + dsl.API(apiName, func() {}) + dsl.Service("Shared", func() { dsl.Method("Add", func() { dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) dsl.HTTP(func() { dsl.POST("/add") }) @@ -400,8 +599,8 @@ func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { }) }) } - first := makeRoot("Foo Bar") - second := makeRoot("Foo-Bar") + first := makeRoot("First") + second := makeRoot("Second") generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) require.NoError(t, err) servicePlans, err := service.NewPlans(generation, @@ -414,15 +613,14 @@ func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { PlanInput{Root: second, Service: servicePlans[1]}, ) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) for index := range plans { require.NoError(t, servicePlans[index].Link()) require.NoError(t, plans[index].Link()) } - firstService := plans[0].services.Get("Foo Bar") - secondService := plans[1].services.Get("Foo-Bar") + firstService := plans[0].services.Get("Shared") + secondService := plans[1].services.Get("Shared") firstInit := firstService.Endpoints[0].Payload.Request.PayloadInit secondInit := secondService.Endpoints[0].Payload.Request.PayloadInit require.NotEqual(t, firstInit.Name, secondInit.Name) @@ -478,7 +676,6 @@ func TestHTTPHelperDefinitionsUseAssignedNames(t *testing.T) { PlanInput{Root: second, Service: servicePlans[1]}, ) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) for index := range plans { require.NoError(t, servicePlans[index].Link()) diff --git a/http/codegen/plan_test_helpers_test.go b/http/codegen/plan_test_helpers_test.go index 0f50829f35..6ba6bc6422 100644 --- a/http/codegen/plan_test_helpers_test.go +++ b/http/codegen/plan_test_helpers_test.go @@ -24,9 +24,28 @@ func linkedHTTPPlanForRoot(t *testing.T, root *expr.RootExpr) *Plan { require.NoError(t, err) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) return plans[0] } + +// linkedHTTPExamplePlanForRoot builds an HTTP plan whose copied server data +// belongs to the same service plan. +func linkedHTTPExamplePlanForRoot(t *testing.T, root *expr.RootExpr) *ExamplePlan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return examples +} diff --git a/http/codegen/planned_name_collision_test.go b/http/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..1ce7a32e6b --- /dev/null +++ b/http/codegen/planned_name_collision_test.go @@ -0,0 +1,294 @@ +// This file proves generated HTTP definitions and their callers use the same +// package names after another generator claims the preferred spelling. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestHTTPPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + child := dsl.Type("ChildPayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + clientPackage, err := generation.ClaimPackage("generated.local/gen/http/names/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildCompleteRequest"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "validateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameType, "SocketServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + complete := serviceData.Endpoint("Complete") + require.Equal(t, "BuildCompleteRequest2", complete.RequestInit.Declaration.Name()) + require.Equal(t, complete.RequestInit.Declaration.Name(), complete.RequestInit.Name) + childData := releasedTypeData(t, serviceData, func(data *TypeData) bool { + return data.Declaration != nil && strings.HasPrefix(data.Declaration.Name(), "ChildPayload") + }) + require.Equal(t, "ChildPayloadRequestBody2", childData.Declaration.Name()) + require.Equal(t, "ValidateChildPayloadRequestBody2", childData.ValidatorDeclaration.Name()) + require.Equal(t, "validateChildPayloadRequestBody2", childData.NestedValidatorDeclaration.Name()) + require.Equal(t, childData.Declaration.Name(), childData.VarName) + require.Equal(t, childData.ValidatorDeclaration.Name(), childData.ValidatorName) + require.Equal(t, childData.NestedValidatorDeclaration.Name(), childData.NestedValidatorName) + socket := serviceData.Endpoint("Socket").ServerWebSocket + require.Equal(t, "SocketServerStream2", socket.VarDeclaration.Name()) + require.Equal(t, socket.VarDeclaration.Name(), socket.VarName) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ClientFiles(), "encode_decode.go", "request-builder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ClientFiles(), "client.go", "client-endpoint-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-body-attributes", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == complete.Payload.Request.ServerBody.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerFiles(), "encode_decode.go", "request-decoder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerFiles(), "websocket.go", "server-websocket-struct-type", func(data any) bool { + stream, ok := data.(*WebSocketData) + return ok && stream.VarDeclaration == socket.VarDeclaration + }}, + {plans[0].ServerFiles(), "server.go", "server-handler-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Socket" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// TestHTTPUnionPlannedNamesSurvivePackageCollisions checks that a union's type, +// kind, constants, constructors, and every use share the names selected by the +// generated package. +func TestHTTPUnionPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + dsl.Attribute("count", dsl.Int) + }) + dsl.Required("choice") + }) + dsl.HTTP(func() { + dsl.POST("/choose") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "Choice"), + codegen.NewExactName(codegen.NameType, "ChoiceKind"), + codegen.NewExactName(codegen.NameConstant, "ChoiceKindText"), + codegen.NewExactName(codegen.NameConstant, "ChoiceKindCount"), + codegen.NewExactName(codegen.NameFunction, "NewChoiceText"), + codegen.NewExactName(codegen.NameFunction, "NewChoiceCount"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + unions := plans[0].services.Get("Names").serverWireTypes.unionTypes() + require.Len(t, unions, 1) + union := unions[0] + require.NotEqual(t, "Choice", union.TypeDeclaration.Name()) + require.NotEqual(t, "ChoiceKind", union.KindDeclaration.Name()) + for _, field := range union.Fields { + require.NotEqual(t, "ChoiceKind"+codegen.Goify(field.Name, true), field.KindDeclaration.Name()) + require.NotEqual(t, "NewChoice"+codegen.Goify(field.Name, true), field.ConstructorDeclaration.Name()) + } + sections := codegentest.Sections(plans[0].ServerTypeFiles(), "types.go", "server-union-type") + require.Len(t, sections, 1) + testutil.AssertGo(t, "testdata/golden/planned_union_name_collisions.go.golden", codegen.SectionCode(t, sections[0])) +} + +// TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions checks that the +// JSON-RPC body validator definition and decoder call use the same planned +// declaration after the preferred names are already taken. +func TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("ChoosePayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(payload) + dsl.JSONRPC(func() { + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/jsonrpc/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "DecodeChooseRequest"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + body := serviceData.Endpoint("Choose").Payload.Request.ServerBody + require.NotEqual(t, "ChooseRequestBody", body.Declaration.Name()) + require.NotEqual(t, "ValidateChooseRequestBody", body.ValidatorDeclaration.Name()) + snapshot, ok := plans[0].JSONRPCService("Names") + require.True(t, ok) + require.NotEqual(t, "DecodeChooseRequest", snapshot.Endpoints[0].RequestDecoderDeclaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ServerTypeFiles(), "types.go", "request-body-type-decl", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {[]*codegen.File{snapshot.ServerCodecFile()}, "encode_decode.go", "request-decoder", func(data any) bool { + endpoint := plannedJSONRPCEndpoint(data) + return endpoint != nil && endpoint.Method.Name == "Choose" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_jsonrpc_validator_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// plannedJSONRPCEndpoint returns the copied endpoint stored in a JSON-RPC +// request codec section. +func plannedJSONRPCEndpoint(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + case *JSONRPCEndpointSnapshot: + return actual + default: + return nil + } +} diff --git a/http/codegen/planned_service_name_uses_test.go b/http/codegen/planned_service_name_uses_test.go new file mode 100644 index 0000000000..bc3dcf207b --- /dev/null +++ b/http/codegen/planned_service_name_uses_test.go @@ -0,0 +1,208 @@ +// This file verifies that service declarations and transport callers keep the +// same Go names when authored types claim the usual generated spellings. +package codegen + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + gencodegengrpc "goa.design/goa/v3/grpc/codegen" +) + +type ( + // plannedNameSection identifies one complete generated section or one file + // body included in the cross-transport golden output. + plannedNameSection struct { + label string + files []*codegen.File + file string + name string + whole bool + } +) + +// TestPlannedServiceNamesUsedAcrossTransports renders each definition and use +// from the same generation so a changed or rebuilt name breaks one fixture. +func TestPlannedServiceNamesUsedAcrossTransports(t *testing.T) { + root := expr.RunDSL(t, plannedServiceNameUsesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + grpcPlans, err := gencodegengrpc.NewPlans(generation, gencodegengrpc.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + httpExamples, err := NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + grpcExamples, err := gencodegengrpc.NewExamplePlan(grpcPlans[0], examplePlan) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, grpcPlans[0].Link()) + + serviceData := servicePlan.Services().Get("Collisions") + require.NotNil(t, serviceData) + method := serviceData.Methods[0] + require.Equal(t, "Endpoints2", serviceData.EndpointsDeclaration.Name()) + require.Equal(t, "MethodNames2", serviceData.MethodNamesDeclaration.Name()) + require.Equal(t, "ClientInterceptors2", serviceData.ClientInterceptorsDeclaration.Name()) + require.Equal(t, "WrapReadClientEndpoint2", method.ClientEndpointWrapperDeclaration.Name()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + sections := []plannedNameSection{ + { + label: "service method names definition", + files: serviceFiles, + file: "service.go", + name: "service", + }, + { + label: "service endpoints definition", + files: serviceFiles, + file: "endpoints.go", + name: "endpoints-struct", + }, + { + label: "client interceptors definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-interceptors-type", + }, + { + label: "client endpoint wrapper definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-wrapper", + }, + { + label: "HTTP server endpoints use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "HTTP server method names use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-method-names", + }, + { + label: "HTTP command parser", + files: httpPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint", + }, + { + label: "HTTP example client interceptor use", + files: httpExamples.CLIFiles(), + file: "http.go", + whole: true, + }, + { + label: "gRPC server endpoints use", + files: grpcPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "gRPC example server endpoints use", + files: grpcExamples.ServerFiles(), + file: "grpc.go", + whole: true, + }, + { + label: "gRPC command parser", + files: grpcPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint-grpc", + }, + } + + var source strings.Builder + for _, section := range sections { + source.WriteString("===== ") + source.WriteString(section.label) + source.WriteString(" =====\n") + source.WriteString(plannedNameSectionCode(t, section)) + source.WriteString("\n") + } + testutil.AssertString(t, "testdata/golden/planned_service_name_uses.go.golden", source.String()) +} + +// plannedNameSectionCode renders either one complete section or the complete +// body of a file whose opening and closing statements span several sections. +func plannedNameSectionCode(t *testing.T, section plannedNameSection) string { + t.Helper() + if !section.whole { + matches := codegentest.Sections(section.files, section.file, section.name) + require.Len(t, matches, 1, section.label) + return codegen.SectionCode(t, matches[0]) + } + for _, file := range section.files { + if filepath.Base(file.Path) == section.file { + var source bytes.Buffer + for _, part := range file.SectionTemplates[1:] { + require.NoError(t, part.Write(&source)) + } + return codegen.FormatTestCode(t, "package foo\n"+source.String()) + } + } + require.Fail(t, "missing generated file", section.label) + return "" +} + +// plannedServiceNameUsesDSL makes authored types claim four names normally +// chosen for the generated service and interceptor code. +func plannedServiceNameUsesDSL() { + endpointType := dsl.Type("Endpoints", dsl.String) + methodNamesType := dsl.Type("MethodNames", dsl.String) + interceptorsType := dsl.Type("ClientInterceptors", dsl.String) + wrapperType := dsl.Type("WrapReadClientEndpoint", dsl.String) + trace := dsl.Interceptor("Trace") + + dsl.API("Name Test", func() { + dsl.Server("test", func() { + dsl.Host("development", func() { + dsl.URI("http://localhost:80") + }) + }) + }) + dsl.Service("Collisions", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "endpoint", endpointType) + dsl.Field(2, "method_names", methodNamesType) + dsl.Field(3, "interceptors", interceptorsType) + dsl.Field(4, "wrapper", wrapperType) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/read") + }) + dsl.GRPC(func() { + }) + }) + }) +} diff --git a/http/codegen/plugin_api_compatibility_test.go b/http/codegen/plugin_api_compatibility_test.go new file mode 100644 index 0000000000..42a6762fb7 --- /dev/null +++ b/http/codegen/plugin_api_compatibility_test.go @@ -0,0 +1,183 @@ +// This file checks the public HTTP plugin fields kept for existing plugins. +package codegen + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles + _ func(*ServicesData) []*codegen.File = PathFiles + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ClientEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ServerEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = WebsocketClientFile +) + +// TestReleasedHTTPNamesMatchDeclarations checks all 23 released string fields, +// including optional fields that are empty when no declaration exists. +func TestReleasedHTTPNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + assertReleasedName(t, service.ServerStruct, service.ServerStructDeclaration) + assertReleasedName(t, service.MountPointStruct, service.MountPointStructDeclaration) + assertReleasedName(t, service.ServerInit, service.ServerInitDeclaration) + assertReleasedName(t, service.MountServer, service.MountServerDeclaration) + assertReleasedName(t, service.ClientStruct, service.ClientStructDeclaration) + + for _, endpoint := range service.Endpoints { + assertReleasedName(t, endpoint.MountHandler, endpoint.MountHandlerDeclaration) + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseEncoder, endpoint.ResponseEncoderDeclaration) + assertReleasedName(t, endpoint.ErrorEncoder, endpoint.ErrorEncoderDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + assertReleasedName(t, endpoint.BuildStreamPayload, endpoint.BuildStreamPayloadDeclaration) + } + + multipart := service.Endpoint("Multipart") + for _, data := range []*MultipartData{multipart.MultipartRequestDecoder, multipart.MultipartRequestEncoder} { + require.NotNil(t, data) + assertReleasedName(t, data.FuncName, data.FuncDeclaration) + assertReleasedName(t, data.InitName, data.InitDeclaration) + } + stream := service.Endpoint("Watch").SSE + require.NotNil(t, stream) + assertReleasedName(t, stream.StructName, stream.StructDeclaration) + require.NotEmpty(t, service.FileServers) + assertReleasedName(t, service.FileServers[0].MountHandler, service.FileServers[0].MountHandlerDeclaration) + empty := service.Endpoint("Empty") + require.Nil(t, empty.RequestDecoderDeclaration) + require.Empty(t, empty.RequestDecoder) + socket := service.Endpoint("Socket").ServerWebSocket + require.NotNil(t, socket) + assertReleasedName(t, socket.VarName, socket.VarDeclaration) + assertReleasedName(t, service.Endpoint("Complete").RequestInit.Name, service.Endpoint("Complete").RequestInit.Declaration) + typeData := releasedTypeData(t, service, func(data *TypeData) bool { return data.NestedValidatorDeclaration != nil }) + assertReleasedName(t, typeData.VarName, typeData.Declaration) + assertReleasedName(t, typeData.ValidatorName, typeData.ValidatorDeclaration) + assertReleasedName(t, typeData.NestedValidatorName, typeData.NestedValidatorDeclaration) +} + +// TestReleasedHTTPFileFunctionsUsePlannedPackage checks that public helpers +// render the retained HTTP plan and reject a different generated package. +func TestReleasedHTTPFileFunctionsUsePlannedPackage(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, expr.RunDSL(t, testdata.MultiSimpleDSL)) + genpkg := plan.services.GenPkg() + for _, files := range []struct { + name string + released func(string, *ServicesData) []*codegen.File + planned func(*ServicesData) []*codegen.File + }{ + {name: "client", released: ClientFiles, planned: clientFiles}, + {name: "client CLI", released: ClientCLIFiles, planned: clientCLIFiles}, + {name: "server", released: ServerFiles, planned: serverFiles}, + {name: "server types", released: ServerTypeFiles, planned: serverTypeFiles}, + {name: "client types", released: ClientTypeFiles, planned: clientTypeFiles}, + } { + t.Run(files.name, func(t *testing.T) { + require.Len(t, files.released(genpkg, plan.services), len(files.planned(plan.services))) + require.PanicsWithValue( + t, + `HTTP generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { + files.released("other.local/gen", plan.services) + }, + ) + }) + } + require.Len(t, PathFiles(plan.services), len(pathFiles(plan.services))) + service := plan.root.API.HTTP.Services[0] + require.Equal(t, clientEncodeDecodeFile(service, plan.services).Path, ClientEncodeDecodeFile(genpkg, service, plan.services).Path) + require.Equal(t, serverEncodeDecodeFile(service, plan.services).Path, ServerEncodeDecodeFile(genpkg, service, plan.services).Path) + + websocket := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + websocketService := websocket.root.API.HTTP.Service("Names") + require.Equal( + t, + websocketClientFile(websocketService, websocket.services).Path, + WebsocketClientFile(websocket.services.GenPkg(), websocketService, websocket.services).Path, + ) +} + +// TestReleasedHTTPNameUsesCollisionSuffix checks that a compatibility field +// copies the final package name instead of rebuilding an unsuffixed name. +func TestReleasedHTTPNameUsesCollisionSuffix(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("read-data", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + dsl.Method("read_data", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("Calc").Endpoint("read_data") + require.NotEqual(t, "MountReadDataHandler", endpoint.MountHandlerDeclaration.Name()) + require.Contains(t, endpoint.MountHandlerDeclaration.Name(), "MountReadData") + require.Equal(t, endpoint.MountHandlerDeclaration.Name(), endpoint.MountHandler) +} + +// TestReleasedSSEDataFieldTypeMatchesPlannedValue verifies existing plugins +// can still read the final type of an explicitly mapped SSE data field. +func TestReleasedSSEDataFieldTypeMatchesPlannedValue(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Events", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + stream := linkedHTTPPlanForRoot(t, root).services.Get("Events").Endpoint("Watch").SSE + require.NotNil(t, stream) + require.NotEmpty(t, stream.DataField) + require.Equal(t, stream.Data.TypeRef, stream.DataFieldTypeRef) +} + +// TestReleasedHTTPNameFieldsRemainSourceCompatible checks old keyed literals +// and template reads still compile. +func TestReleasedHTTPNameFieldsRemainSourceCompatible(t *testing.T) { + data := struct { + Endpoint *EndpointData + Service *ServiceData + FileServer *FileServerData + Multipart *MultipartData + Stream *SSEData + WebSocket *WebSocketData + Init *InitData + Type *TypeData + }{ + &EndpointData{MountHandler: "MountReadHandler"}, + &ServiceData{ServerStruct: "Server"}, + &FileServerData{MountHandler: "MountAssetJSON"}, + &MultipartData{FuncName: "DecoderFunc", InitName: "NewDecoder"}, + &SSEData{StructName: "ReadServerStream"}, + &WebSocketData{VarName: "SocketServerStream"}, + &InitData{Name: "NewBody"}, + &TypeData{VarName: "Body", ValidatorName: "ValidateBody", NestedValidatorName: "validateBodyAt"}, + } + tmpl := template.Must(template.New("released-fields").Parse( + `{{.Endpoint.MountHandler}} {{.Service.ServerStruct}} {{.FileServer.MountHandler}} {{.Multipart.FuncName}} {{.Stream.StructName}} {{.WebSocket.VarName}} {{.Init.Name}} {{.Type.VarName}} {{.Type.ValidatorName}} {{.Type.NestedValidatorName}}`, + )) + var rendered bytes.Buffer + require.NoError(t, tmpl.Execute(&rendered, data)) + require.Equal(t, "MountReadHandler Server MountAssetJSON DecoderFunc ReadServerStream SocketServerStream NewBody Body ValidateBody validateBodyAt", rendered.String()) +} diff --git a/http/codegen/plugin_api_test_helpers_test.go b/http/codegen/plugin_api_test_helpers_test.go new file mode 100644 index 0000000000..3626963fe1 --- /dev/null +++ b/http/codegen/plugin_api_test_helpers_test.go @@ -0,0 +1,108 @@ +// This file builds one HTTP design that exercises every public name kept for +// existing plugins and provides small assertions shared by compatibility tests. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// releasedHTTPNamesRoot returns a service with ordinary, multipart, streaming, +// empty, and raw-body endpoints plus a file server. +func releasedHTTPNamesRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + child := dsl.Type("Child", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Multipart", func() { + dsl.Payload(dsl.String) + dsl.HTTP(func() { + dsl.POST("/multipart") + dsl.MultipartRequest() + }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + dsl.Method("Raw", func() { + dsl.HTTP(func() { + dsl.POST("/raw") + dsl.SkipRequestBodyEncodeDecode() + }) + }) + dsl.Method("Empty", func() { + dsl.HTTP(func() { + dsl.GET("/empty") + }) + }) + dsl.Files("/asset.json", "asset.json") + }) + }) +} + +// assertReleasedName checks that one public compatibility string contains the +// final name selected by its declaration. +func assertReleasedName(t *testing.T, name string, declaration *codegen.NameDeclaration) { + t.Helper() + if declaration == nil { + require.Empty(t, name) + return + } + require.Equal(t, declaration.Name(), name) +} + +// releasedTypeData returns the first planned HTTP body type accepted by match. +func releasedTypeData(t *testing.T, service *ServiceData, match func(*TypeData) bool) *TypeData { + t.Helper() + candidates := append([]*TypeData(nil), service.ServerBodyAttributeTypes...) + candidates = append(candidates, service.ClientBodyAttributeTypes...) + for _, endpoint := range service.Endpoints { + if endpoint.Payload != nil && endpoint.Payload.Request != nil { + candidates = append(candidates, endpoint.Payload.Request.ServerBody, endpoint.Payload.Request.ClientBody) + } + if endpoint.Result != nil { + for _, response := range endpoint.Result.Responses { + candidates = append(candidates, response.ServerBody...) + candidates = append(candidates, response.ClientBody) + } + } + } + for _, candidate := range candidates { + if candidate != nil && match(candidate) { + return candidate + } + } + require.FailNow(t, "planned HTTP body type was not found") + return nil +} diff --git a/http/codegen/released_streaming_name_test.go b/http/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..68c8cc7e6a --- /dev/null +++ b/http/codegen/released_streaming_name_test.go @@ -0,0 +1,33 @@ +// This file checks that WebSocket response collections keep the public Go +// names generated by released Goa versions. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +// TestReleasedStreamingResponseCollectionNames catches renaming a response +// collection merely because it is sent after receiving streamed input. +func TestReleasedStreamingResponseCollectionNames(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingPayloadResultCollectionWithExplicitViewDSL) + plan := linkedHTTPPlanForRoot(t, root) + + t.Run("server", func(t *testing.T) { + file := plan.ServerTypeFiles()[0] + sections := append(file.Section("response-server-body"), file.Section("server-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_server.go.golden", code) + }) + + t.Run("client", func(t *testing.T) { + file := plan.ClientTypeFiles()[0] + sections := append(file.Section("client-response-body"), file.Section("client-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_client.go.golden", code) + }) +} diff --git a/http/codegen/server.go b/http/codegen/server.go index 6c992d2656..b0f7d82628 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -27,17 +27,17 @@ type ( func serverFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { - files = append(files, addEndpointImports(serverFile(svc, data), data, svc.HTTPEndpoints...)) + files = append(files, addPlannedFileImports(serverFile(svc, data), data)) if f := websocketServerFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, httpWebSocketEndpoints(svc)...)) + files = append(files, addPlannedFileImports(f, data)) } if f := sseServerFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, httpSSEEndpoints(svc)...)) + files = append(files, addPlannedFileImports(f, data)) } } for _, svc := range data.Expressions.Services { if f := serverEncodeDecodeFile(svc, data); f != nil { - files = append(files, addEndpointImports(f, data, svc.HTTPEndpoints...)) + files = append(files, addPlannedFileImports(f, data)) } } return files @@ -48,6 +48,8 @@ func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File data := services.Get(svc.Name()) svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "http", svcName, "server", "server.go") + outputPackage := generatedFileOutputPackage(services, fpath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s HTTP server", svc.Name()) funcs := map[string]any{ "join": strings.Join, @@ -72,7 +74,7 @@ func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -138,6 +140,8 @@ func serverEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "server", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s server encoders and decoders", svc.Name(), services.label()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -152,10 +156,10 @@ func serverEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } if serviceHasViewedResult(data, nil) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections := []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} diff --git a/http/codegen/server_decode_test.go b/http/codegen/server_decode_test.go index e743b9badb..ee6d8bae66 100644 --- a/http/codegen/server_decode_test.go +++ b/http/codegen/server_decode_test.go @@ -175,6 +175,7 @@ func TestDecode(t *testing.T) { {"decode-body-primitive-bool-validate", testdata.PayloadBodyPrimitiveBoolValidateDSL}, {"decode-body-primitive-array-string-validate", testdata.PayloadBodyPrimitiveArrayStringValidateDSL}, {"decode-body-primitive-array-bool-validate", testdata.PayloadBodyPrimitiveArrayBoolValidateDSL}, + {"decode-body-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"decode-body-primitive-array-user-required", testdata.PayloadBodyPrimitiveArrayUserRequiredDSL}, {"decode-body-primitive-array-user-validate", testdata.PayloadBodyPrimitiveArrayUserValidateDSL}, @@ -203,8 +204,11 @@ func TestDecode(t *testing.T) { {"decode-map-query-object", testdata.PayloadMapQueryObjectDSL}, {"decode-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"decode-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"decode-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"decode-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"decode-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, + {"decode-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, + {"decode-multipart-with-params-and-headers", testdata.PayloadMultipartWithParamsAndHeadersDSL}, {"decode-with-params-and-headers-dsl", testdata.WithParamsAndHeadersBlockDSL}, {"decode-query-int-alias", testdata.QueryIntAliasDSL}, diff --git a/http/codegen/server_extensions_test.go b/http/codegen/server_extensions_test.go new file mode 100644 index 0000000000..10dba84b01 --- /dev/null +++ b/http/codegen/server_extensions_test.go @@ -0,0 +1,74 @@ +// This file compares generated server source for plugin-declared handler +// wrappers and additional routes with reviewed golden files. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "First", extensionNameOrder("first")) + require.NoError(t, err) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Second", extensionNameOrder("second")) + require.NoError(t, err) + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + _, err = plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), []ServerMountPoint{ + {Method: "Preflight item", Verb: "OPTIONS", Pattern: "/items/{id}"}, + {Method: "Preflight assets", Verb: "OPTIONS", Pattern: "/assets/{*path}"}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + files := plan.ServerFiles() + for _, test := range []struct { + section string + sectionIndex int + golden string + }{ + {"server-mount", 0, "testdata/golden/server_extensions_mount.go.golden"}, + {"server-handler", 0, "testdata/golden/server_extensions_endpoint_helper.go.golden"}, + {"server-files", 0, "testdata/golden/server_extensions_file_helper.go.golden"}, + {"server-files", 1, "testdata/golden/server_extensions_redirect_helper.go.golden"}, + {"server-init", 0, "testdata/golden/server_extensions_init.go.golden"}, + } { + sections := codegentest.Sections(files, "server.go", test.section) + require.Greater(t, len(sections), test.sectionIndex) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[test.sectionIndex])) + } +} + +func TestServerExtensionMountPointEscaping(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Escape", func() { + dsl.Method("Ping", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + _, err := plan.DeclareServerMount(root.API.HTTP.Services[0], "MountQuoted", extensionNameOrder("quoted"), []ServerMountPoint{{ + Method: "Quoted \"method\"\nnext", + Verb: "CUSTOM\\VERB", + Pattern: "/quoted/\"value\"\\next\nline", + }}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + sections := codegentest.Sections(plan.ServerFiles(), "server.go", "server-init") + require.Len(t, sections, 1) + testutil.AssertGo(t, "testdata/golden/server_extensions_escaping.go.golden", codegen.SectionCode(t, sections[0])) +} diff --git a/http/codegen/server_handler_test.go b/http/codegen/server_handler_test.go index ccdb426eb7..23aa0f770a 100644 --- a/http/codegen/server_handler_test.go +++ b/http/codegen/server_handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerHandler(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/server_init_test.go b/http/codegen/server_init_test.go index bc9b0b088a..4bc152d230 100644 --- a/http/codegen/server_init_test.go +++ b/http/codegen/server_init_test.go @@ -13,7 +13,6 @@ import ( ) func TestServerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/server_mount_test.go b/http/codegen/server_mount_test.go index 620120bd84..4c02cd02ba 100644 --- a/http/codegen/server_mount_test.go +++ b/http/codegen/server_mount_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerMount(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() diff --git a/http/codegen/server_types_test.go b/http/codegen/server_types_test.go index 0ca6c9c573..a892c68b19 100644 --- a/http/codegen/server_types_test.go +++ b/http/codegen/server_types_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -36,6 +35,8 @@ func TestServerTypes(t *testing.T) { {"server-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"server-cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, {"server-payload-with-validated-alias", testdata.PayloadWithValidatedAliasDSL}, + {"server-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"server-multipart-validation", testdata.PayloadMultipartValidationDSL}, {"server-streaming-payload-required-fields", testdata.StreamingPayloadRequiredFieldsDSL}, } for _, c := range cases { diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index b1d7338ab7..ab6c61a699 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -54,7 +54,13 @@ type ( // plannedSymbols contains the Go names used in each client and server package. plannedSymbols map[*expr.HTTPServiceExpr]*httpSymbols // cliParsers contains the function names for each command parser file. - cliParsers map[*expr.ServerExpr]*cli.ParserPlan + cliParsers map[string]*cli.ParserPlan + // linkErr is the first conversion error found while building template data. + // Plan.Link returns it before exposing any generated files. + linkErr error + // fileImports contains the exact design-derived imports collected for each + // generated file before package names were frozen. + fileImports map[string][]*codegen.ImportSpec } // ServiceData contains the data used to render the code related to a @@ -70,19 +76,45 @@ type ( Endpoints []*EndpointData // FileServers lists the file servers for this service. FileServers []*FileServerData - // ServerStructDeclaration is the package name used by server definitions and calls. + // ServerHandlerWrappers lists the planned wrapper declarations copied into + // every endpoint and file mount helper for this service. + ServerHandlerWrappers []*codegen.NameDeclaration + // ServerMounts lists functions that add routes after the routes defined in + // the design. + ServerMounts []*ServerMount + // ServerStruct is the server type name kept for existing plugins. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning so name collisions are handled. + ServerStruct string + // ServerStructDeclaration is the generated Go type name used by server definitions and calls. ServerStructDeclaration *codegen.NameDeclaration - // MountPointStructDeclaration is the package name used by the mount point type. + // MountPointStruct is the mount point type name kept for existing plugins. + // + // Deprecated: Use MountPointStructDeclaration.Name() after planning so name collisions are handled. + MountPointStruct string + // MountPointStructDeclaration is the generated Go type name used by the mount point type. MountPointStructDeclaration *codegen.NameDeclaration - // ServerInitDeclaration is the package name used by the server constructor. + // ServerInit is the server constructor name kept for existing plugins. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning so name collisions are handled. + ServerInit string + // ServerInitDeclaration is the generated Go function name used by the server constructor. ServerInitDeclaration *codegen.NameDeclaration - // MountServerDeclaration is the package name used by the route mount function. + // MountServer is the route mount function name kept for existing plugins. + // + // Deprecated: Use MountServerDeclaration.Name() after planning so name collisions are handled. + MountServer string + // MountServerDeclaration is the generated Go function name used to mount the service routes. MountServerDeclaration *codegen.NameDeclaration // ServerService is the name of service function. ServerService string - // ClientStructDeclaration is the package name used by the client type. + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. + ClientStruct string + // ClientStructDeclaration is the generated Go type name used by the client. ClientStructDeclaration *codegen.NameDeclaration - // ClientInitDeclaration is the package name used by the client constructor. + // ClientInitDeclaration is the generated Go function name used by the client constructor. ClientInitDeclaration *codegen.NameDeclaration // ServerConnConfigurerDeclaration names the server WebSocket configuration type. ServerConnConfigurerDeclaration *codegen.NameDeclaration @@ -110,14 +142,18 @@ type ( // ClientTransformHelpers is the list of transform functions // required by the various client side constructors. ClientTransformHelpers []*codegen.TransformFunctionData - // Scope initialized with all the server and client types. + // Scope records unique Go names for all server and client types. Scope *codegen.NameScope - // serverWireTypes owns declarations emitted in the actual server - // package. + // serverWireTypes stores declarations emitted in the server package. serverWireTypes *wireTypeCatalog - // clientWireTypes owns declarations emitted in the actual client - // package. + // clientWireTypes stores declarations emitted in the client package. clientWireTypes *wireTypeCatalog + // clientBodyConstructors contains planned constructors for unnamed + // request and streamed request body values. + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + // transforms contains the exact conversions selected while this + // service's HTTP shapes were planned. + transforms plannedWireTransforms // bodies stores copied request and response fields after applying the HTTP // mappings. Building service data must never change the input design. bodies shapedBodies @@ -166,15 +202,42 @@ type ( // server - // MountHandlerDeclaration is the package name used by this endpoint's mount function. + // MountHandler is the endpoint mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. + MountHandler string + // MountHandlerDeclaration is the generated Go function name used to mount this endpoint. MountHandlerDeclaration *codegen.NameDeclaration - // HandlerInitDeclaration is the package name used by this endpoint's handler constructor. + // ServerHandlerWrappers lists functions that surround the handler before + // this endpoint's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration + // HandlerInit is the handler constructor name kept for existing plugins. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning so name collisions are handled. + HandlerInit string + // HandlerInitDeclaration is the generated Go function name used to create + // this endpoint's handler. HandlerInitDeclaration *codegen.NameDeclaration - // RequestDecoderDeclaration is the package name used by this endpoint's request decoder. + // RequestDecoder is the request decoder name kept for existing plugins. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning so name collisions are handled. + RequestDecoder string + // RequestDecoderDeclaration is the generated Go function name used to + // decode this endpoint's request. RequestDecoderDeclaration *codegen.NameDeclaration - // ResponseEncoderDeclaration is the package name used by this endpoint's response encoder. + // ResponseEncoder is the response encoder name kept for existing plugins. + // + // Deprecated: Use ResponseEncoderDeclaration.Name() after planning so name collisions are handled. + ResponseEncoder string + // ResponseEncoderDeclaration is the generated Go function name used to + // encode this endpoint's response. ResponseEncoderDeclaration *codegen.NameDeclaration - // ErrorEncoderDeclaration is the package name used by this endpoint's error encoder. + // ErrorEncoder is the error encoder name kept for existing plugins. + // + // Deprecated: Use ErrorEncoderDeclaration.Name() after planning so name collisions are handled. + ErrorEncoder string + // ErrorEncoderDeclaration is the generated Go function name used to encode + // this endpoint's errors. ErrorEncoderDeclaration *codegen.NameDeclaration // DiscardStreamDeclaration names the no-output stream used by a mixed-result request. DiscardStreamDeclaration *codegen.NameDeclaration @@ -189,12 +252,16 @@ type ( SSE *SSEData // Redirect defines a redirect for the endpoint. Redirect *RedirectData - // HasMixedResults indicates if the method has both Result and StreamingResult - // defined with different types, enabling content negotiation. + // HasMixedResults indicates that HTTP clients may request one normal result + // or a stream of results. HasMixedResults bool // client + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. + ClientStruct string // ClientStructDeclaration supplies the client type name used by endpoint methods. ClientStructDeclaration *codegen.NameDeclaration // EndpointInit is the name of the constructor function for the @@ -202,9 +269,19 @@ type ( EndpointInit string // RequestInit is the request builder function. RequestInit *InitData - // RequestEncoderDeclaration is the package name used by this endpoint's request encoder. + // RequestEncoder is the request encoder name kept for existing plugins. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning so name collisions are handled. + RequestEncoder string + // RequestEncoderDeclaration is the generated Go function name used to + // encode this endpoint's request. RequestEncoderDeclaration *codegen.NameDeclaration - // ResponseDecoderDeclaration is the package name used by this endpoint's response decoder. + // ResponseDecoder is the response decoder name kept for existing plugins. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning so name collisions are handled. + ResponseDecoder string + // ResponseDecoderDeclaration is the generated Go function name used to + // decode this endpoint's response. ResponseDecoderDeclaration *codegen.NameDeclaration // MultipartRequestEncoder indicates the request encoder for // multipart content type. @@ -212,16 +289,28 @@ type ( // ClientWebSocket holds the data to render the client struct which // implements the client stream interface. ClientWebSocket *WebSocketData - // BuildStreamPayloadDeclaration is the package name used by the streamed request helper. + // BuildStreamPayload is the streamed request helper name kept for existing plugins. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning so name collisions are handled. + BuildStreamPayload string + // BuildStreamPayloadDeclaration is the generated Go function name used to + // build streamed requests. BuildStreamPayloadDeclaration *codegen.NameDeclaration - // CLIPayloadDeclaration is the package name used by the command-line payload helper. + // CLIPayloadDeclaration is the generated Go function name used to build command-line payloads. CLIPayloadDeclaration *codegen.NameDeclaration } // FileServerData lists the data needed to generate file servers. FileServerData struct { - // MountHandlerDeclaration is the package name used by this file server's mount function. + // MountHandler is the file server mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. + MountHandler string + // MountHandlerDeclaration is the generated Go function name used to mount this file server. MountHandlerDeclaration *codegen.NameDeclaration + // ServerHandlerWrappers lists functions that surround the handler before + // this file server's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration // RequestPaths is the set of HTTP paths to the server. RequestPaths []string // Root is the root server file path. @@ -256,6 +345,8 @@ type ( Name string // Ref is the fully qualified reference to the payload type. Ref string + // CLIPlan describes how command-line text becomes the complete payload. + CLIPlan *cli.FlagPlan // Request contains the data for the corresponding HTTP request. Request *RequestData // DecoderReturnValue is a reference to the decoder return value @@ -429,23 +520,31 @@ type ( // View is the exact design view name carried on variable-view messages. View string // ResultAttr is the Go field selected by Body("name"). It is empty when - // the response body uses the complete projected result. + // the response body uses the complete result containing the selected view's + // fields. ResultAttr string // ServerBody is the body type encoded by the server for View. ServerBody *TypeData // ClientBody is the body type decoded by the client for View. ClientBody *TypeData - // ResultInit rebuilds the projected result from ClientBody. + // ClientDataPointer reports whether the SSE data line is assigned to + // a pointer field in ClientBody. + ClientDataPointer bool + // ResultInit rebuilds the result containing the selected view's fields from + // ClientBody. ResultInit *InitData } // InitData contains the data required to render a constructor. InitData struct { - // Declaration is the generated package function name used by this constructor. + // Declaration is the generated Go function name used by this constructor. Declaration *codegen.NameDeclaration - // ClientDeclaration is the client package name for a path function also emitted on the server. + // ClientDeclaration is the generated Go function name written in the client package + // when the same path constructor is also written in the server package. ClientDeclaration *codegen.NameDeclaration - // Name is the constructor function name. + // Name is the constructor function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. Name string // Description is the function description. Description string @@ -520,6 +619,9 @@ type ( DefaultValue any // Validate contains the validation code for the attribute value if any. Validate string + // CLIPlan describes how command-line text becomes this attribute value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is an example attribute value Example any // IsAliased is true when the field uses a user-defined type. @@ -606,7 +708,13 @@ type ( TypeData struct { // Name is the type name. Name string - // VarName is the Go type name. + // Declaration is the generated Go type name. + Declaration *codegen.NameDeclaration + // VarName is the Go type spelling kept for existing plugins. When + // Declaration is nonnil, it matches Declaration.Name(). Otherwise it is a + // Go expression such as []string that does not declare a named type. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. VarName string // Description is the type human description. Description string @@ -619,25 +727,53 @@ type ( Ref string // ValidateDef contains the validation code. ValidateDef string - // ValidateRef contains the call to the validation code. + // NestedValidateDef contains validation code whose error paths begin with + // the path passed by another generated validator. + NestedValidateDef string + // ValidateRef contains inline validation code when no named validator is called. ValidateRef string - // ValidatorName is the package-level function that runs ValidateDef. + // ValidationTarget is the value passed to ValidatorDeclaration. It is empty + // when this body does not need a named validator call. + ValidationTarget string + // ValidatorDeclaration is the generated Go function name that runs ValidateDef. + ValidatorDeclaration *codegen.NameDeclaration + // ValidatorName is the validator name kept for existing plugins. + // + // Deprecated: Use ValidatorDeclaration.Name() after planning so name collisions are handled. ValidatorName string + // NestedValidatorDeclaration is the private generated Go function name used + // when this type appears inside another HTTP body value. + NestedValidatorDeclaration *codegen.NameDeclaration + // NestedValidatorName is the nested validator name kept for existing plugins. + // + // Deprecated: Use NestedValidatorDeclaration.Name() after planning so name collisions are handled. + NestedValidatorName string // Example is an example value for the type. Example any // View is the view used to render the (result) type if any. View string - // Declaration identifies the canonical declaration and validator owned - // by the generated output package. + // declaration points to the one request or response type record that the HTTP + // plan uses for this generated type. declaration *wireTypeRecord + // attribute is the copied HTTP type whose generated names produced Def and + // Ref. Example generators use it to qualify nested request body types. + attribute *expr.AttributeExpr } // MultipartData contains the data needed to render multipart // encoder/decoder. MultipartData struct { - // FuncDeclaration is the package name used by the multipart function type or root helper. + // FuncName is the multipart function type or helper name kept for existing plugins. + // + // Deprecated: Use FuncDeclaration.Name() after planning so name collisions are handled. + FuncName string + // FuncDeclaration is the generated Go name used by the multipart function type or helper. FuncDeclaration *codegen.NameDeclaration - // InitDeclaration is the package name used by the multipart constructor. + // InitName is the multipart constructor name kept for existing plugins. + // + // Deprecated: Use InitDeclaration.Name() after planning so name collisions are handled. + InitName string + // InitDeclaration is the generated Go function name used by the multipart constructor. InitDeclaration *codegen.NameDeclaration // VarName is the name of the variable referring to the function. VarName string @@ -656,17 +792,23 @@ type ( // report messages. httpElementKind string - // shapedBodies caches the detached body attributes computed from the - // design expressions: request and response bodies are shaped with - // makeHTTPType while streaming bodies are plain copies. Caching - // guarantees the shaping runs once per expression and that all the - // consumers share the same attribute instances, which keeps the - // example generator call sequence stable. + // shapedBodies stores each HTTP body after it is copied from the design. + // Requests, responses, and streamed results use their HTTP field names; + // streamed requests keep their authored fields. Reusing each copy gives + // every generated file the same body and the same example values. shapedBodies struct { - requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr - errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streamResults map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr + errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + } + + // releasedWireTypePair stops recursive response types after their current + // and released names have been paired once. + releasedWireTypePair struct { + current expr.UserType + released expr.UserType } ) @@ -732,7 +874,8 @@ func (sds *ServicesData) label() string { func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { svc := sds.ServicesData.Get(httpSvc.ServiceExpr.Name) transportService := *svc - transportService.PkgName = sds.ServiceImport(svc.Name).Name + clientOutputPackage := path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client") + transportService.PkgName = sds.ServiceImport(clientOutputPackage, svc.Name).Name svc = &transportService scope := codegen.NewNameScope() scope.Unique("c") // 'c' is reserved as the client's receiver name. @@ -751,15 +894,36 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { if symbols == nil { panic(fmt.Sprintf("HTTP service %q has no package names", httpSvc.Name())) } + clientPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "c" + serverPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "svr" + if sds.jsonrpc { + serverPkgName = strings.ToLower(codegen.Goify(svc.Name, false)) + "jssvr" + } + for _, server := range sds.Root.API.Servers { + if !slices.Contains(server.Services, svc.Name) { + continue + } + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliOutputPackage := path.Join(sds.GenPkg(), sds.dir(), "cli", serverName) + exampleOutputPackage := path.Join(path.Dir(sds.GenPkg()), "cmd", serverName) + clientPkgName = sds.PackageImport(cliOutputPackage, clientOutputPackage).Name + serverPkgName = sds.PackageImport(exampleOutputPackage, path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name + break + } sd := &ServiceData{ Service: svc, - ClientPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client")).Name, - ServerPkgName: sds.PackageImport(path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name, + ClientPkgName: clientPkgName, + ServerPkgName: serverPkgName, + ServerStruct: symbols.serverStruct.Name(), ServerStructDeclaration: symbols.serverStruct, + MountPointStruct: symbols.mountPoint.Name(), MountPointStructDeclaration: symbols.mountPoint, + ServerInit: symbols.serverInit.Name(), ServerInitDeclaration: symbols.serverInit, + MountServer: symbols.mountServer.Name(), MountServerDeclaration: symbols.mountServer, ServerService: "Service", + ClientStruct: symbols.clientStruct.Name(), ClientStructDeclaration: symbols.clientStruct, ClientInitDeclaration: symbols.clientInit, ServerConnConfigurerDeclaration: symbols.serverConfigurer, @@ -771,6 +935,8 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Scope: scope, serverWireTypes: planned.server, clientWireTypes: planned.client, + clientBodyConstructors: planned.clientBodyConstructors, + transforms: planned.transforms, bodies: planned.bodies, } @@ -799,6 +965,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } data := &FileServerData{ + MountHandler: symbols.fileServers[s].Name(), MountHandlerDeclaration: symbols.fileServers[s], RequestPaths: paths, FilePath: s.FilePath, @@ -946,13 +1113,11 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { var requestInit *InitData var ( - name string args []*InitArgData payloadRef string pkg string ) { - name = fmt.Sprintf("Build%sRequest", method.VarName) svcctx := sds.serviceTypeContext(sd, "client").Enter(httpEndpoint.MethodExpr.Payload) s := codegen.NewNameScope() s.Unique("c") // 'c' is reserved as the client's receiver name. @@ -993,8 +1158,9 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } clientArgs := []*InitArgData{{Ref: "v", AttributeData: &AttributeData{Name: "payload", VarName: "v", TypeRef: "any"}}} requestInit = &InitData{ - Name: name, - Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", name, svc.Name, method.Name), + Declaration: endpointSymbols.requestBuilder, + Name: endpointSymbols.requestBuilder.Name(), + Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", endpointSymbols.requestBuilder.Name(), svc.Name, method.Name), ClientCode: buf.String(), ClientArgs: clientArgs, } @@ -1013,20 +1179,36 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { QuerySchemes: qsch, BasicScheme: basch, Routes: routes, + MountHandler: endpointSymbols.mountHandler.Name(), MountHandlerDeclaration: endpointSymbols.mountHandler, + HandlerInit: endpointSymbols.handlerInit.Name(), HandlerInitDeclaration: endpointSymbols.handlerInit, RequestDecoderDeclaration: endpointSymbols.requestDecoder, ResponseEncoderDeclaration: endpointSymbols.responseEncoder, ErrorEncoderDeclaration: endpointSymbols.errorEncoder, DiscardStreamDeclaration: endpointSymbols.discardStream, + ClientStruct: symbols.clientStruct.Name(), ClientStructDeclaration: symbols.clientStruct, EndpointInit: method.VarName, RequestInit: requestInit, HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), RequestEncoderDeclaration: endpointSymbols.requestEncoder, + ResponseDecoder: endpointSymbols.responseDecoder.Name(), ResponseDecoderDeclaration: endpointSymbols.responseDecoder, Requirements: reqs, } + if declaration := endpointSymbols.requestDecoder; declaration != nil { + ed.RequestDecoder = declaration.Name() + } + if declaration := endpointSymbols.responseEncoder; declaration != nil { + ed.ResponseEncoder = declaration.Name() + } + if declaration := endpointSymbols.errorEncoder; declaration != nil { + ed.ErrorEncoder = declaration.Name() + } + if declaration := endpointSymbols.requestEncoder; declaration != nil { + ed.RequestEncoder = declaration.Name() + } if httpEndpoint.MethodExpr.IsStreaming() { sds.initWebSocketData(ed, httpEndpoint, sd) sds.initSSEData(ed, httpEndpoint, sd) @@ -1039,6 +1221,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { ed.ClientWebSocket.VarName = endpointSymbols.clientStream.Name() } if ed.SSE != nil { + ed.SSE.StructName = endpointSymbols.serverStream.Name() ed.SSE.StructDeclaration = endpointSymbols.serverStream ed.SSE.ClientInterfaceDeclaration = endpointSymbols.sseClientInterface ed.SSE.ClientStructDeclaration = endpointSymbols.sseClientStruct @@ -1048,7 +1231,9 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { if httpEndpoint.MultipartRequest { ed.MultipartRequestDecoder = &MultipartData{ + FuncName: endpointSymbols.serverMultipart.functionType.Name(), FuncDeclaration: endpointSymbols.serverMultipart.functionType, + InitName: endpointSymbols.serverMultipart.constructor.Name(), InitDeclaration: endpointSymbols.serverMultipart.constructor, VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), ServiceName: svc.Name, @@ -1056,7 +1241,9 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Payload: ed.Payload, } ed.MultipartRequestEncoder = &MultipartData{ + FuncName: endpointSymbols.clientMultipart.functionType.Name(), FuncDeclaration: endpointSymbols.clientMultipart.functionType, + InitName: endpointSymbols.clientMultipart.constructor.Name(), InitDeclaration: endpointSymbols.clientMultipart.constructor, VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), ServiceName: svc.Name, @@ -1066,6 +1253,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } if httpEndpoint.SkipRequestBodyEncodeDecode { + ed.BuildStreamPayload = endpointSymbols.buildStreamPayload.Name() ed.BuildStreamPayloadDeclaration = endpointSymbols.buildStreamPayload } ed.CLIPayloadDeclaration = endpointSymbols.cliPayload @@ -1086,7 +1274,6 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { if a.MethodExpr.StreamingPayload.Type != expr.Empty { sds.buildRequestAttributeTypes(sd.bodies.streaming(a), sd) } - } return sd @@ -1124,42 +1311,66 @@ func (sds *ServicesData) buildRequestAttributeTypes(body *expr.AttributeExpr, da } // collectPlannedWireTypes records every request and response type written by -// the generated client and server packages. NewPlans calls it before Goa -// assigns package names, and Link later uses these same copied values. -func collectPlannedWireTypes(httpService *expr.HTTPServiceExpr, planned *plannedWireTypes, servicePlan *service.Plan) { +// the generated client and server packages. NewPlans calls it before +// Generation.Freeze chooses Go names, and Link later uses these same copied +// values. +func collectPlannedWireTypes(api string, httpService *expr.HTTPServiceExpr, planned *plannedWireTypes, servicePlan *service.Plan) { bodies, server, client := &planned.bodies, planned.server, planned.client for _, endpoint := range httpService.HTTPEndpoints { request := expr.DupAtt(bodies.request(endpoint)) addMarshalTags(request) - server.collect(request, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}, "") - clientRequest := client.collect(request, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + serverRequestPolicy := jsonBodyPolicy(true, true, true, "") + clientRequestPolicy := jsonBodyPolicy(true, false, false, "") + server.collect(request, wireRequestBody, serverRequestPolicy, "", api) + server.addValidationRoot(request, serverRequestPolicy) + clientRequest := client.collect(request, wireRequestBody, clientRequestPolicy, "", api) + if userType, named := request.Type.(expr.UserType); named && userType.Attribute().Validation != nil { + client.addValidationRoot(request, clientRequestPolicy) + } if clientRequest != nil && needInit(request.Type) { clientRequest.needsConstructor = true + } else if needInit(request.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(request, clientRequestPolicy) } - server.collectChildren(request, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}) - client.collectChildren(request, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}) + server.collectChildren(request, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(request, jsonBodyPolicy(true, false, true, ""), api) if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { streaming := expr.DupAtt(bodies.streaming(endpoint)) addMarshalTags(streaming) - serverStream := server.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, pointer: true, validate: true}, "") + serverStreamPolicy := jsonBodyPolicy(true, true, true, "") + clientStreamPolicy := jsonBodyPolicy(true, false, false, "") + serverStream := server.collect(streaming, wireStreamPayload, serverStreamPolicy, "", api) + server.addValidationRoot(streaming, serverStreamPolicy) if endpoint.UsesWebSocket() && needInit(endpoint.MethodExpr.StreamingPayload.Type) && serverStream != nil { serverStream.needsConstructor = true planned.streamPayloads[endpoint] = serverStream } - clientStream := client.collect(streaming, wireStreamPayload, wireTypePolicy{request: true, useDefault: true, validate: true}, "") + clientStream := client.collect(streaming, wireStreamPayload, clientStreamPolicy, "", api) + if userType, named := streaming.Type.(expr.UserType); !named || userType.Attribute().Validation != nil { + client.addValidationRoot(streaming, clientStreamPolicy) + } if clientStream != nil && needInit(streaming.Type) { clientStream.needsConstructor = true + } else if needInit(streaming.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireStreamPayload} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(streaming, clientStreamPolicy) } - server.collectChildren(streaming, wireAttribute, wireTypePolicy{request: true, pointer: true, validate: true}) - client.collectChildren(streaming, wireAttribute, wireTypePolicy{request: true, useDefault: true, validate: true}) + server.collectChildren(streaming, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(streaming, jsonBodyPolicy(true, false, true, ""), api) + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body := bodies.streamingResult(endpoint) + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") } resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) for _, response := range endpoint.Responses { body := bodies.response(response) if !viewed { - collectResponseWireType(body, endpoint, server, true, nil) - collectResponseWireType(body, endpoint, client, false, nil) + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") continue } origin := "" @@ -1169,55 +1380,75 @@ func collectPlannedWireTypes(httpService *expr.HTTPServiceExpr, planned *planned emptyView := "" switch { case origin != "": - collectResponseWireType(body, endpoint, server, true, &emptyView) + collectResponseWireType(api, body, body, endpoint, server, true, &emptyView, "") case endpoint.MethodExpr.Result.Meta != nil: if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { - collectResponseWireType(body, endpoint, server, true, &view) + collectResponseWireType(api, body, body, endpoint, server, true, &view, "") } else { for _, view := range resultType.Views { - collectResponseWireType(body, endpoint, server, true, &view.Name) + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") } } default: for _, view := range resultType.Views { - collectResponseWireType(body, endpoint, server, true, &view.Name) + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") } } clientView := clientResponseViewNameExpr(endpoint, resultType) if origin != "" { emptyView := "" - collectResponseWireType(body, endpoint, client, false, &emptyView) + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") continue } if clientView == "" && !endpoint.UsesSSE() && !endpoint.IsJSONRPC() { emptyView := "" - collectResponseWireType(body, endpoint, client, false, &emptyView) + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") continue } if clientView != "" { clientBody := effectiveClientResponseBodyForView(body, clientView) - collectResponseWireType(clientBody, endpoint, client, false, &clientView) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &clientView, "") continue } for _, view := range resultType.Views { clientBody := effectiveClientResponseBodyForView(body, view.Name) - collectResponseWireType(clientBody, endpoint, client, false, &view.Name) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &view.Name, "") } } for _, transportError := range endpoint.HTTPErrors { body := bodies.errorResponse(transportError) - collectResponseWireType(body, endpoint, server, true, nil) - collectResponseWireType(body, endpoint, client, false, nil) + collectResponseWireType(api, body, body, endpoint, server, true, nil, transportError.Name) + collectResponseWireType(api, body, body, endpoint, client, false, nil, transportError.Name) } - collectPlannedTransforms(endpoint, bodies, servicePlan, server, client) + collectPlannedTransforms(endpoint, planned, servicePlan) } } -// collectPlannedTransforms records each HTTP body conversion in the same order -// that Link writes it. This lets the generated package name every extra -// conversion function before Plan.Link. -func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBodies, servicePlan *service.Plan, server, client *wireTypeCatalog) { +// anonymousClientBodyConstructorName returns the preferred function name for +// a request body that uses a Go expression such as []T instead of declaring a +// package type. +func anonymousClientBodyConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + scope := codegen.NewAttributeScope(codegen.NewNameScope()) + name := scope.Name(body, "", policy.pointer, policy.useDefault) + return "New" + codegen.Goify(name, true) +} + +// collectPlannedTransforms records each request, response, error, and stream +// conversion and stores its handle with the endpoint value that will write it. +// The generated package can then name every helper before Plan.Link. +func collectPlannedTransforms( + endpoint *expr.HTTPEndpointExpr, + planned *plannedWireTypes, + servicePlan *service.Plan, +) { methodName := endpoint.MethodExpr.Name + bodies := &planned.bodies + server := planned.server + client := planned.client + servicePackage, viewsPackage, err := servicePlan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + panic(err) + } request := expr.DupAtt(bodies.request(endpoint)) addMarshalTags(request) payload := endpoint.MethodExpr.Payload @@ -1227,21 +1458,49 @@ func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBod if origin, ok := request.Meta["origin:attribute"]; ok { target = expr.AsObject(payload.Type).Attribute(origin[0]) } - client.collectTransform(target, request, "marshal", methodName+" request body") - server.collectTransform(request, target, "unmarshal", methodName+" server payload") - client.collectTransform(request, target, "marshal", methodName+" command payload") + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + if needInit(request.Type) { + requestTransforms.clientEncode = client.collectTransform(target, request, "marshal", methodName+" request body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) + } + requestTransforms.serverDecode = server.collectTransform(request, target, "unmarshal", methodName+" server payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + servicePackage: *servicePackage, + }) + requestTransforms.clientDecode = client.collectTransform(request, target, "marshal", methodName+" command payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(endpoint.Params.Type); len(*params) > 0 { - server.collectTransform((*params)[0].Attribute, payload, "unmarshal", methodName+" server parameters") - client.collectTransform((*params)[0].Attribute, payload, "marshal", methodName+" command parameters") + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + requestTransforms.serverDecode = server.collectTransform((*params)[0].Attribute, payload, "unmarshal", methodName+" server parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, pointer: true}, + servicePackage: *servicePackage, + }) + requestTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, payload, "marshal", methodName+" command parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, useDefault: true}, + servicePackage: *servicePackage, + }) } } } result := endpoint.MethodExpr.Result resultType, viewed := result.Type.(*expr.ResultTypeExpr) + resultPackage := *servicePackage if viewed { - var err error + if viewsPackage == nil { + panic(fmt.Sprintf("viewed method %q has no views package", methodName)) + } + resultPackage = *viewsPackage result, err = servicePlan.ProjectedResult(endpoint.MethodExpr) if err != nil { panic(err) @@ -1278,29 +1537,39 @@ func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBod } } for _, view := range serverViews { - prepared, _ := prepareResponseWireBody(body, view) + prepared, viewName := prepareResponseWireBody(body, view) if prepared.Type != expr.Empty && resultAttribute.Type != expr.Empty && needInit(prepared.Type) { - server.collectTransform(resultAttribute, prepared, "marshal", transformResponseOwner(methodName, response, view, "server")) + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.serverEncode = server.collectTransform(resultAttribute, prepared, "marshal", transformResponseOwner(methodName, response, view, "server"), wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, viewName), + servicePointer: view != nil, + servicePackage: resultPackage, + }) } } if !needInit(result.Type) { continue } - var clientViews []*string + clientViewCount := 1 + if viewed { + clientViewCount += len(resultType.Views) + } + clientViews := make([]*string, 0, clientViewCount) if !viewed { - clientViews = []*string{nil} + clientViews = append(clientViews, nil) } else { selected := clientResponseViewNameExpr(endpoint, resultType) switch { case origin != "": empty := "" - clientViews = []*string{&empty} + clientViews = append(clientViews, &empty) case selected != "": - clientViews = []*string{&selected} + clientViews = append(clientViews, &selected) case !endpoint.UsesSSE() && !endpoint.IsJSONRPC(): empty := "" - clientViews = []*string{&empty} + clientViews = append(clientViews, &empty) default: for index := range resultType.Views { clientViews = append(clientViews, &resultType.Views[index].Name) @@ -1312,14 +1581,26 @@ func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBod if view != nil && *view != "" { clientBody = effectiveClientResponseBodyForView(body, *view) } - prepared, _ := prepareResponseWireBody(clientBody, view) + prepared, viewName := prepareResponseWireBody(clientBody, view) if prepared.Type != expr.Empty { - client.collectTransform(prepared, resultAttribute, "unmarshal", transformResponseOwner(methodName, response, view, "client")) + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.clientDecode = client.collectTransform(prepared, resultAttribute, "unmarshal", transformResponseOwner(methodName, response, view, "client"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, viewName), + servicePointer: viewed, + servicePackage: resultPackage, + }) } } if body.Type == expr.Empty && (expr.IsArray(result.Type) || expr.IsMap(result.Type)) { if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { - client.collectTransform((*params)[0].Attribute, result, "unmarshal", transformResponseOwner(methodName, response, nil, "client parameters")) + responseTransforms := planned.transforms.response(endpoint, response, "") + responseTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, result, "unmarshal", transformResponseOwner(methodName, response, nil, "client parameters"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + servicePointer: viewed, + servicePackage: resultPackage, + }) } } } @@ -1331,11 +1612,27 @@ func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBod target = expr.AsObject(target.Type).Attribute(origin[0]) } if body.Type != expr.Empty && needInit(transportError.Type) { - server.collectTransform(target, body, "marshal", methodName+" server error "+transportError.Name) - client.collectTransform(body, target, "unmarshal", methodName+" client error "+transportError.Name) + errorTransforms := planned.transforms.transportError(transportError) + if needInit(body.Type) { + errorTransforms.serverEncode = server.collectTransform(target, body, "marshal", methodName+" server error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + servicePackage: *servicePackage, + }) + } + errorTransforms.clientDecode = client.collectTransform(body, target, "unmarshal", methodName+" client error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, ""), + servicePackage: *servicePackage, + }) } else if body.Type == expr.Empty && (expr.IsArray(transportError.Type) || expr.IsMap(transportError.Type)) { if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { - client.collectTransform((*params)[0].Attribute, endpoint.MethodExpr.Error(transportError.Name).AttributeExpr, "unmarshal", methodName+" client error parameters "+transportError.Name) + errorTransforms := planned.transforms.transportError(transportError) + errorTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, endpoint.MethodExpr.Error(transportError.Name).AttributeExpr, "unmarshal", methodName+" client error parameters "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + servicePackage: *servicePackage, + }) } } } @@ -1344,14 +1641,144 @@ func collectPlannedTransforms(endpoint *expr.HTTPEndpointExpr, bodies *shapedBod body := expr.DupAtt(bodies.streaming(endpoint)) addMarshalTags(body) if body.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) { - server.collectTransform(body, endpoint.MethodExpr.StreamingPayload, "marshal", methodName+" server stream payload") - client.collectTransform(endpoint.MethodExpr.StreamingPayload, body, "marshal", methodName+" client stream body") + requestTransforms := planned.transforms.request(endpoint, wireStreamPayload) + requestTransforms.serverDecode = server.collectTransform(body, endpoint.MethodExpr.StreamingPayload, "marshal", methodName+" server stream payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + servicePackage: *servicePackage, + }) + requestTransforms.clientEncode = client.collectTransform(endpoint.MethodExpr.StreamingPayload, body, "marshal", methodName+" client stream body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) + } + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body, _ := prepareResponseWireBody(bodies.streamingResult(endpoint), nil) + result := endpoint.MethodExpr.StreamingResult + streamTransforms := planned.transforms.streamingResult(endpoint) + serviceLayout, err := servicePlan.StreamingResultLayout(endpoint.MethodExpr) + if err != nil { + panic(err) + } + direct, err := sameMixedSSERepresentation(body, serviceLayout) + if err != nil { + panic(err) + } + if body.Type != expr.Empty && direct { + streamTransforms.clientDecodeDirect = true + } + if body.Type != expr.Empty && !streamTransforms.clientDecodeDirect { + if needInit(body.Type) { + streamTransforms.serverEncode = server.collectTransform(result, body, "marshal", methodName+" server streaming result", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + servicePackage: *servicePackage, + }) + } + streamTransforms.clientDecode = client.collectTransform(body, result, "unmarshal", methodName+" client streaming result", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, ""), + servicePackage: *servicePackage, + }) } } } -// transformResponseOwner returns the design values that distinguish helper -// functions for two responses with the same generated Go types. +// sameMixedSSERepresentation compares the retained service layout with the +// wire layout decoded by the client. Named values, unions, and structs always +// use a planned conversion; primitive values and their collections are direct +// only when every retained Go type detail matches. +func sameMixedSSERepresentation(wire *expr.AttributeExpr, serviceLayout *codegen.GoTypePlan) (bool, error) { + if !mixedSSEDirectLayout(serviceLayout) { + return false, nil + } + wireLayout, err := codegen.PlanGoType(wire, codegen.GoTypePlanOptions{ + Owner: serviceLayout.Owner(), + Policy: serviceLayout.Policy(), + }) + if err != nil { + return false, err + } + return serviceLayout.Equivalent(wireLayout), nil +} + +// mixedSSEDirectLayout reports whether a layout can be assigned without any +// generated declaration or field-by-field conversion. +func mixedSSEDirectLayout(layout *codegen.GoTypePlan) bool { + switch layout.Kind() { + case codegen.GoPrimitive: + return true + case codegen.GoArray: + return mixedSSEDirectLayout(layout.Elem()) + case codegen.GoMap: + return mixedSSEDirectLayout(layout.Key()) && mixedSSEDirectLayout(layout.Elem()) + default: + return false + } +} + +// request returns the retained conversions for one ordinary or streamed +// request body, creating the record during collection when needed. +func (p *plannedWireTransforms) request( + endpoint *expr.HTTPEndpointExpr, + role wireTypeRole, +) *plannedRequestTransforms { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + transforms := p.requests[key] + if transforms == nil { + transforms = &plannedRequestTransforms{} + p.requests[key] = transforms + } + return transforms +} + +// response returns the retained conversions for one status, tag, and view +// representation, creating the record during collection when needed. +func (p *plannedWireTransforms) response( + endpoint *expr.HTTPEndpointExpr, + response *expr.HTTPResponseExpr, + view string, +) *plannedResponseTransforms { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + transforms := p.responses[key] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.responses[key] = transforms + } + return transforms +} + +// transportError returns the retained conversions for one designed error, +// creating the record during collection when needed. +func (p *plannedWireTransforms) transportError( + transportError *expr.HTTPErrorExpr, +) *plannedResponseTransforms { + transforms := p.errors[transportError] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.errors[transportError] = transforms + } + return transforms +} + +// streamingResult returns the retained conversions for a mixed method's +// streamed result, creating the record during collection when needed. +func (p *plannedWireTransforms) streamingResult( + endpoint *expr.HTTPEndpointExpr, +) *plannedResponseTransforms { + transforms := p.streamingResults[endpoint] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.streamingResults[endpoint] = transforms + } + return transforms +} + +// transformResponseOwner returns the method, transport side, status, tag, and +// view values that distinguish helper functions for two responses with the +// same generated Go types. func transformResponseOwner(method string, response *expr.HTTPResponseExpr, view *string, side string) string { viewName := "" if view != nil { @@ -1362,25 +1789,46 @@ func transformResponseOwner(method string, response *expr.HTTPResponseExpr, view // collectResponseWireType applies the selected view and records response body // declarations using the same policy later consumed by buildResponseBodyType. -func collectResponseWireType(body *expr.AttributeExpr, endpoint *expr.HTTPEndpointExpr, catalog *wireTypeCatalog, server bool, view *string) { +func collectResponseWireType( + api string, + body *expr.AttributeExpr, + releasedBody *expr.AttributeExpr, + endpoint *expr.HTTPEndpointExpr, + catalog *wireTypeCatalog, + server bool, + view *string, + errorName string, +) { body, viewName := prepareResponseWireBody(body, view) - policy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server && view == nil, view: viewName} + releasedNames := releasedResponseWireNames(releasedBody, body, view) + policy := jsonBodyPolicy(false, server, !server && view == nil, viewName) preferred := "" if server && !expr.IsPrimitive(body.Type) && needInit(body.Type) { if _, userType := body.Type.(expr.UserType); !userType { preferred = codegen.Goify(endpoint.Name(), true) + "ResponseBody" } } - record := catalog.collect(body, wireResponseBody, policy, preferred) + record := catalog.collectWithReleasedNames(body, wireResponseBody, policy, preferred, releasedNames, api) + if record != nil && errorName != "" { + record.addErrorUse(wireErrorUse{ + service: endpoint.Service.Name(), + method: endpoint.Name(), + name: errorName, + }) + } + if policy.validate { + catalog.addValidationRoot(body, policy) + } if server && record != nil && needInit(body.Type) { record.needsConstructor = true } - attributePolicy := wireTypePolicy{pointer: !server, useDefault: server, validate: !server} - catalog.collectChildren(body, wireAttribute, attributePolicy) + attributePolicy := jsonBodyPolicy(false, server, !server, "") + catalog.collectChildrenWithReleasedNames(body, attributePolicy, releasedNames) } -// prepareResponseWireBody returns the detached, projected, and tagged shape -// consumed by collection, declarations, and client response transforms. +// prepareResponseWireBody copies the response body, selects the requested view +// fields, and adds JSON tags. Collection, declaration generation, and client +// response conversion all use the returned shape. func prepareResponseWireBody(body *expr.AttributeExpr, view *string) (*expr.AttributeExpr, string) { body = expr.DupAtt(body) viewName := "" @@ -1398,6 +1846,112 @@ func prepareResponseWireBody(body *expr.AttributeExpr, view *string) (*expr.Attr return body, viewName } +// releasedResponseWireNames returns the response type names produced when Goa +// added transport suffixes before selecting a result view. +func releasedResponseWireNames(original, prepared *expr.AttributeExpr, view *string) map[expr.UserType]string { + released := expr.DupAtt(original) + suffix := releasedWireTypeSuffix(released, wireResponseBody) + if userType, ok := released.Type.(expr.UserType); ok { + appendReleasedWireSuffix(userType.Attribute().Type, suffix, make(map[expr.UserType]struct{})) + } else { + appendReleasedWireSuffix(released.Type, suffix, make(map[expr.UserType]struct{})) + } + released, _ = prepareResponseWireBody(released, view) + names := make(map[expr.UserType]string) + collectReleasedWireNames(prepared.Type, released.Type, names, make(map[releasedWireTypePair]struct{})) + if collection, ok := prepared.Type.(*expr.ResultTypeExpr); ok { + if array := expr.AsArray(collection.Attribute().Type); array != nil { + if element, ok := array.ElemType.Type.(expr.UserType); ok { + names[collection] = names[element] + "Collection" + } + } + } + return names +} + +// appendReleasedWireSuffix reproduces the order used by released Goa versions +// on a private copy of the response type. +func appendReleasedWireSuffix(dataType expr.DataType, suffix string, seen map[expr.UserType]struct{}) { + switch actual := dataType.(type) { + case expr.UserType: + if _, ok := seen[actual]; ok { + return + } + seen[actual] = struct{}{} + actual.Rename(actual.Name() + suffix) + appendReleasedWireSuffix(actual.Attribute().Type, suffix, seen) + case *expr.Object: + for _, named := range *actual { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) + } + case *expr.Array: + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Map: + appendReleasedWireSuffix(actual.KeyType.Type, suffix, seen) + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Union: + for _, named := range actual.Values { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) + } + } +} + +// collectReleasedWireNames pairs the current response types with the names +// from the earlier suffix-before-view order. +func collectReleasedWireNames(current, released expr.DataType, names map[expr.UserType]string, seen map[releasedWireTypePair]struct{}) { + currentUser, currentNamed := current.(expr.UserType) + releasedUser, releasedNamed := released.(expr.UserType) + if currentNamed || releasedNamed { + if !currentNamed || !releasedNamed { + panic("response view changed whether a generated type is named") + } + pair := releasedWireTypePair{current: currentUser, released: releasedUser} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + names[currentUser] = codegen.Goify(releasedUser.Name(), true) + collectReleasedWireNames(currentUser.Attribute().Type, releasedUser.Attribute().Type, names, seen) + return + } + + switch currentType := current.(type) { + case *expr.Object: + releasedType, ok := released.(*expr.Object) + if !ok { + panic("response view changed the generated object shape") + } + for _, named := range *currentType { + other := releasedType.Attribute(named.Name) + if other == nil { + panic("response view changed a generated field name") + } + collectReleasedWireNames(named.Attribute.Type, other.Type, names, seen) + } + case *expr.Array: + releasedType, ok := released.(*expr.Array) + if !ok { + panic("response view changed the generated array shape") + } + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Map: + releasedType, ok := released.(*expr.Map) + if !ok { + panic("response view changed the generated map shape") + } + collectReleasedWireNames(currentType.KeyType.Type, releasedType.KeyType.Type, names, seen) + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Union: + releasedType, ok := released.(*expr.Union) + if !ok || len(currentType.Values) != len(releasedType.Values) { + panic("response view changed the generated union shape") + } + for index, named := range currentType.Values { + collectReleasedWireNames(named.Attribute.Type, releasedType.Values[index].Attribute.Type, names, seen) + } + } +} + // makeHTTPType traverses the attribute recursively and performs these actions: // // * removes aliased user type by replacing them with the underlying type. @@ -1471,11 +2025,10 @@ func (b *shapedBodies) request(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { return att } -// streaming returns the streaming request body for the given endpoint. The -// returned attribute is a detached copy of the design body so that marshal -// tag meta may be added to it without affecting the design expression tree. -// Streaming bodies are not shaped with makeHTTPType: aliased user types have -// never been flattened in streaming bodies. +// streaming returns a copy of the endpoint's streaming request body. Generated +// JSON field information may be added to the copy without changing the authored +// design. Streaming requests keep named user types instead of replacing them +// with their fields. func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { if att, ok := b.streams[e]; ok { return att @@ -1489,6 +2042,21 @@ func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { return att } +// streamingResult returns the JSON body written for each result in a mixed SSE +// stream. It copies the service result before applying HTTP field names so +// generation never changes the authored service type. +func (b *shapedBodies) streamingResult(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { + if att, ok := b.streamResults[e]; ok { + return att + } + if b.streamResults == nil { + b.streamResults = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) + } + att := makeHTTPType(e.MethodExpr.StreamingResult) + b.streamResults[e] = att + return att +} + // response returns the shaped HTTP body for the given success response, see // request. func (b *shapedBodies) response(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { @@ -1527,8 +2095,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD if httpBody.Type != expr.Empty { addMarshalTags(serverHTTPBody) addMarshalTags(clientHTTPBody) - serverPolicy := wireTypePolicy{request: true, pointer: true, validate: true} - clientPolicy := wireTypePolicy{request: true, useDefault: true, validate: true} + serverPolicy := jsonBodyPolicy(true, true, true, "") + clientPolicy := jsonBodyPolicy(true, false, true, "") sd.serverWireTypes.applyNames(serverHTTPBody, wireRequestBody, serverPolicy) sd.clientWireTypes.applyNames(clientHTTPBody, wireRequestBody, clientPolicy) } @@ -1537,8 +2105,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD svc = sd.Service body = httpBody.Type ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) - httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + httpsvrctx = jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) payloadOwner = expr.MethodPayloadExampleIdentity(e.MethodExpr) @@ -1549,8 +2117,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD ) { var ( - serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd, payloadOwner, bodyOwner) - clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd, payloadOwner, bodyOwner) + serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, true, sd, payloadOwner, bodyOwner) + clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, false, sd, payloadOwner, bodyOwner) paramsData = sds.extractPathParams(e.PathParams(), payload, sd, payloadOwner) queryData = sds.extractQueryParams(e.QueryParams(), payload, sd, payloadOwner) headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope, payloadOwner) @@ -1574,21 +2142,30 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD fieldName = codegen.Goify(name, true) } varn := codegen.Goify(name, false) + typeName := sd.Scope.GoTypeName(pAtt) + typeRef := sd.Scope.GoTypeRef(pAtt) + validate := codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name) mapQueryParam = &ParamData{ MapQueryParams: e.MapQueryParams, Map: expr.AsMap(payload.Type) != nil, Element: &Element{ HTTPName: name, AttributeData: &AttributeData{ - Name: name, - VarName: varn, - FieldName: fieldName, - FieldType: pAtt.Type, - Required: required, - Type: pAtt.Type, - TypeName: sd.Scope.GoTypeName(pAtt), - TypeRef: sd.Scope.GoTypeRef(pAtt), - Validate: codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name), + Name: name, + VarName: varn, + FieldName: fieldName, + FieldType: pAtt.Type, + Required: required, + Type: pAtt.Type, + TypeName: typeName, + TypeRef: typeRef, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + pAtt, + typeName, + typeRef, + cliValidationRenderer(validate != "", pAtt, httpsvrctx, name), + ), DefaultValue: pAtt.DefaultValue, Example: sds.FieldExample(pAtt, e.MethodExpr.Payload, name, payloadOwner), }, @@ -1683,14 +2260,14 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD clientTypeName string clientTypeRef string ) - if record := sd.serverWireTypes.lookupUser(serverHTTPBody, wireRequestBody, wireTypePolicy{request: true, pointer: true, validate: true}); record != nil { + if record := sd.serverWireTypes.lookupUser(serverHTTPBody, wireRequestBody, jsonBodyPolicy(true, true, true, "")); record != nil { serverTypeName = record.name serverTypeRef = record.ref } else { serverTypeName = httpsvrctx.Scope.Name(serverHTTPBody, "", httpsvrctx.Pointer, httpsvrctx.UseDefault) serverTypeRef = httpsvrctx.Scope.Ref(serverHTTPBody, "") } - if record := sd.clientWireTypes.lookupUser(clientHTTPBody, wireRequestBody, wireTypePolicy{request: true, useDefault: true, validate: true}); record != nil { + if record := sd.clientWireTypes.lookupUser(clientHTTPBody, wireRequestBody, jsonBodyPolicy(true, false, true, "")); record != nil { clientTypeName = record.name clientTypeRef = record.ref } else { @@ -1707,6 +2284,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD cvcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") } } + cliValidation := cliValidationRenderer(cvcode != "", clientHTTPBody, httpclictx, "body") serverArgs = append(serverArgs, &InitArgData{ Ref: sd.serverWireTypes.scope.GoVar("body", serverHTTPBody.Type), AttributeData: &AttributeData{ @@ -1731,6 +2309,12 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD Required: true, Example: sds.Example(httpBody, bodyOwner), Validate: cvcode, + CLIPlan: cli.NewFlagPlan( + clientHTTPBody, + clientTypeName, + clientTypeName, + cliValidation, + ), }, }) } @@ -1772,6 +2356,8 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD uatt := e.MethodExpr.Payload.Find(sc.UsernameAttr) uctx := svcclictx.Enter(uatt) uref := uctx.Scope.Ref(uatt, uctx.Pkg(uatt)) + uvalueRef := uref + uvalidate := codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr) if sc.UsernamePointer { uref = "*" + uref } @@ -1789,13 +2375,21 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD TypeRef: uref, Type: uatt.Type, Pointer: sc.UsernamePointer, - Validate: codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr), - Example: sds.FieldExample(uatt, e.MethodExpr.Payload, sc.UsernameAttr, payloadOwner), + Validate: uvalidate, + CLIPlan: cli.NewFlagPlan( + uatt, + uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), + uvalueRef, + cliValidationRenderer(uvalidate != "", uatt, uctx, sc.UsernameAttr), + ), + Example: sds.FieldExample(uatt, e.MethodExpr.Payload, sc.UsernameAttr, payloadOwner), }, } patt := e.MethodExpr.Payload.Find(sc.PasswordAttr) pctx := svcclictx.Enter(patt) pref := pctx.Scope.Ref(patt, pctx.Pkg(patt)) + pvalueRef := pref + pvalidate := codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr) if sc.PasswordPointer { pref = "*" + pref } @@ -1813,8 +2407,14 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD TypeRef: pref, Type: patt.Type, Pointer: sc.PasswordPointer, - Validate: codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr), - Example: sds.FieldExample(patt, e.MethodExpr.Payload, sc.PasswordAttr, payloadOwner), + Validate: pvalidate, + CLIPlan: cli.NewFlagPlan( + patt, + pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), + pvalueRef, + cliValidationRenderer(pvalidate != "", patt, pctx, sc.PasswordAttr), + ), + Example: sds.FieldExample(patt, e.MethodExpr.Payload, sc.PasswordAttr, payloadOwner), }, } cliArgs = []*InitArgData{uarg, parg} @@ -1833,52 +2433,59 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD err error origin string pointer bool - - pAtt = payload ) + requestTransforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireRequestBody}] if body != expr.Empty { // If design uses Body("name") syntax then need to use payload // attribute to transform. if o, ok := httpBody.Meta["origin:attribute"]; ok { origin = o[0] - pAtt = expr.AsObject(payload.Type).Attribute(origin) - pointer = !payload.IsRequired(o[0]) && expr.IsPrimitive(pAtt.Type) + attribute := expr.AsObject(payload.Type).Attribute(origin) + pointer = !payload.IsRequired(o[0]) && expr.IsPrimitive(attribute.Type) } var ( helpers []*codegen.TransformFunctionData ) - transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) - serverCode, helpers, err = sd.serverWireTypes.renderTransform(serverHTTPBody, pAtt, "body", "v", "unmarshal", transformctx, svcsvrctx) + transformctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, serverHTTPBody, "body", "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } // The client code for building the method payload from a request // body is used by the CLI tool to build the payload given to the // client endpoint. It differs because the body type there does not // use pointers for all fields (no need to validate). - transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) - clientCode, helpers, err = sd.clientWireTypes.renderTransform(clientHTTPBody, pAtt, "body", "v", "marshal", transformctx, svcclictx) + transformctx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, clientHTTPBody, "body", "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) - serverCode, helpers, err = sd.serverWireTypes.renderTransform((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", transformctx, svcsvrctx) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) - clientCode, helpers, err = sd.clientWireTypes.renderTransform((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", "marshal", transformctx, svcclictx) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) } init = &InitData{ Declaration: declaration, @@ -1903,10 +2510,12 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD returnValue string name string ref string + cliPlan *cli.FlagPlan ) if payload.Type != expr.Empty { name = svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true) ref = svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)) + cliPlan = cli.NewFlagPlan(payload, name, ref, nil) } if init == nil { if o := expr.AsObject(e.Params.Type); o != nil && len(*o) > 0 { @@ -1922,6 +2531,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD data := &PayloadData{ Name: name, Ref: ref, + CLIPlan: cliPlan, Request: request, DecoderReturnValue: returnValue, } @@ -2066,14 +2676,16 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A if origin != "" { // Response body is explicitly set to an attribute in the method // result type. No need to do any view-based projections server side. - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd, resultOwner, bodyOwner); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: vname}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else if v, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { // Design explicitly sets the view to render the result. // We generate only one server body type which will be rendered // using the specified view. - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd, resultOwner, bodyOwner); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: v}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else { @@ -2086,31 +2698,34 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // attributes defined in the view in the response (NOTE: a required // attribute in the result type may not be present in all its views) for _, view := range md.ViewedResult.Views { - if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd, resultOwner, bodyOwner); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view.Name}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } } - if clientView != "" { + switch { + case clientView != "": clientRespBody = effectiveClientResponseBodyForView(respBody, clientView) - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd, resultOwner, bodyOwner) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd, nil, resultOwner, bodyOwner) clientBodyView = &clientView - } else if origin != "" || !e.UsesSSE() && !e.IsJSONRPC() { - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, resultOwner, bodyOwner) + case origin != "" || !e.UsesSSE() && !e.IsJSONRPC(): + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, nil, resultOwner, bodyOwner) clientBodyView = &vname - } else { + default: clientRespBody = &expr.AttributeExpr{Type: expr.Empty} } } else { - if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, resultOwner, bodyOwner); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, resultOwner, bodyOwner) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, nil, resultOwner, bodyOwner) } if clientBodyData != nil && clientRespBody.Type != expr.Empty { var viewName string clientRespBody, viewName = prepareResponseWireBody(clientRespBody, clientBodyView) - policy := wireTypePolicy{pointer: true, validate: clientBodyView == nil, view: viewName} + policy := jsonBodyPolicy(false, false, clientBodyView == nil, viewName) sd.clientWireTypes.applyNames(clientRespBody, wireResponseBody, policy) } for _, h := range headersData { @@ -2143,10 +2758,11 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A } for _, view := range views { representation := &ViewedRepresentationData{ - View: view.Name, - ResultAttr: codegen.Goify(origin, true), - ClientBody: clientBodyData, - ResultInit: init, + View: view.Name, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, } if len(serverBodyData) > 0 { representation.ServerBody = serverBodyData[0] @@ -2156,10 +2772,11 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A } else { if clientView != "" { representation := &ViewedRepresentationData{ - View: clientView, - ResultAttr: codegen.Goify(origin, true), - ClientBody: clientBodyData, - ResultInit: init, + View: clientView, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, } if len(serverBodyData) > 0 { representation.ServerBody = viewedServerBody(serverBodyData, clientView) @@ -2173,10 +2790,10 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A viewName := view.Name body := effectiveClientResponseBodyForView(respBody, viewName) clientBody := sds.buildResponseBodyType( - respBody, result, e, false, &viewName, sd, resultOwner, bodyOwner, + respBody, result, e, false, &viewName, sd, nil, resultOwner, bodyOwner, ) if body.Type != expr.Empty { - policy := wireTypePolicy{pointer: true, view: viewName} + policy := jsonBodyPolicy(false, false, false, viewName) sd.clientWireTypes.applyNames(body, wireResponseBody, policy) } resultInit := sds.buildResponseResultInit( @@ -2184,10 +2801,11 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A headersData, cookiesData, sd, viewName, clientBody, ) representation := &ViewedRepresentationData{ - View: viewName, - ResultAttr: codegen.Goify(origin, true), - ClientBody: clientBody, - ResultInit: resultInit, + View: viewName, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBody, + ClientDataPointer: clientSSEDataPointer(e, body), + ResultInit: resultInit, } if len(serverBodyData) > 0 { representation.ServerBody = viewedServerBody(serverBodyData, viewName) @@ -2240,10 +2858,9 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // body, headers, and cookies into the method result. func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, resAttr, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { var ( - svc = sd.Service - md = svc.Method(e.Name()) - httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) - svcctx = sds.serviceTypeContext(sd, "client").Enter(result) + svc = sd.Service + md = svc.Method(e.Name()) + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) if md.ViewedResult != nil { svcctx = sds.viewTypeContext(sd, "client").Enter(result) @@ -2261,7 +2878,7 @@ func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp var ( code string pointer bool - clientArgs []*InitArgData + clientArgs = make([]*InitArgData, 0, len(headers)+len(cookies)+1) ) if clientBody.Type != expr.Empty { if origin != "" { @@ -2272,39 +2889,42 @@ func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp ref = "&body" pointer = false } - var validate string - if ut, ok := clientBody.Type.(expr.UserType); ok && ut.Attribute().Validation != nil { - validate = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") - } bodyTypeRef := bodyType.Ref if bodyTypeRef == "" { bodyTypeRef = bodyType.VarName } - clientArgs = []*InitArgData{{ + clientArgs = append(clientArgs, &InitArgData{ Ref: ref, AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeRef: bodyTypeRef, - Validate: validate, + Name: "body", + VarName: "body", + TypeRef: bodyTypeRef, }, - }} - transformctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) - transformctx.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, wireTypePolicy{ - pointer: transformctx.Pointer, - view: bodyType.View, }) - converted, helpers, err := sd.clientWireTypes.renderTransform(clientBody, resAttr, "body", "v", "unmarshal", transformctx, svcctx) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + bodyPolicy := wireTypePolicy{ + pointer: transformctx.Pointer, + arrayElementPointer: transformctx.ArrayElementPointer, + view: bodyType.View, + } + transformctx.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, bodyPolicy) + if bodyPolicy.view != "" { + transformctx.Scope = sd.clientWireTypes.rootResolver(sd.clientWireTypes.scope, bodyPolicy, bodyType.declaration) + } + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: bodyType.View}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, clientBody, "body", "v", transformctx, svcctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } code = converted sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { - converted, helpers, err := sd.clientWireTypes.renderTransform((*params)[0].Attribute, result, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", httpclictx, svcctx) + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, svcctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } code = converted sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) @@ -2337,7 +2957,7 @@ func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceData) []*ErrorGroupData { var ( svc = sd.Service - httpclictx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) ) data := make(map[string][]*ErrorData) @@ -2381,7 +3001,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa if isObject { ref = "&body" } - policy := wireTypePolicy{pointer: true, validate: true} + policy := jsonBodyPolicy(false, false, true, "") bodyRecord := sd.clientWireTypes.lookupUser(respBody, wireResponseBody, policy) sd.clientWireTypes.applyNames(respBody, wireResponseBody, policy) var bodyTypeRef string @@ -2408,31 +3028,32 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa err error ) if body != expr.Empty { - eAtt := errorAttribute // If design uses Body("name") syntax then need to use payload // attribute to transform. if o, ok := respBody.Meta["origin:attribute"]; ok { origin = o[0] - eAtt = expr.AsObject(v.ErrorExpr.Type).Attribute(origin) } var helpers []*codegen.TransformFunctionData - transformctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) - code, helpers, err = sd.clientWireTypes.renderTransform(respBody, eAtt, "body", "v", "unmarshal", transformctx, errctx) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, respBody, "body", "v", transformctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(v.Type) || expr.IsMap(v.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - code, helpers, err = sd.clientWireTypes.renderTransform((*params)[0].Attribute, errorAttribute, codegen.Goify((*params)[0].Name, false), "v", "unmarshal", httpclictx, errctx) + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) } init = &InitData{ @@ -2458,16 +3079,11 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa clientBodyData *TypeData ) { - if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd, errorOwner, bodyOwner); sbd != nil { + transforms := sd.transforms.errors[v] + if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd, transforms, errorOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd, errorOwner, bodyOwner) - if clientBodyData != nil { - clientBodyData.Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - clientBodyData.VarName, svc.Name, e.Name(), v.Name) - serverBodyData[0].Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - serverBodyData[0].VarName, svc.Name, e.Name(), v.Name) - } + clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd, nil, errorOwner, bodyOwner) } headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) @@ -2554,24 +3170,26 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa // svr is true if the function is generated for server side code. // // sd is the service data -func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { +func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, role wireTypeRole, svr bool, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { if body.Type == expr.Empty { return nil } body = expr.DupAtt(body) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string svc = sd.Service catalog = sd.wireTypes(svr) - policy = wireTypePolicy{request: true, pointer: svr, useDefault: !svr, validate: true} - httpctx = wireHTTPContext(catalog, catalog.scope, true, svr) + policy = jsonBodyPolicy(true, svr, true, "") + httpctx = jsonBodyContext(catalog, catalog.scope, true, svr) side = "client" ) if svr { @@ -2579,8 +3197,8 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * } svcctx := sds.serviceTypeContext(sd, side).Enter(att) addMarshalTags(body) - record := catalog.lookupUser(body, wireRequestBody, policy) - catalog.applyNames(body, wireRequestBody, policy) + record := catalog.lookupUser(body, role, policy) + catalog.applyNames(body, role, policy) name = body.Type.Name() if record != nil { name = record.name @@ -2597,16 +3215,20 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * if svr { // generate validation code for unmarshaled type (server-side). validateDef = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body", "path") + } if validateDef != "" { - validateRef = fmt.Sprintf("err = Validate%s(&body)", varname) + validationTarget = "&body" } } } else { - // Generate validation code first because inline struct validation is removed. - ctx := wireHTTPContext(catalog, catalog.scope, true, svr) - ctx.Pointer = !expr.IsPrimitive(body.Type) - ctx.UseDefault = !svr - validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") + if svr { + // Generate validation code first because inline struct validation is removed. + ctx := jsonBodyContext(catalog, catalog.scope, true, true) + ctx.Pointer = !expr.IsPrimitive(body.Type) + validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") + } if svr && expr.IsObject(body.Type) { // Body is an explicit object described in the design and in // this case the GoTypeRef is an inline struct definition. We @@ -2620,38 +3242,41 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * var init *InitData if !svr && att.Type != expr.Empty && needInit(body.Type) { var ( - name string - desc string - code string - origin string - err error - helpers []*codegen.TransformFunctionData + name string + desc string + code string + origin string + err error + helpers []*codegen.TransformFunctionData + declaration *codegen.NameDeclaration sourceVar = "p" svc = sd.Service ) { if record != nil { - name = fmt.Sprintf("New%s", record.name) + declaration = record.constructor } else { - name = fmt.Sprintf("New%s", codegen.Goify(httpctx.Scope.Name(body, "", httpctx.Pointer, httpctx.UseDefault), true)) + declaration = sd.clientBodyConstructors[clientBodyConstructorKey{endpoint: e, role: role}] } + if declaration == nil { + panic(fmt.Sprintf("client body constructor for %s.%s was not submitted", svc.Name, e.Name())) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds the HTTP request body from the payload of the %q endpoint of the %q service.", name, e.Name(), svc.Name) src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use payload attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - transformctx := wireHTTPContext(catalog, catalog.scope, true, svr) - code, helpers, err = catalog.renderTransform(srcAtt, body, src, "body", "marshal", svcctx, transformctx) + transformctx := jsonBodyContext(catalog, catalog.scope, true, svr) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: role}] + code, helpers, err = catalog.renderTransform(transforms.clientEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2667,6 +3292,7 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * }, } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ReturnTypeRef: ref, @@ -2676,15 +3302,18 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * } } data := &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: sds.Example(body, bodyOwner), + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + attribute: body, } if record == nil || data.Def == "" && data.ValidateDef == "" { return data @@ -2703,20 +3332,30 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // svr is true if the function is generated for server side code // // view is the view name to add as a suffix to the type name. -func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { +func (sds *ServicesData) buildResponseBodyType( + body, att *expr.AttributeExpr, + e *expr.HTTPEndpointExpr, + svr bool, + view *string, + sd *ServiceData, + transforms *plannedResponseTransforms, + sourceOwner, bodyOwner expr.ExampleIdentity, +) *TypeData { if body.Type == expr.Empty { return nil } body, viewName := prepareResponseWireBody(body, view) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string - mustInit bool + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string + mustInit bool svc = sd.Service side = "client" @@ -2726,7 +3365,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e } svcctx := sds.serviceTypeContext(sd, side).Enter(att) catalog := sd.wireTypes(svr) - policy := wireTypePolicy{pointer: !svr, useDefault: svr, validate: !svr && view == nil, view: viewName} + policy := jsonBodyPolicy(false, svr, !svr && view == nil, viewName) // Add each nested named field before body receives its chosen Go names. This // keeps each copied request or response field tied to its own definition. topLevel, _ := body.Type.(expr.UserType) @@ -2744,7 +3383,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e }) record := catalog.lookupUser(body, wireResponseBody, policy) catalog.applyNames(body, wireResponseBody, policy) - httpctx := wireHTTPContext(catalog, catalog.scope, false, svr) + httpctx := jsonBodyContext(catalog, catalog.scope, false, svr) name = body.Type.Name() if record != nil { name = record.name @@ -2763,13 +3402,16 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e if !svr && view == nil { // generate validation code for unmarshaled type (client-side). validateDef = codegen.ValidationCode(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body", "path") + } if validateDef != "" { target := "&body" if expr.IsArray(ut) { // result type collection target = "body" } - validateRef = fmt.Sprintf("err = Validate%s(%s)", varname, target) + validationTarget = target } } } else if !expr.IsPrimitive(body.Type) && mustInit { @@ -2802,7 +3444,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e } else { // response body is a primitive type. They are used as non-pointers when // encoding/decoding responses. - httpctx = wireHTTPContext(catalog, catalog.scope, false, true) + httpctx = jsonBodyContext(catalog, catalog.scope, false, true) if !svr { validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") } @@ -2839,20 +3481,20 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e svcctx = sds.viewTypeContext(sd, "server").Enter(att) } src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use result attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - transformctx := wireHTTPContext(catalog, catalog.scope, false, svr) + transformctx := jsonBodyContext(catalog, catalog.scope, false, svr) transformctx.Scope = catalog.resolver(catalog.scope, policy) - code, helpers, err = catalog.renderTransform(srcAtt, body, src, "body", "marshal", svcctx, transformctx) + if policy.view != "" { + transformctx.Scope = catalog.rootResolver(catalog.scope, policy, record) + } + code, helpers, err = catalog.renderTransform(transforms.serverEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -2882,16 +3524,18 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, e } } td := &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: sds.Example(body, bodyOwner), - View: viewName, + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + View: viewName, } if record == nil || td.Def == "" && td.ValidateDef == "" { return td @@ -3044,6 +3688,7 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt if kind != pathElement { pointer = a.IsPrimitivePointer(name, true) } + valueTypeRef := typeRef if pointer { typeRef = "*" + typeRef } @@ -3058,6 +3703,7 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt fptr = svcCtx.IsPrimitivePointer(name, svcAtt) } } + validationAttribute := att validate := codegen.AttributeValidationCode(att, nil, svcCtx, required, expr.IsAlias(att.Type), varn, name) isText := (kind == pathElement || kind == queryElement) && isStringMetaType(att) if isText { @@ -3069,26 +3715,33 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt v.Format = "" attNoFmt.Validation = &v } - validate = codegen.AttributeValidationCode(&attNoFmt, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) + validationAttribute = &attNoFmt + validate = codegen.AttributeValidationCode(validationAttribute, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) } add(&Element{ HTTPName: elem, Slice: slice, StringSlice: stringSlice, AttributeData: &AttributeData{ - Name: name, - Description: att.Description, - FieldName: fieldName, - FieldPointer: fptr, - FieldType: ft, - VarName: varn, - Required: required, - Type: att.Type, - TypeName: scope.GoTypeName(att), - TypeRef: typeRef, - ElemTypeRef: elemTypeRef, - Pointer: pointer, - Validate: validate, + Name: name, + Description: att.Description, + FieldName: fieldName, + FieldPointer: fptr, + FieldType: ft, + VarName: varn, + Required: required, + Type: att.Type, + TypeName: scope.GoTypeName(att), + TypeRef: typeRef, + ElemTypeRef: elemTypeRef, + Pointer: pointer, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + validationAttribute, + scope.GoTypeName(validationAttribute), + valueTypeRef, + cliValidationRenderer(validate != "", validationAttribute, svcCtx, name), + ), IsTextUnmarshaler: isText, DefaultValue: att.DefaultValue, Example: sds.FieldExample(att, svcAtt, name, owner), @@ -3107,6 +3760,19 @@ func elementInitArg(el *Element) *InitArgData { return &InitArgData{Ref: att.VarName, AttributeData: &att} } +// cliValidationRenderer returns nil when the transport plan has no checks. A +// non-nil function writes checks for the concrete value parsed from a flag. +func cliValidationRenderer(enabled bool, attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + if !enabled { + return nil + } + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, expr.IsAlias(attribute.Type), target, name) + } +} + // resultInitArg returns a result constructor argument backed by a copy of the // element attribute data. Result constructor arguments carry no description, // type name or default value: the constructor templates do not read them. @@ -3184,6 +3850,24 @@ func effectiveClientResponseBodyForView(body *expr.AttributeExpr, view string) * return body } +// clientSSEDataPointer reports whether a configured SSE data field uses the +// pointer layout required by client response validation. Complete primitive +// response bodies remain values. +func clientSSEDataPointer(endpoint *expr.HTTPEndpointExpr, body *expr.AttributeExpr) bool { + if endpoint.SSE == nil || endpoint.SSE.DataField == "" { + return false + } + object := expr.AsObject(body.Type) + if object == nil { + return false + } + attribute := object.Attribute(endpoint.SSE.DataField) + if attribute == nil { + panic(fmt.Sprintf("SSE data field %q is missing from the client response body", endpoint.SSE.DataField)) + } + return expr.IsPrimitive(attribute.Type) +} + // clientResponseViewName returns the response view used by client code // generation when the design fixes the response to a single view. An empty // string means the client must keep the unprojected transport body because the @@ -3219,22 +3903,26 @@ func buildHTTPUnionTypeData(u *expr.Union, scope codegen.Attributor, record *wir fieldName := codegen.Goify(nat.Name, true) fieldType := scope.Ref(nat.Attribute, scope.Package(nat.Attribute)) fields[i] = &service.UnionFieldData{ - Name: nat.Name, - KindConst: record.kindConsts[i], - Constructor: record.constructors[i], - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - TypeTag: nat.Name, + Name: nat.Name, + KindConst: record.kindConsts[i], + Constructor: record.constructors[i], + KindDeclaration: record.kindDecls[i], + ConstructorDeclaration: record.ctorDecls[i], + FieldName: fieldName, + FieldType: fieldType, + Nilable: codegen.IsNilable(nat.Attribute.Type), + TypeTag: nat.Name, } } return &service.UnionTypeData{ - Name: record.name, - KindName: record.kindName, - Fields: fields, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), + Name: record.name, + KindName: record.kindName, + TypeDeclaration: record.declaration, + KindDeclaration: record.kind, + Fields: fields, + TypeKey: u.GetTypeKey(), + ValueKey: u.GetValueKey(), } } @@ -3250,19 +3938,20 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve } var ( - name string - desc string - validate string - validateRef string + name string + desc string + validate string + nestedValidate string + validateRef string att = expr.DupAtt(&expr.AttributeExpr{Type: ut}) catalog = rd.wireTypes(server) - policy = wireTypePolicy{request: req, pointer: ptr, useDefault: hctxUseDefault(req, server), validate: req || !server, view: view} + policy = wireTypePolicy{request: req, pointer: ptr, useDefault: hctxUseDefault(req, server), validate: req || !server, arrayElementPointer: req == server, view: view} ) ut = att.Type.(expr.UserType) record := catalog.lookupUser(att, wireAttribute, policy) catalog.applyNames(att, wireAttribute, policy) - hctx := wireHTTPContext(catalog, catalog.scope, req, server) + hctx := jsonBodyContext(catalog, catalog.scope, req, server) name = record.name ctx := "request" if !req { @@ -3274,22 +3963,36 @@ func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, serve // requests server-side and CLI. // Alias types are validated inline in the parent type validate = codegen.ValidationCode(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidate = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body", "path") + } } + validationTarget := "" if validate != "" { - validateRef = fmt.Sprintf("err = Validate%s(v)", name) + validationTarget = "v" } return catalog.bind(record, &TypeData{ - Name: ut.Name(), - VarName: name, - Description: desc, - Def: goTypeDefForContext(ut.Attribute(), hctx), - Ref: record.ref, - ValidateDef: validate, - ValidateRef: validateRef, - Example: sds.Example(att, expr.UserTypeExampleIdentity(ut)), + Name: ut.Name(), + VarName: name, + Description: desc, + Def: goTypeDefForContext(ut.Attribute(), hctx), + Ref: record.ref, + ValidateDef: validate, + NestedValidateDef: nestedValidate, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(att, expr.UserTypeExampleIdentity(ut)), }) } +// recordLinkError keeps the first failed conversion so Plan.Link can return it +// before callers receive files built from incomplete template data. +func (sds *ServicesData) recordLinkError(err error) { + if sds.linkErr == nil { + sds.linkErr = err + } +} + // wireTypes returns the request and response types for the server or client package. func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { if server { @@ -3298,6 +4001,21 @@ func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { return sd.clientWireTypes } +// jsonBodyPolicy describes one generated JSON body. Bodies being decoded keep +// required primitive array elements as pointers until validation rejects null. +func jsonBodyPolicy(request, server, validate bool, view string) wireTypePolicy { + // A server decodes a request, and a client decodes a response. + decode := request == server + return wireTypePolicy{ + request: request, + pointer: decode, + useDefault: !decode, + validate: validate, + arrayElementPointer: decode, + view: view, + } +} + // hctxUseDefault reports whether missing HTTP values receive their design // defaults for the selected request or response side. func hctxUseDefault(request, server bool) bool { @@ -3335,6 +4053,21 @@ func wireHTTPContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request return context } +// jsonBodyContext uses pointer elements only while decoding a JSON body. This +// lets generated validation reject null before conversion to service values. +func jsonBodyContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := wireHTTPContext(catalog, scope, request, server) + decode := request == server + context.ArrayElementPointer = decode + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + arrayElementPointer: context.ArrayElementPointer, + }) + return context +} + // serviceTypeContext returns the service type names as referenced from the // generated client or server package named by side. func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { @@ -3356,36 +4089,6 @@ func (sds *ServicesData) viewTypeContext(sd *ServiceData, side string) *codegen. } } -// unmarshal initializes a data structure defined by target type from a data -// structure defined by source type. The attributes in the source data -// structure are pointers and the attributes in the target data structure that -// have default values are non-pointers. Fields in target type are initialized -// with their default values (if any). -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func unmarshal(source, target *expr.AttributeExpr, sourceVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, "v", sourceCtx, targetCtx, "unmarshal", true) -} - -// marshal initializes a data structure defined by target type from a data -// structure defined by source type. The fields in the source and target -// data structure use non-pointers for attributes with default values. -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func marshal(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "marshal", true) -} - // needConversion returns true if the type needs to be converted from a string. func needConversion(dt expr.DataType) bool { if dt == expr.Empty { @@ -3503,7 +4206,8 @@ func upgradeParams(e *EndpointData, fn string) map[string]any { } // serviceHasViewedResult reports whether the selected endpoint sections -// reference a projected result from the service views package. +// reference a result containing only a selected view's fields from the service +// views package. func serviceHasViewedResult(service *ServiceData, selected func(*EndpointData) bool) bool { for _, endpoint := range service.Endpoints { if selected != nil && !selected(endpoint) { diff --git a/http/codegen/service_data_union_nilability_test.go b/http/codegen/service_data_union_nilability_test.go index 1f6dc21769..cba322bb91 100644 --- a/http/codegen/service_data_union_nilability_test.go +++ b/http/codegen/service_data_union_nilability_test.go @@ -1,5 +1,5 @@ // This file verifies HTTP union records preserve the nilability of every -// branch when rendering their package-owned sum type. +// branch when rendering a value that holds one selected branch. package codegen import ( @@ -13,11 +13,23 @@ import ( func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() + kindNames := []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"} + constructorNames := []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"} + kindDeclarations := make([]*codegen.NameDeclaration, len(kindNames)) + constructorDeclarations := make([]*codegen.NameDeclaration, len(constructorNames)) + for index := range kindNames { + kindDeclarations[index] = codegen.NewExactName(codegen.NameConstant, kindNames[index]) + constructorDeclarations[index] = codegen.NewExactName(codegen.NameFunction, constructorNames[index]) + } record := &wireUnionRecord{ + declaration: codegen.NewExactName(codegen.NameType, "Value"), + kind: codegen.NewExactName(codegen.NameType, "ValueKind"), + kindDecls: kindDeclarations, + ctorDecls: constructorDeclarations, name: "Value", kindName: "ValueKind", - kindConsts: []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"}, - constructors: []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"}, + kindConsts: kindNames, + constructors: constructorNames, } data := buildHTTPUnionTypeData(union, codegen.NewAttributeScope(codegen.NewNameScope()), record) diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go index bc46927d3d..021cef482c 100644 --- a/http/codegen/service_imports.go +++ b/http/codegen/service_imports.go @@ -4,24 +4,55 @@ package codegen import ( "path" + "sort" "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) -// addEndpointImports adds the named service-type references used by endpoints -// to file's header. The output package is computed from the generated path. -func addEndpointImports(file *codegen.File, services *ServicesData, endpoints ...*expr.HTTPEndpointExpr) *codegen.File { +// addPlannedFileImports adds the service-type packages recorded for file before +// generation names were frozen. +func addPlannedFileImports(file *codegen.File, services *ServicesData) *codegen.File { if file == nil { return nil } - outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") - outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) - codegen.AddImport(file.SectionTemplates[0], services.AttributeImports(outputPackage, serviceReferenceAttributes(endpoints...)...)...) + codegen.AddImport(file.SectionTemplates[0], services.fileImports[filepathKey(file.Path)]...) return file } +// generatedFileOutputPackage returns the import path of the package that owns +// a file written below the generated directory. +func generatedFileOutputPackage(services *ServicesData, filePath string) string { + outputPath := strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), codegen.Gendir+"/") + return path.Join(services.GenPkg(), path.Dir(outputPath)) +} + +// serviceDataForOutput copies the package-name fields that a template writes +// so they match the imports selected by its actual output package. +func serviceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + serviceCopy := *data.Service + serviceCopy.PkgName = services.ServiceImport(outputPackage, data.Service.Name).Name + copy := *data + copy.Service = &serviceCopy + copy.Endpoints = make([]*EndpointData, len(data.Endpoints)) + for index, endpoint := range data.Endpoints { + endpointCopy := *endpoint + endpointCopy.ServicePkgName = serviceCopy.PkgName + copy.Endpoints[index] = &endpointCopy + } + return © +} + +// exampleServiceDataForOutput gives local variables the unique service package +// path chosen for this generation. Example files may use several services at +// once, and two service names can produce the same Go name. +func exampleServiceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + copy := serviceDataForOutput(data, services, outputPackage) + copy.Service.VarName = codegen.Goify(copy.Service.PathName, false) + return copy +} + // serviceReferenceAttributes returns the named service attributes referenced // by generated HTTP or JSON-RPC endpoint sections, including the nested result // field selected as SSE event data. @@ -46,6 +77,83 @@ func serviceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.Att return attributes } +// planHTTPAttributeImports records the metadata and relocated generated types +// referenced by transport conversion code in one output package. +func planHTTPAttributeImports(generation *codegen.Generation, outputPackage *codegen.GeneratedPackage, attributes ...*expr.AttributeExpr) ([]string, error) { + seen := make(map[expr.UserType]struct{}) + paths := make(map[string]struct{}) + var visit func(*expr.AttributeExpr) error + visit = func(attribute *expr.AttributeExpr) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := codegen.GetMetaType(attribute); spec != nil && spec.Path != outputPackage.ImportPath() { + if err := outputPackage.DeclareImport(spec); err != nil { + return err + } + paths[spec.Path] = struct{}{} + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + importPath := path.Join(generation.GenPkg(), location.RelImportPath) + if importPath != outputPackage.ImportPath() { + if err := outputPackage.ReserveGeneratedImport(codegen.NewImport( + strings.ToLower(codegen.Goify(path.Base(importPath), false)), + importPath, + )); err != nil { + return err + } + paths[importPath] = struct{}{} + } + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return visit(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + if err := visit(named.Attribute); err != nil { + return err + } + } + case *expr.Array: + return visit(actual.ElemType) + case *expr.Map: + if err := visit(actual.KeyType); err != nil { + return err + } + return visit(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + if err := visit(named.Attribute); err != nil { + return err + } + } + } + return nil + } + for _, attribute := range attributes { + if err := visit(attribute); err != nil { + return nil, err + } + } + result := make([]string, 0, len(paths)) + for importPath := range paths { + result = append(result, importPath) + } + sort.Strings(result) + return result, nil +} + +// filepathKey normalizes generated paths so file writers on every platform +// read the same planned import record. +func filepathKey(filePath string) string { + return strings.ReplaceAll(filePath, "\\", "/") +} + // httpWebSocketEndpoints returns only the endpoints whose stream sections are // rendered into WebSocket files. func httpWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { diff --git a/http/codegen/sse.go b/http/codegen/sse.go index 8a8524e129..13c67bebb9 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -14,16 +14,37 @@ import ( ) type ( + // SSEValueData describes one value written to or read from an SSE line. + // Kind selects its generated conversion. TypeRef keeps a declared Go type + // when the client rebuilds the service result. + SSEValueData struct { + // Kind is the designed kind of the value. + Kind expr.Kind + // TypeRef is the Go type assigned by the generated client. + TypeRef string + // Named reports whether TypeRef is a declared service type. + Named bool + // Pointer reports whether the service field stores a primitive pointer. + Pointer bool + // ClientPointer reports whether the validated HTTP body stores this + // primitive as a pointer before conversion to the service event. + ClientPointer bool + } + // SSEData contains the data needed to render struct type that // implements the server and client stream interface for SSE. SSEData struct { - // StructDeclaration is the package name used by the server stream type. + // StructName is the server stream type name kept for existing plugins. + // + // Deprecated: Use StructDeclaration.Name() after planning so name collisions are handled. + StructName string + // StructDeclaration is the generated Go type name used by the server stream. StructDeclaration *codegen.NameDeclaration - // ClientInterfaceDeclaration is the package name used by the client stream interface. + // ClientInterfaceDeclaration is the generated Go type name used by the client stream interface. ClientInterfaceDeclaration *codegen.NameDeclaration - // ClientStructDeclaration is the package name used by the client stream implementation. + // ClientStructDeclaration is the generated Go type name used by the client stream implementation. ClientStructDeclaration *codegen.NameDeclaration - // ClientInitDeclaration is the package name used by the client stream constructor. + // ClientInitDeclaration is the generated Go function name used by the client stream constructor. ClientInitDeclaration *codegen.NameDeclaration // Interface is the fully qualified name of the interface that // the struct implements. @@ -38,24 +59,37 @@ type ( SendWithContextDesc string // EventTypeRef is the fully qualified type ref for the event type. EventTypeRef string - // EventTypeName is the event type name without its Go package name. + // EventTypeName is the fully qualified non-pointer type used to allocate an event. EventTypeName string // EventIsStruct indicates whether the SSE method return type is a struct. EventIsStruct bool - // DataFieldTypeRef is the fully qualified type ref for the data field if any. + // DataFieldTypeRef is the final Go type of the mapped data field kept for + // existing plugins. It is empty when the whole event is data. + // + // Deprecated: Use Data.TypeRef. DataFieldTypeRef string // DataField is the name of the result type event data attribute if any. // If empty, the entire result type is used as the data field. DataField string + // Data describes the exact value carried by each data line. + Data SSEValueData // IDField is the name of the result type event ID attribute if any. // If empty, no id field is included in the event. IDField string + // ClientIDPointer reports whether the validated HTTP body stores IDField + // as a pointer before conversion to the service event. + ClientIDPointer bool // EventField is the name of the result type event field if any. // If empty, no event field is included in the event. EventField string + // ClientEventPointer reports whether the validated HTTP body stores + // EventField as a pointer before conversion to the service event. + ClientEventPointer bool // RetryField is the name of the result type event retry field if any. // If empty, no retry field is included in the event. RetryField string + // Retry describes the exact integer type carried by the retry line. + Retry *SSEValueData // RequestIDField is the name of the payload field that maps to the Last-Event-ID header if any. // If empty, no last event id is included in the request. RequestIDField string @@ -66,6 +100,10 @@ type ( // Response is the successful HTTP response whose body types encode and // decode stream events. Response *ResponseData + // ClientEventCode converts the validated HTTP event body into the service + // event returned by Recv. It is present for methods with different ordinary + // and streaming result types. + ClientEventCode string // VariableView reports whether SetView selects the result body used by all // events sent for one HTTP request. VariableView bool @@ -89,6 +127,9 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, if e.MethodExpr.HasMixedResults() && e.MethodExpr.StreamingResult != nil { // For mixed results, use StreamingResult for SSE events eventAttr = e.MethodExpr.StreamingResult + if eventAttr.Type == expr.Empty { + eventAttr = e.MethodExpr.Result + } svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) eventType = &ResultData{ Name: svcctx.Scope.Name(eventAttr, svcctx.Pkg(eventAttr), false, true), @@ -105,7 +146,15 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sendWithContextDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint SSE connection with context.", md.ServerStream.SendWithContextName, eventType.Name, md.Name) // Convert attribute names to Go field names - var dataFieldVar, dataFieldTypeRef, idFieldVar, eventFieldVar, retryFieldVar string + var ( + dataFieldVar string + dataFieldTypeRef string + dataField *expr.AttributeExpr + idFieldVar string + eventFieldVar string + retryFieldVar string + retryField *expr.AttributeExpr + ) svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) if obj := expr.AsObject(eventAttr.Type); obj != nil { for _, nat := range *obj { @@ -116,17 +165,23 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, eventFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) case e.SSE.RetryField: retryFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + retryField = nat.Attribute case e.SSE.DataField: dataFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + dataField = nat.Attribute fieldctx := svcctx.Enter(nat.Attribute) dataFieldTypeRef = fieldctx.Scope.Ref(nat.Attribute, fieldctx.Pkg(nat.Attribute)) } } } - // Determine if the Last-Event-ID mapped payload attribute is a primitive pointer + // Record the exact service field that receives Last-Event-ID and whether it + // uses a pointer. + ridField := "" ridPtr := false if e.SSE.RequestIDField != "" { + attribute := e.MethodExpr.Payload.Find(e.SSE.RequestIDField) + ridField = codegen.GoifyAtt(attribute, e.SSE.RequestIDField, true) ridPtr = e.MethodExpr.Payload.IsPrimitivePointer(e.SSE.RequestIDField, true) } @@ -144,10 +199,18 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, IDField: idFieldVar, EventField: eventFieldVar, RetryField: retryFieldVar, - RequestIDField: e.SSE.RequestIDField, + RequestIDField: ridField, RequestIDPointer: ridPtr, VariableView: md.ViewedResult != nil && md.ViewedResult.ViewName == "", } + if retryField != nil { + fieldctx := svcctx.Enter(retryField) + ed.SSE.Retry = &SSEValueData{ + Kind: retryField.Type.Kind(), + TypeRef: fieldctx.Scope.Ref(retryField, fieldctx.Pkg(retryField)), + Pointer: eventAttr.IsPrimitivePointer(e.SSE.RetryField, true), + } + } if ed.SSE.VariableView { for _, view := range md.ViewedResult.Views { if view.Name == expr.DefaultView { @@ -159,17 +222,68 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, panic(fmt.Sprintf("viewed SSE method %q has no default view", md.Name)) } } - if len(ed.Result.Responses) > 0 { - ed.SSE.Response = ed.Result.Responses[0] - } - - // Mixed results SSE uses the streaming result type for events, not the unary - // HTTP response body type. Disable HTTP response body conversion in the SSE - // stream implementation and marshal the event value directly. + // A mixed method has one ordinary result and a different streamed result. + // Build the streamed result's own JSON body instead of reusing the ordinary + // response body or encoding the service struct directly. if ed.HasMixedResults { - ed.SSE.HasResponseBody = false + body := sd.bodies.streamingResult(e) + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + transforms := sd.transforms.streamingResults[e] + serverBody := sds.buildResponseBodyType(body, eventAttr, e, true, nil, sd, transforms, owner, owner) + clientBody := sds.buildResponseBodyType(body, eventAttr, e, false, nil, sd, nil, owner, owner) + clientObject := expr.AsObject(body.Type) + ed.SSE.ClientIDPointer = sseBodyFieldPointer(clientObject, e.SSE.IDField) + ed.SSE.ClientEventPointer = sseBodyFieldPointer(clientObject, e.SSE.EventField) + if ed.SSE.Retry != nil { + ed.SSE.Retry.ClientPointer = sseBodyFieldPointer(clientObject, e.SSE.RetryField) + } + clientCode := "" + switch { + case body.Type == expr.Empty: + case transforms.clientDecodeDirect: + clientCode = "result := body" + case transforms.clientDecode.record != nil: + transformContext := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transformContext.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, jsonBodyPolicy(false, false, true, "")) + serviceContext := sds.serviceTypeContext(sd, "client").Enter(eventAttr) + var helpers []*codegen.TransformFunctionData + var err error + clientCode, helpers, err = sd.clientWireTypes.renderTransform( + transforms.clientDecode, + body, + "body", + "result", + transformContext, + serviceContext, + ) + if err != nil { + sds.recordLinkError(err) + } else { + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + default: + sds.recordLinkError(fmt.Errorf("mixed SSE client result for %q has no planned conversion", e.Name())) + } + ed.SSE.Response = &ResponseData{ClientBody: clientBody} + if serverBody != nil { + ed.SSE.Response.ServerBody = []*TypeData{serverBody} + } + ed.SSE.ClientEventCode = clientCode + ed.SSE.HasResponseBody = serverBody != nil + if dataField == nil { + dataField = body + dataFieldTypeRef = eventType.Ref + if serverBody != nil { + dataFieldTypeRef = serverBody.Ref + } + } + ed.SSE.Data = sseValueData(eventAttr, dataField, dataFieldTypeRef, e.SSE.DataField) + ed.SSE.Data.ClientPointer = sseBodyFieldPointer(clientObject, e.SSE.DataField) return } + if len(ed.Result.Responses) > 0 { + ed.SSE.Response = ed.Result.Responses[0] + } for _, resp := range ed.Result.Responses { if len(resp.ServerBody) > 0 { @@ -177,6 +291,100 @@ func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, break } } + dataAttribute := dataField + dataTypeRef := dataFieldTypeRef + if dataAttribute == nil { + dataAttribute = eventAttr + dataTypeRef = eventType.Ref + if ed.SSE.HasResponseBody && len(e.Responses) > 0 { + dataAttribute = sd.bodies.response(e.Responses[0]) + } + } + ed.SSE.Data = sseValueData(eventAttr, dataAttribute, dataTypeRef, e.SSE.DataField) +} + +// sseValueData records the exact conversion selected for one SSE value. +func sseValueData(event, value *expr.AttributeExpr, typeRef, field string) SSEValueData { + pointer := field != "" && event.IsPrimitivePointer(field, true) + if field == "" && value != event { + if origin, ok := value.Meta["origin:attribute"]; ok && len(origin) > 0 { + pointer = event.IsPrimitivePointer(origin[0], true) + } + } + kind := sseValueKind(value.Type) + named := false + if expr.IsPrimitive(value.Type) { + named = typeRef != codegen.GoNativeTypeName(expr.Primitive(kind)) + } + return SSEValueData{Kind: kind, TypeRef: typeRef, Named: named, Pointer: pointer} +} + +// sseBodyFieldPointer reports whether client validation keeps one primitive +// event field as a pointer so it can distinguish a missing value from zero. +func sseBodyFieldPointer(object *expr.Object, field string) bool { + if object == nil || field == "" { + return false + } + attribute := object.Attribute(field) + return attribute != nil && expr.IsPrimitive(attribute.Type) +} + +// sseValueKind returns the primitive or structured kind beneath a declared +// type name. Generated assignments still use the declared Go type in TypeRef. +func sseValueKind(dataType expr.DataType) expr.Kind { + switch actual := dataType.(type) { + case *expr.UserTypeExpr: + return sseValueKind(actual.Type) + case *expr.ResultTypeExpr: + return sseValueKind(actual.Type) + default: + return actual.Kind() + } +} + +// sseTemplateFuncs returns the generation-time type tests used by SSE +// templates. Each test removes every other conversion from generated code. +func sseTemplateFuncs() map[string]any { + return map[string]any{ + "ssePrimitive": func(value SSEValueData) bool { + switch value.Kind { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, + expr.Float32Kind, expr.Float64Kind, expr.StringKind, expr.BytesKind: + return true + default: + return false + } + }, + "sseString": func(value SSEValueData) bool { + return value.Kind == expr.StringKind + }, + "sseBytes": func(value SSEValueData) bool { + return value.Kind == expr.BytesKind + }, + "sseBoolean": func(value SSEValueData) bool { + return value.Kind == expr.BooleanKind + }, + "sseSignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.IntKind || value.Kind == expr.Int32Kind || value.Kind == expr.Int64Kind + }, + "sseUnsignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.UIntKind || value.Kind == expr.UInt32Kind || value.Kind == expr.UInt64Kind + }, + "sseFloat": func(value SSEValueData) bool { + return value.Kind == expr.Float32Kind || value.Kind == expr.Float64Kind + }, + "sseBitSize": func(value SSEValueData) int { + switch value.Kind { + case expr.Int32Kind, expr.UInt32Kind, expr.Float32Kind: + return 32 + case expr.Int64Kind, expr.UInt64Kind, expr.Float64Kind: + return 64 + default: + return 0 + } + }, + } } // sseServerFile returns the file implementing the SSE server @@ -187,7 +395,9 @@ func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "server", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "server", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) imports := []*codegen.ImportSpec{ @@ -198,7 +408,7 @@ func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F {Path: "time"}, {Path: "encoding/json"}, {Path: "fmt"}, - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } if serviceHasVariableViewedResult(data, IsSSEEndpoint) { imports = append(imports, codegen.GoaImport("")) @@ -221,14 +431,14 @@ func sseTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goify"] = codegen.Goify sections = append(sections, &codegen.SectionTemplate{ - Name: "server-sse", - Source: httpTemplates.Read(serverSseT, sseFormatP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "goify": codegen.Goify, - }, + Name: "server-sse", + Source: httpTemplates.Read(serverSseT, sseFormatP), + Data: ed, + FuncMap: funcs, }) } return sections diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index f30fadc859..0dd8c628e2 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -2,7 +2,6 @@ package codegen import ( "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -15,7 +14,9 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F if !HasSSE(data) { return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "client", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "client", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseClientTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) imports := []*codegen.ImportSpec{ @@ -29,11 +30,11 @@ func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.F {Path: "strings"}, {Path: "strconv"}, {Path: "sync"}, - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), {Path: "goa.design/goa/v3/http", Name: "goahttp"}, } if serviceHasViewedResult(data, IsSSEEndpoint) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } if serviceHasVariableViewedResult(data, IsSSEEndpoint) || serviceHasSSEResponseElements(data) { imports = append(imports, codegen.GoaImport("")) @@ -56,19 +57,16 @@ func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goTypeRef"] = func(dataType expr.DataType) string { + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dataType}) + } sections = append(sections, &codegen.SectionTemplate{ - Name: "client-sse", - Source: httpTemplates.Read(clientSseT, sseParseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "goTypeRef": func(dataType expr.DataType) string { - return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dataType}) - }, - "deref": func(ref string) string { - return strings.TrimPrefix(ref, "*") - }, - }, + Name: "client-sse", + Source: httpTemplates.Read(clientSseT, sseParseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: ed, + FuncMap: funcs, }) } return sections diff --git a/http/codegen/sse_client_test.go b/http/codegen/sse_client_test.go index 590813a1c6..5df5a83cd1 100644 --- a/http/codegen/sse_client_test.go +++ b/http/codegen/sse_client_test.go @@ -41,3 +41,62 @@ func TestSSEClient(t *testing.T) { }) } } + +// TestSSEClientSpecializesDataAndRetryParsing checks that generated clients +// parse each designed field into its exact Go type. +func TestSSEClientSpecializesDataAndRetryParsing(t *testing.T) { + tests := []struct { + name string + design func() + contains []string + }{ + { + name: "string alias", + design: ssePrimitiveAliasDSL, + contains: []string{"event = sseprimitivealias.EventText(dataContent)"}, + }, + { + name: "optional data field", + design: testdata.SSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "event.Data = &value", + }, + }, + { + name: "viewed data field", + design: viewedSSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "body.Data = &value", + }, + }, + { + name: "viewed alias data field", + design: viewedSSEPrimitiveAliasDataFieldDSL, + contains: []string{ + "value := viewedssealiasdata.ViewedEventText(dataContent)", + "body.Data = &value", + }, + }, + { + name: "retry", + design: testdata.SSEAllFieldsDSL, + contains: []string{ + `retryContent := s.trimHeader(line[len("retry:"):])`, + `strconv.ParseInt(retryContent, 10, 0)`, + "event.Retry = &value", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ClientFiles()) + for _, expected := range test.contains { + require.Contains(t, code, expected) + } + require.NotContains(t, code, "retry value parsing depends on the field type") + }) + } +} diff --git a/http/codegen/sse_mixed_result_runtime_test.go b/http/codegen/sse_mixed_result_runtime_test.go new file mode 100644 index 0000000000..11218453d7 --- /dev/null +++ b/http/codegen/sse_mixed_result_runtime_test.go @@ -0,0 +1,197 @@ +// This file renders a mixed HTTP/SSE client into a temporary module. The +// generated test proves mapped SSE fields are validated before the wire event +// becomes the service event returned by Recv. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedMixedSSEClientValidatesMappedWireBody catches clients decoding +// event data directly into a service value and skipping transport validation. +func TestGeneratedMixedSSEClientValidatesMappedWireBody(t *testing.T) { + root := expr.RunDSL(t, mixedSSEMappedFieldDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSEClientTest(t, files) +} + +// TestGeneratedMixedSSEResultShapesCompile verifies each generation-time +// conversion branch produces complete client and server packages: direct +// primitive values, direct primitive collections, converted anonymous objects, +// and empty events. +func TestGeneratedMixedSSEResultShapesCompile(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSECompile(t, files, "./gen/...") +} + +// mixedSSEResultShapesDSL puts every optional-transform shape in one generated +// client package so one compile checks their declarations and return paths. +func mixedSSEResultShapesDSL() { + dsl.Service("Mixed SSE Shapes", func() { + shapes := []struct { + name string + result any + }{ + {"int", dsl.Int}, + {"ints", dsl.ArrayOf(dsl.Int)}, + {"inline", func() { dsl.Attribute("value", dsl.Int) }}, + {"empty", func() {}}, + } + for _, shape := range shapes { + dsl.Method("watch_"+shape.name, func() { + dsl.Result(dsl.String) + dsl.StreamingResult(shape.result) + dsl.HTTP(func() { + dsl.GET("/" + shape.name) + dsl.ServerSentEvents() + }) + }) + } + }) +} + +// mixedSSEMappedFieldDSL makes event_id required and maps it to the SSE id +// line while message is carried by the data line. +func mixedSSEMappedFieldDSL() { + result := dsl.Type("Result", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + event := dsl.Type("Event", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + dsl.Service("Mixed SSE Wire", func() { + dsl.Method("watch", func() { + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("message", func() { + dsl.SSEEventID("event_id") + }) + }) + }) + }) +} + +// runGeneratedMixedSSEClientTest writes generated packages and runs the +// generated client's private event parser against complete and incomplete +// frames. +func runGeneratedMixedSSEClientTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "mixed_sse_wire", "client", "mixed_sse_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedMixedSSEClientTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/mixed_sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated mixed SSE client test:\n%s", output) +} + +// runGeneratedMixedSSECompile renders files in an isolated module and compiles +// the requested generated package. +func runGeneratedMixedSSECompile(t *testing.T, files []*codegen.File, packagePath string) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePath) + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated mixed SSE clients:\n%s", output) +} + +const generatedMixedSSEClientTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMappedEventBodyIsValidated(t *testing.T) { + stream := &WatchStreamImpl{} + + event, err := stream.processEvent([]byte("id: event-1\ndata: ready\n\n")) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "ready", event.Message) + + _, err = stream.processEvent([]byte("data: ready\n\n")) + require.Error(t, err) +} +` diff --git a/http/codegen/sse_mixed_results_test.go b/http/codegen/sse_mixed_results_test.go index 80169372ec..e71a5c0025 100644 --- a/http/codegen/sse_mixed_results_test.go +++ b/http/codegen/sse_mixed_results_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,8 +32,10 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "payload = res") - require.NotContains(t, code, "NewCreateResponseBody") + require.Contains(t, code, "body := NewEvent(res)") + require.Contains(t, code, "json.Marshal(body)") + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "json.Marshal(res)") }) t.Run("client", func(t *testing.T) { @@ -50,6 +53,68 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "event = new(") + require.Contains(t, code, "var body Event") + require.Contains(t, code, "err = ValidateEvent(&body)") + require.Contains(t, code, "result := &mixedresultsservice.Event{") + require.Contains(t, code, "return result, nil") }) } + +// TestSSE_MixedResultConversionSelection verifies that mixed SSE clients use a +// direct assignment only for wire values that already have the service type. +// Anonymous objects need a planned conversion, while an empty streamed result +// returns the method's zero event without declaring an HTTP body. +func TestSSE_MixedResultConversionSelection(t *testing.T) { + tests := []struct { + name string + streaming any + contains string + notContain string + }{ + {"primitive", dsl.Int, "result := body", "result := &"}, + {"primitive collection", dsl.ArrayOf(dsl.Int), "result := body", "result := &"}, + {"inline object", func() { dsl.Attribute("value", dsl.Int) }, "result := &", "result := body"}, + {"empty body", func() {}, "return event, nil", "var body"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapeDSL(test.streaming)) + plan := linkedHTTPPlanForRoot(t, root) + code := mixedSSEClientCode(t, plan) + require.Contains(t, code, test.contains) + require.NotContains(t, code, test.notContain) + }) + } +} + +// mixedSSEResultShapeDSL defines one ordinary string result and a separately +// streamed result so each test exercises the mixed SSE client path. +func mixedSSEResultShapeDSL(streaming any) func() { + return func() { + dsl.Service("Mixed Shape", func() { + dsl.Method("watch", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(streaming) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) + } +} + +// mixedSSEClientCode renders the mixed endpoint's client stream implementation. +func mixedSSEClientCode(t *testing.T, plan *Plan) string { + t.Helper() + for _, file := range plan.ClientFiles() { + if !strings.HasSuffix(file.Path, filepath.Join("client", "sse.go")) { + continue + } + sections := file.Section("client-sse") + require.NotEmpty(t, sections) + return codegen.SectionCode(t, sections[0]) + } + t.Fatal("mixed SSE client file was not generated") + return "" +} diff --git a/http/codegen/sse_primitive_wire_runtime_test.go b/http/codegen/sse_primitive_wire_runtime_test.go new file mode 100644 index 0000000000..2bba5465b4 --- /dev/null +++ b/http/codegen/sse_primitive_wire_runtime_test.go @@ -0,0 +1,248 @@ +// This file renders an HTTP SSE service into a temporary module. The generated +// server and client tests check the exact text used for primitive fields and +// the JSON used for structured fields. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedSSEFieldWireFormat checks both sides of the generated SSE +// connection for primitive, declared primitive, object, and array fields. +func TestGeneratedSSEFieldWireFormat(t *testing.T) { + root := expr.RunDSL(t, sseFieldWireDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := append(serviceFiles, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedSSEFieldWireTests(t, files) +} + +// sseFieldWireDSL maps each representative field type to the SSE data line. +func sseFieldWireDSL() { + eventText := dsl.Type("EventText", dsl.String) + requiredText := dsl.Type("RequiredText", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + aliasText := dsl.Type("AliasText", func() { + dsl.Attribute("value", eventText) + dsl.Required("value") + }) + optionalText := dsl.Type("OptionalText", func() { + dsl.Attribute("value", dsl.String) + }) + wireObject := dsl.Type("WireObject", func() { + dsl.Attribute("label", dsl.String) + dsl.Required("label") + }) + structured := dsl.Type("Structured", func() { + dsl.Attribute("object", wireObject) + dsl.Attribute("values", dsl.ArrayOf(dsl.String)) + dsl.Required("object", "values") + }) + + dsl.Service("SSE Wire", func() { + dsl.Method("required", func() { + dsl.StreamingResult(requiredText) + dsl.HTTP(func() { + dsl.GET("/required") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("alias", func() { + dsl.StreamingResult(aliasText) + dsl.HTTP(func() { + dsl.GET("/alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("optional", func() { + dsl.StreamingResult(optionalText) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("object", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/object") + dsl.ServerSentEvents("object") + }) + }) + dsl.Method("array", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/array") + dsl.ServerSentEvents("values") + }) + }) + }) +} + +// runGeneratedSSEFieldWireTests writes the generated packages and executes the +// server and client tests inside them. +func runGeneratedSSEFieldWireTests(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + serverTest := filepath.Join(directory, "gen", "http", "sse_wire", "server", "wire_test.go") + clientTest := filepath.Join(directory, "gen", "http", "sse_wire", "client", "wire_test.go") + require.NoError(t, os.WriteFile(serverTest, []byte(generatedSSEServerWireTest), 0o600)) + require.NoError(t, os.WriteFile(clientTest, []byte(generatedSSEClientWireTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/sse_wire/server", "./gen/http/sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated SSE wire tests:\n%s", output) +} + +const generatedSSEServerWireTest = `package server + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_wire" +) + +func TestPrimitiveFieldsUseRawSSEData(t *testing.T) { + tests := []struct { + name string + send func() string + want string + }{ + { + name: "string", + send: func() string { + recorder := httptest.NewRecorder() + stream := &RequiredServerStream{w: recorder} + require.NoError(t, stream.Send(&service.RequiredText{Value: "event"})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + { + name: "string alias", + send: func() string { + recorder := httptest.NewRecorder() + stream := &AliasServerStream{w: recorder} + require.NoError(t, stream.Send(&service.AliasText{Value: service.EventText("event")})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, test.send()) + }) + } +} + +func TestOptionalPrimitiveFieldPreservesPresence(t *testing.T) { + tests := []struct { + name string + value *string + want string + }{ + {name: "value", value: stringPointer("event"), want: "data: event\n\n"}, + {name: "empty", value: stringPointer(""), want: "data: \n\n"}, + {name: "absent", want: "\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &OptionalServerStream{w: recorder} + require.NoError(t, stream.Send(&service.OptionalText{Value: test.value})) + require.Equal(t, test.want, recorder.Body.String()) + }) + } +} + +func TestStructuredFieldsUseJSON(t *testing.T) { + objectRecorder := httptest.NewRecorder() + objectStream := &ObjectServerStream{w: objectRecorder} + value := &service.WireObject{Label: "event"} + require.NoError(t, objectStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: {\"label\":\"event\"}\n\n", objectRecorder.Body.String()) + + arrayRecorder := httptest.NewRecorder() + arrayStream := &ArrayServerStream{w: arrayRecorder} + require.NoError(t, arrayStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: [\"one\",\"two\"]\n\n", arrayRecorder.Body.String()) +} + +func stringPointer(value string) *string { + return &value +} +` + +const generatedSSEClientWireTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOptionalPrimitiveDataAllocatesOnlyWhenPresent(t *testing.T) { + stream := &OptionalStreamImpl{} + + value, err := stream.processEvent([]byte("data: event\n\n")) + require.NoError(t, err) + require.NotNil(t, value.Value) + require.Equal(t, "event", *value.Value) + + empty, err := stream.processEvent([]byte("data: \n\n")) + require.NoError(t, err) + require.NotNil(t, empty.Value) + require.Empty(t, *empty.Value) + + absent, err := stream.processEvent([]byte("\n\n")) + require.NoError(t, err) + require.Nil(t, absent.Value) +} +` diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go index 2382119cbc..2586091fa3 100644 --- a/http/codegen/sse_server_test.go +++ b/http/codegen/sse_server_test.go @@ -8,6 +8,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -42,6 +43,46 @@ func TestSSE(t *testing.T) { } } +// TestSSEServerSpecializesDataEncoding checks that generated send methods use +// the designed data type directly, including named primitive types. +func TestSSEServerSpecializesDataEncoding(t *testing.T) { + tests := []struct { + name string + design func() + contains string + }{ + {name: "string", design: testdata.SSEStringDSL, contains: "data = string(body)"}, + {name: "string alias", design: ssePrimitiveAliasDSL, contains: "data = string(body)"}, + {name: "object", design: testdata.SSEObjectDSL, contains: "json.Marshal(body)"}, + {name: "optional data field", design: testdata.SSEDataFieldDSL, contains: "data = string(*body.Data)"}, + {name: "viewed data field", design: viewedSSEDataFieldDSL, contains: "data = string(body.Data)"}, + {name: "viewed alias data field", design: viewedSSEPrimitiveAliasDataFieldDSL, contains: "data = string(body.Data)"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + + require.Contains(t, code, test.contains) + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "payload.(type)") + if test.name == "optional data field" { + require.Contains(t, code, "if body.Data != nil") + require.NotContains(t, code, "json.Marshal(body.Data)") + } + }) + } +} + +// TestSSEServerWritesOptionalRetryValue checks that an optional service field +// is tested and dereferenced before it is written to the retry line. +func TestSSEServerWritesOptionalRetryValue(t *testing.T) { + root := expr.RunDSL(t, testdata.SSEAllFieldsDSL) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + require.Contains(t, code, "retry != nil && *retry > 0") + require.Contains(t, code, `fmt.Fprintf(s.w, "retry: %d\n", *retry)`) +} + func TestSSETransportDefaultsToStatusOK(t *testing.T) { root := expr.RunDSL(t, testdata.SSEStringDSL) plan := linkedHTTPPlanForRoot(t, root) @@ -54,3 +95,18 @@ func TestSSETransportDefaultsToStatusOK(t *testing.T) { require.Contains(t, code, "s.w.WriteHeader(http.StatusOK)") require.NotContains(t, code, "http.StatusSwitchingProtocols") } + +// ssePrimitiveAliasDSL streams a named string so generated SSE code must use +// its known underlying string representation. +func ssePrimitiveAliasDSL() { + text := dsl.Type("EventText", dsl.String) + dsl.Service("SSE Primitive Alias", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(text) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/streaming_test.go b/http/codegen/streaming_test.go index 7a73958e05..93f3acf66c 100644 --- a/http/codegen/streaming_test.go +++ b/http/codegen/streaming_test.go @@ -1,6 +1,7 @@ package codegen import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -108,7 +109,7 @@ func TestServerStreaming(t *testing.T) { {"server-websocket-send", &testdata.StreamingPayloadResultWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultWithViewsServerStreamRecvCode}, {"server-websocket-close", nil}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewServerStreamSendCode}, @@ -118,7 +119,7 @@ func TestServerStreaming(t *testing.T) { {"streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode}, @@ -164,7 +165,7 @@ func TestServerStreaming(t *testing.T) { {"server-websocket-send", &testdata.BidirectionalStreamingResultWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsServerStreamRecvCode}, {"server-websocket-close", &testdata.BidirectionalStreamingResultWithViewsServerStreamCloseCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewServerStreamSendCode}, @@ -174,7 +175,7 @@ func TestServerStreaming(t *testing.T) { {"bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewServerStreamSendCode}, @@ -210,6 +211,36 @@ func TestServerStreaming(t *testing.T) { runTests(t, cases, filesFn) } +// TestVariableViewWebSocketSendWritesTypedBranches checks that the selected +// view is the only runtime choice and that an unknown view writes nothing. +func TestVariableViewWebSocketSendWritesTypedBranches(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingResultWithViewsDSL) + files := linkedHTTPPlanForRoot(t, root).ServerFiles() + var code string + for _, file := range files { + for _, section := range file.SectionTemplates { + if section.Name == "server-websocket-send" { + code = codegen.SectionCode(t, section) + } + } + } + require.NotEmpty(t, code) + require.NotContains(t, code, "var body any") + require.Contains(t, code, `if view == "" {`) + require.Contains(t, code, `view = "default"`) + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `respHdr.Add("goa-view", view)`) + require.Contains(t, code, `case "tiny":`) + require.Contains(t, code, `res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny")`) + require.Contains(t, code, "return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected))") + require.Contains(t, code, `default:`) + require.Contains(t, code, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(code, `InvalidEnumValueError("view", view`), + strings.Index(code, "s.once.Do"), + ) +} + func TestClientStreaming(t *testing.T) { cases := []*testCase{ {"client-mixed-endpoints", testdata.StreamingResultDSL, []*sectionExpectation{ @@ -289,7 +320,7 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-send", &testdata.StreamingPayloadResultWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultWithViewsClientStreamRecvCode}, {"client-websocket-close", nil}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewClientStreamSendCode}, @@ -299,7 +330,7 @@ func TestClientStreaming(t *testing.T) { {"client-streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewClientStreamSendCode}, @@ -347,7 +378,7 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-send", &testdata.BidirectionalStreamingResultWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsClientStreamRecvCode}, {"client-websocket-close", &testdata.BidirectionalStreamingResultWithViewsClientStreamCloseCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewClientStreamSendCode}, @@ -357,7 +388,7 @@ func TestClientStreaming(t *testing.T) { {"client-bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewClientStreamSendCode}, diff --git a/http/codegen/symbols.go b/http/codegen/symbols.go index f3f07b2786..19a5be127e 100644 --- a/http/codegen/symbols.go +++ b/http/codegen/symbols.go @@ -40,6 +40,7 @@ type ( discardStream *codegen.NameDeclaration requestEncoder *codegen.NameDeclaration responseDecoder *codegen.NameDeclaration + requestBuilder *codegen.NameDeclaration buildStreamPayload *codegen.NameDeclaration cliPayload *codegen.NameDeclaration serverMultipart *httpMultipartSymbols @@ -65,6 +66,7 @@ type ( httpSymbolID struct { transport transportKind role httpSymbolRole + api string service string method string subject string @@ -99,6 +101,7 @@ const ( httpDiscardStreamRole httpRequestEncoderRole httpResponseDecoderRole + httpRequestBuilderRole httpBuildStreamPayloadRole httpCLIPayloadRole httpMultipartTypeRole @@ -127,7 +130,7 @@ func collectHTTPSymbols(plan *Plan, service *expr.HTTPServiceExpr, clientPackage } return declaration, nil } - serviceID := httpSymbolID{transport: plan.transport, service: service.Name()} + serviceID := httpSymbolID{transport: plan.transport, api: plan.root.API.Name, service: service.Name()} var err error if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.withRole(httpServerStructRole)); err != nil { return nil, err @@ -234,6 +237,10 @@ func collectHTTPSymbols(plan *Plan, service *expr.HTTPServiceExpr, clientPackage if err != nil { return nil, err } + endpointSymbols.requestBuilder, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestBuilderRole)) + if err != nil { + return nil, err + } if endpoint.SkipRequestBodyEncodeDecode { endpointSymbols.buildStreamPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"StreamPayload", codegen.ExportedName, id.withRole(httpBuildStreamPayloadRole)) if err != nil { @@ -332,10 +339,8 @@ func clientRequestEncoderSelected(endpoint *expr.HTTPEndpointExpr) bool { if endpoint.IsJSONRPC() { return true } - if endpoint.SkipRequestBodyEncodeDecode { - return false - } - if endpoint.Body.Type != expr.Empty || endpoint.MapQueryParams != nil || + if (!endpoint.SkipRequestBodyEncodeDecode && endpoint.Body.Type != expr.Empty) || + endpoint.MapQueryParams != nil || len(*expr.AsObject(endpoint.QueryParams().Type)) > 0 || len(*expr.AsObject(endpoint.Headers.Type)) > 0 || len(*expr.AsObject(endpoint.Cookies.Type)) > 0 { @@ -382,6 +387,7 @@ func (order httpSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) right := httpSymbolID(other.(httpSymbolOrder)) for _, compared := range []int{ cmp.Compare(left.transport, right.transport), + cmp.Compare(left.api, right.api), cmp.Compare(left.service, right.service), cmp.Compare(left.method, right.method), cmp.Compare(left.role, right.role), diff --git a/http/codegen/templates.go b/http/codegen/templates.go index f625416473..60abee76e5 100644 --- a/http/codegen/templates.go +++ b/http/codegen/templates.go @@ -96,6 +96,7 @@ const ( sseParseP = "sse_parse" websocketUpgradeP = "websocket_upgrade" clientTypeConversionP = "client_type_conversion" + clientTypeExpressionP = "client_type_expression" clientMapConversionP = "client_map_conversion" singleResponseP = "single_response" queryTypeConversionP = "query_type_conversion" diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl index afb2c063e2..f2abdcf65f 100644 --- a/http/codegen/templates/cli_end.go.tpl +++ b/http/codegen/templates/cli_end.go.tpl @@ -1,4 +1,25 @@ -endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} scheme, host, doer, @@ -27,7 +48,34 @@ endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( {{- end }} ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil + +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if and (streamsOutput .Method) (not .HasMixedResults) }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic({{ printf "%q" (printf "parsed %s command has no generated result writer" .Transport) }}) } diff --git a/http/codegen/templates/cli_start.go.tpl b/http/codegen/templates/cli_start.go.tpl index 6054afbdf6..8a3e7640fa 100644 --- a/http/codegen/templates/cli_start.go.tpl +++ b/http/codegen/templates/cli_start.go.tpl @@ -1,9 +1,9 @@ -func do{{ .FuncSuffix }}(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func do{{ .FuncSuffix }}(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer {{- range .Services }} {{- if .Service.ClientInterceptors }} - {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.ClientInterceptors + {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.{{ .Service.ClientInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) diff --git a/http/codegen/templates/cli_usage.go.tpl b/http/codegen/templates/cli_usage.go.tpl index 9160a2b554..edc67cd66b 100644 --- a/http/codegen/templates/cli_usage.go.tpl +++ b/http/codegen/templates/cli_usage.go.tpl @@ -1,8 +1,4 @@ -func {{ .VarPrefix }}UsageCommands() []string { - return {{ .CLIPkg }}.{{ .Parser.UsageCommands.Name }}() -} - func {{ .VarPrefix }}UsageExamples() string { return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } diff --git a/http/codegen/templates/client_body_init.go.tpl b/http/codegen/templates/client_body_init.go.tpl index 1d598660bf..42f68d0067 100644 --- a/http/codegen/templates/client_body_init.go.tpl +++ b/http/codegen/templates/client_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ClientCode }} return body } diff --git a/http/codegen/templates/client_endpoint_init.go.tpl b/http/codegen/templates/client_endpoint_init.go.tpl index 2f53106c78..ced2e48f4a 100644 --- a/http/codegen/templates/client_endpoint_init.go.tpl +++ b/http/codegen/templates/client_endpoint_init.go.tpl @@ -12,7 +12,7 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .Multipa {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .Multipa return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } if c.configurer.{{ .Method.VarName }}Fn != nil { - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) conn = c.configurer.{{ .Method.VarName }}Fn(conn, cancel) @@ -40,7 +40,7 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .Multipa conn = c.configurer.{{ .Method.VarName }}Fn(conn, nil) {{- end }} } - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} go func() { <-ctx.Done() conn.WriteControl( @@ -77,8 +77,11 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .Multipa contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil @@ -90,7 +93,9 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .Multipa {{- if .Method.SkipResponseBodyEncodeDecode }} {{ if .Result.Ref }}res{{ else }}_{{ end }}, err {{ if .Result.Ref }}:{{ end }}= decodeResponse(resp) if err != nil { - resp.Body.Close() + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", closeErr)) + } return nil, err } return &{{ .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil diff --git a/http/codegen/templates/client_sse.go.tpl b/http/codegen/templates/client_sse.go.tpl index 0d8c26b272..810da40f18 100644 --- a/http/codegen/templates/client_sse.go.tpl +++ b/http/codegen/templates/client_sse.go.tpl @@ -1,3 +1,7 @@ +{{/* +client_sse.go.tpl writes the HTTP client stream for one SSE endpoint. The plan +provides the exact data and retry types used to rebuild each service result. +*/ -}} // {{ .SSE.ClientInterfaceDeclaration.Name }} is the interface for reading Server-Sent Events. type {{ .SSE.ClientInterfaceDeclaration.Name }} interface { // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. @@ -197,35 +201,53 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { // processEvent processes a raw SSE event into the expected type func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { - {{- if .SSE.EventIsStruct }} - event = new({{ deref .SSE.EventTypeRef }}) - {{- end }} + {{- if and .SSE.EventIsStruct (not .HasMixedResults) }} + event = new({{ .SSE.EventTypeName }}) + {{- end }} + {{- if .HasMixedResults }} + {{- with .SSE.Response.ClientBody }} + var body {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .VarName }}{{ end }} + {{- end }} + {{- end }} var dataLines []string for _, line := range bytes.Split(eventData, []byte("\n")) { if len(line) == 0 { continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } {{- if .SSE.IDField }} if bytes.HasPrefix(line, []byte("id:")) { - event.{{ .SSE.IDField }} = s.trimHeader(len("id:"), line) + {{- if and $.HasMixedResults $.SSE.ClientIDPointer }} + idContent := s.trimHeader(line[len("id:"):]) + body.{{ .SSE.IDField }} = &idContent + {{- else }} + {{ if $.HasMixedResults }}body{{ else }}event{{ end }}.{{ .SSE.IDField }} = s.trimHeader(line[len("id:"):]) + {{- end }} continue } {{- end }} {{- if .SSE.EventField }} if bytes.HasPrefix(line, []byte("event:")) { - event.{{ .SSE.EventField }} = s.trimHeader(len("event:"), line) + {{- if and $.HasMixedResults $.SSE.ClientEventPointer }} + eventContent := s.trimHeader(line[len("event:"):]) + body.{{ .SSE.EventField }} = &eventContent + {{- else }} + {{ if $.HasMixedResults }}body{{ else }}event{{ end }}.{{ .SSE.EventField }} = s.trimHeader(line[len("event:"):]) + {{- end }} continue } {{- end }} {{- if .SSE.RetryField }} if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + retryContent := s.trimHeader(line[len("retry:"):]) + {{- if $.HasMixedResults }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "Nullable" false "TargetPointer" .SSE.Retry.ClientPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "Nullable" false "TargetPointer" .SSE.Retry.Pointer }} + {{- end }} continue } {{- end }} @@ -248,11 +270,45 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} {{- end }} {{- end }} + {{- else if .HasMixedResults }} + {{- with .SSE.Response.ClientBody }} + if len(dataLines) > 0 { + dataContent := strings.Join(dataLines, "\n") + {{- if $.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $.SSE.DataField) "Source" "dataContent" "Encoding" $.SSE.Data "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.ClientPointer }} + {{- else if ssePrimitive $.SSE.Data }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $.SSE.Data "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.Pointer }} + {{- else }} + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + if err = s.decoder(respBody).Decode(&body); err != nil { + return event, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + } + {{- if and .ValidatorDeclaration .ValidationTarget }} + err = {{ .ValidatorDeclaration.Name }}({{ .ValidationTarget }}) + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ValidateRef }} + {{ .ValidateRef }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{ $.SSE.ClientEventCode }} + return result, nil + {{- else }} + return event, nil + {{- end }} {{- else }} if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") {{- if .SSE.DataField }} - {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "TypeRef" .SSE.DataFieldTypeRef }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "Source" "dataContent" "Encoding" .SSE.Data "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} {{- else if .SSE.EventIsStruct }} // Decode the event data into the result value returned by Recv. respBody := &http.Response{ @@ -264,18 +320,20 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) return } {{- else }} - {{ template "partial_sse_parse" dict "Target" "event" "TypeRef" .SSE.EventTypeRef }} + {{ template "partial_sse_parse" dict "Target" "event" "Source" "dataContent" "Encoding" .SSE.Data "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} {{- end }} } {{- end }} - return + {{- if not .HasMixedResults }} + return + {{- end }} } {{- define "viewed_sse_client_result" }} {{- $endpoint := .Endpoint }} {{- with .Representation }} {{- if .ClientBody }} - var body {{ .ClientBody.VarName }} + var body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} {{- if $endpoint.SSE.IDField }} body.{{ $endpoint.SSE.IDField }} = event.{{ $endpoint.SSE.IDField }} {{- end }} @@ -284,20 +342,28 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) {{- end }} if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") + {{- if ssePrimitive $endpoint.SSE.Data }} + {{- if $endpoint.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $endpoint.SSE.DataField) "Source" "dataContent" "Encoding" $endpoint.SSE.Data "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $endpoint.SSE.Data "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- end }} + {{- else }} respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), } - {{- if $endpoint.SSE.DataField }} + {{- if $endpoint.SSE.DataField }} if err = s.decoder(respBody).Decode(&body.{{ $endpoint.SSE.DataField }}); err != nil { - {{- else }} + {{- else }} if err = s.decoder(respBody).Decode(&body); err != nil { - {{- end }} + {{- end }} return event, goahttp.ErrDecodingError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) } + {{- end }} } {{- end }} - projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }}, {{ end }}) + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }}, {{ end }}) viewed := {{ if not $endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.VarName }}{Projected: projected, View: view} if err = {{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.Validate.Declaration.Name }}(viewed); err != nil { return event, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) @@ -409,12 +475,8 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) {{- end }} {{- end }} -// trimHeader removes the header prefix and optional leading space -func (s *{{ .SSE.ClientStructDeclaration.Name }}) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/templates/client_type_init.go.tpl b/http/codegen/templates/client_type_init.go.tpl index 7324604835..7ac4147b66 100644 --- a/http/codegen/templates/client_type_init.go.tpl +++ b/http/codegen/templates/client_type_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{- if .ClientCode }} {{ .ClientCode }} {{- if .ReturnTypeAttribute }} diff --git a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl index 233be556c4..4ab092f1e7 100644 --- a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart decoder for service %q endpoint %q. The decoder must populate the argument p after encoding." .FuncDeclaration.Name .ServiceName .MethodName | comment }} -func {{ .FuncDeclaration.Name }}(mr *multipart.Reader, p *{{ .Payload.Ref }}) error { +{{ printf "%s reads the multipart request body for service %q endpoint %q into body." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mr *multipart.Reader, body *{{ .BodyType }}) error { // Add multipart request decoder logic here return nil } diff --git a/http/codegen/templates/file_server.go.tpl b/http/codegen/templates/file_server.go.tpl index 8ad01c70c2..fde37a6570 100644 --- a/http/codegen/templates/file_server.go.tpl +++ b/http/codegen/templates/file_server.go.tpl @@ -1,5 +1,8 @@ {{ printf "%s configures the mux to serve GET request made to %q." .MountHandlerDeclaration.Name (join .RequestPaths ", ") | comment }} func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} {{- if .IsDir }} {{- range .RequestPaths }} mux.Handle("GET", "{{ . }}{{if ne . "/"}}/{{end}}", h.ServeHTTP) diff --git a/http/codegen/templates/multipart_request_decoder.go.tpl b/http/codegen/templates/multipart_request_decoder.go.tpl index 9c73bd1acb..b7e3d47e5c 100644 --- a/http/codegen/templates/multipart_request_decoder.go.tpl +++ b/http/codegen/templates/multipart_request_decoder.go.tpl @@ -1,28 +1,15 @@ {{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} -func {{ .InitDeclaration.Name }}(mux goahttp.Muxer, {{ .VarName }} {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Decoder { +func {{ .InitDeclaration.Name }}(_ goahttp.Muxer, {{ .VarName }} {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*{{ .Payload.Ref }}) - if err := {{ .VarName }}(mr, p); err != nil { + body := v.(*{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) + if err := {{ .VarName }}(mr, body); err != nil { return err } - {{- template "partial_request_elements" .Payload.Request }} - {{- if .Payload.Request.MustValidate }} - if err != nil { - return err - } - {{- end }} - {{- if .Payload.Request.PayloadInit }} - {{- range .Payload.Request.PayloadInit.ServerArgs }} - {{- if .FieldName }} - (*p).{{ .FieldName }} = {{ if and (not .Pointer) .FieldPointer }}&{{ end }}{{ .VarName }} - {{- end }} - {{- end }} - {{- end }} return nil }) } diff --git a/http/codegen/templates/multipart_request_decoder_type.go.tpl b/http/codegen/templates/multipart_request_decoder_type.go.tpl index 28be9c5ac5..db7211a4f6 100644 --- a/http/codegen/templates/multipart_request_decoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_decoder_type.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} -type {{ .FuncDeclaration.Name }} func(*multipart.Reader, *{{ .Payload.Ref }}) error +type {{ .FuncDeclaration.Name }} func(*multipart.Reader, *{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) error diff --git a/http/codegen/templates/parse_endpoint.go.tpl b/http/codegen/templates/parse_endpoint.go.tpl index 01676b1371..fdc864fc3a 100644 --- a/http/codegen/templates/parse_endpoint.go.tpl +++ b/http/codegen/templates/parse_endpoint.go.tpl @@ -1,59 +1,59 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. func {{ .Declaration.Name }}( - scheme, host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restore bool, + {{ .Variables.Scheme }}, {{ .Variables.Host }} string, + {{ .Variables.Doer }} goahttp.Doer, + {{ .Variables.Encoder }} func(*http.Request) goahttp.Encoder, + {{ .Variables.Decoder }} func(*http.Response) goahttp.Decoder, + {{ .Variables.Restore }} bool, {{- if streamingCmdExists .Commands }} - dialer goahttp.Dialer, + {{ .Variables.Dialer }} goahttp.Dialer, {{- range .Commands }} {{- if .NeedDialer }} - {{ if .JSONRPC }}{{ .VarName }}ConfigFn goahttp.ConnConfigureFunc,{{ else }}{{ .VarName }}Configurer *{{ .PkgName }}.{{ .Configurer.Name }},{{ end }} + {{ if .JSONRPC }}{{ .ConfigurerLocal.VarName }} goahttp.ConnConfigureFunc,{{ else }}{{ .ConfigurerLocal.VarName }} *{{ .PkgName }}.{{ .Configurer.Name }},{{ end }} {{- end }} {{- end }} {{- end }} {{- range $i, $c := .Commands }} {{- range .Subcommands }} {{- if .MultipartVarName }} - {{ .MultipartVarName }} {{ $c.PkgName }}.{{ .MultipartFuncDeclaration.Name }}, + {{ .MultipartLocal.VarName }} {{ $c.PkgName }}.{{ .MultipartFuncDeclaration.Name }}, {{- end }} {{- end }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.{{ .ClientInit.Name }}(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ if .JSONRPC }}{{ .VarName }}ConfigFn{{ else }}{{ .VarName }}Configurer{{ end }}{{ end }}) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Scheme }}, {{ $.Variables.Host }}, {{ $.Variables.Doer }}, {{ $.Variables.Encoder }}, {{ $.Variables.Decoder }}, {{ $.Variables.Restore }}{{ if .NeedDialer }}, {{ $.Variables.Dialer }}, {{ .ConfigurerLocal.VarName }}{{ end }}) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}({{ if .MultipartVarName }}{{ .MultipartVarName }}{{ end }}) + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}({{ if .MultipartLocal }}{{ .MultipartLocal.VarName }}{{ end }}) {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName }}.{{ .BuildFunction.Declaration.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildFunction.Name }}({{ range .ActualPointerVars }}*{{ . }}, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} {{- if .StreamFlag }} {{- if .BuildFunction }} - if err == nil { + if {{ $.Variables.Error }} == nil { {{- end }} - data, err = {{ $pkgName }}.{{ .BuildStreamPayload.Name }}({{ if or .BuildFunction .Conversion }}data, {{ end }}*{{ .StreamFlag.FullName }}Flag) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildStreamPayloadDeclaration.Name }}({{ if or .BuildFunction .Conversion }}{{ $.Variables.Data }}, {{ end }}*{{ .StreamPointerVar }}) {{- if .BuildFunction }} } {{- end }} @@ -63,9 +63,9 @@ func {{ .Declaration.Name }}( {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/http/codegen/templates/partial/client_type_conversion.go.tpl b/http/codegen/templates/partial/client_type_conversion.go.tpl index d4382c027f..7e1fc34b5c 100644 --- a/http/codegen/templates/partial/client_type_conversion.go.tpl +++ b/http/codegen/templates/partial/client_type_conversion.go.tpl @@ -1,27 +1 @@ - {{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) - {{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) - {{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'f', -1, 64) - {{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} - {{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) - {{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) - {{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} - {{- end }} +{{- .VarName }} := {{ template "partial_client_type_expression" . }} diff --git a/http/codegen/templates/partial/client_type_expression.go.tpl b/http/codegen/templates/partial/client_type_expression.go.tpl new file mode 100644 index 0000000000..be77be13f0 --- /dev/null +++ b/http/codegen/templates/partial/client_type_expression.go.tpl @@ -0,0 +1,23 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "uint" "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'g', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'g', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/http/codegen/templates/partial/request_elements.go.tpl b/http/codegen/templates/partial/request_elements.go.tpl index 6849830237..d9d948b1e9 100644 --- a/http/codegen/templates/partial/request_elements.go.tpl +++ b/http/codegen/templates/partial/request_elements.go.tpl @@ -14,7 +14,7 @@ {{- range .Cookies }} {{ .VarName }} {{ .TypeRef }} {{- end }} - {{- if and .MustValidate (or (not .ServerBody) .Multipart) }} + {{- if and .MustValidate (not .ServerBody) }} err error {{- end }} {{- if .Cookies }} diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 895c35517c..68e589ce1c 100644 --- a/http/codegen/templates/partial/response.go.tpl +++ b/http/codegen/templates/partial/response.go.tpl @@ -7,7 +7,7 @@ {{- range $.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- $vsb := (viewedServerBody $.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body = {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} } {{- else if (index .ServerBody 0).Init }} @@ -17,7 +17,7 @@ body = formatter(ctx, {{ (index (index .ServerBody 0).Init.ServerArgs 0).Ref }}) } else { {{- end }} - body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Declaration.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- if .ErrorHeader }} } {{- end }} diff --git a/http/codegen/templates/partial/single_response.go.tpl b/http/codegen/templates/partial/single_response.go.tpl index 764446207d..85eafbcfe7 100644 --- a/http/codegen/templates/partial/single_response.go.tpl +++ b/http/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,19 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) diff --git a/http/codegen/templates/partial/sse_format.go.tpl b/http/codegen/templates/partial/sse_format.go.tpl index 1745c21900..f46be3b6f4 100644 --- a/http/codegen/templates/partial/sse_format.go.tpl +++ b/http/codegen/templates/partial/sse_format.go.tpl @@ -1,21 +1,35 @@ -{{- if eq .TypeRef "string" }} - data = {{ .VarName }} -{{- else if eq .TypeRef "boolean" }} - if {{ .VarName }} { +{{/* +sse_format.go.tpl converts one planned service value to SSE event text. Every +type choice is resolved before this template writes the send method. +*/ -}} +{{- $value := .Value }} +{{- if .Encoding.Pointer }} + if {{ .Value }} != nil { + {{- $value = printf "*%s" .Value }} +{{- end }} +{{- if sseString .Encoding }} + data = string({{ $value }}) +{{- else if sseBoolean .Encoding }} + if {{ $value }} { data = "true" } else { data = "false" } -{{- else if eq .TypeRef "bytes" }} - data = string({{ .VarName }}) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") (eq .TypeRef "int64") (eq .TypeRef "uint") (eq .TypeRef "uint32") (eq .TypeRef "uint64") }} - data = fmt.Sprintf("%d", {{ .VarName }}) -{{- else if or (eq .TypeRef "float32") (eq .TypeRef "float64") }} - data = fmt.Sprintf("%g", {{ .VarName }}) +{{- else if sseBytes .Encoding }} + data = string({{ $value }}) +{{- else if or (sseSignedInteger .Encoding) (sseUnsignedInteger .Encoding) }} + data = fmt.Sprintf("%d", {{ $value }}) +{{- else if sseFloat .Encoding }} + data = fmt.Sprintf("%g", {{ $value }}) {{- else }} - byts, err := json.Marshal({{ .VarName }}) + byts, err := json.Marshal({{ $value }}) if err != nil { return err } data = string(byts) -{{- end }} \ No newline at end of file +{{- end }} +{{- if .Encoding.Pointer }} + } else { + hasData = false + } +{{- end }} diff --git a/http/codegen/templates/partial/sse_parse.go.tpl b/http/codegen/templates/partial/sse_parse.go.tpl index abc3995d0c..540944a8ed 100644 --- a/http/codegen/templates/partial/sse_parse.go.tpl +++ b/http/codegen/templates/partial/sse_parse.go.tpl @@ -1,58 +1,87 @@ -{{- if eq .TypeRef "string" }} - {{ .Target }} = dataContent -{{- else if eq .TypeRef "boolean" }} +{{/* +sse_parse.go.tpl rebuilds one planned Go value from SSE event text. The +generated client contains only the conversion required by that value. +*/ -}} +{{- if sseString .Encoding }} + {{- if .TargetPointer }} + {{- if .Encoding.Named }} + value := {{ .Encoding.TypeRef }}({{ .Source }}) + {{- else }} + value := {{ .Source }} + {{- end }} + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}({{ .Source }}) + {{- else }} + {{ .Target }} = {{ .Source }} + {{- end }} +{{- else if sseBoolean .Encoding }} var val bool - val, err = strconv.ParseBool(dataContent) + val, err = strconv.ParseBool({{ .Source }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- else }} {{ .Target }} = val -{{- else if eq .TypeRef "bytes" }} - {{ .Target }} = []byte(dataContent) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") }} + {{- end }} +{{- else if sseBytes .Encoding }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}([]byte({{ .Source }})) + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}([]byte({{ .Source }})) + {{- else }} + {{ .Target }} = []byte({{ .Source }}) + {{- end }} +{{- else if sseSignedInteger .Encoding }} var val int64 - val, err = strconv.ParseInt(dataContent, 10, 0) + val, err = strconv.ParseInt({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } - {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "int64" }} - {{ .Target }}, err = strconv.ParseInt(dataContent, 10, 64) - if err != nil { - return - } -{{- else if or (eq .TypeRef "uint") (eq .TypeRef "uint32") }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} +{{- else if sseUnsignedInteger .Encoding }} var val uint64 - val, err = strconv.ParseUint(dataContent, 10, 0) + val, err = strconv.ParseUint({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } - {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "uint64" }} - {{ .Target }}, err = strconv.ParseUint(dataContent, 10, 64) - if err != nil { - return - } -{{- else if eq .TypeRef "float32" }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} +{{- else if sseFloat .Encoding }} var val float64 - val, err = strconv.ParseFloat(dataContent, 32) - if err != nil { - return - } - {{ .Target }} = float32(val) -{{- else if eq .TypeRef "float64" }} - {{ .Target }}, err = strconv.ParseFloat(dataContent, 64) + val, err = strconv.ParseFloat({{ .Source }}, {{ sseBitSize .Encoding }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} {{- else }} - // Use user-provided decoder for complex types + // The configured decoder handles structured event data. respBody := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + Body: io.NopCloser(bytes.NewReader([]byte({{ .Source }}))), } err = s.decoder(respBody).Decode(&{{ .Target }}) if err != nil { return } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/http/codegen/templates/partial/websocket_upgrade.go.tpl b/http/codegen/templates/partial/websocket_upgrade.go.tpl index 86cbb06e87..9978bd9506 100644 --- a/http/codegen/templates/partial/websocket_upgrade.go.tpl +++ b/http/codegen/templates/partial/websocket_upgrade.go.tpl @@ -3,7 +3,7 @@ {{- if and .ViewedResult (eq .Function "Send") }} {{- if not .ViewedResult.ViewName }} respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) {{- end }} {{- end }} var conn *websocket.Conn diff --git a/http/codegen/templates/request_builder.go.tpl b/http/codegen/templates/request_builder.go.tpl index 2e5d14c768..1eb1935bd1 100644 --- a/http/codegen/templates/request_builder.go.tpl +++ b/http/codegen/templates/request_builder.go.tpl @@ -1,4 +1,4 @@ {{ comment .RequestInit.Description }} -func (c *{{ .ClientStructDeclaration.Name }}) {{ .RequestInit.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .RequestInit.Declaration.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { {{- .RequestInit.ClientCode }} } diff --git a/http/codegen/templates/request_decoder.go.tpl b/http/codegen/templates/request_decoder.go.tpl index f5b26cf3f8..7c33d13257 100644 --- a/http/codegen/templates/request_decoder.go.tpl +++ b/http/codegen/templates/request_decoder.go.tpl @@ -5,17 +5,9 @@ func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http r.Body = io.NopCloser(bytes.NewReader(req.Params)) {{- end }} var payload {{ .Payload.Ref }} -{{- if .MultipartRequestDecoder }} - if err := decoder(r).Decode(&payload); err != nil { - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) - } -{{- else if .Payload.Request.ServerBody }} +{{- if .Payload.Request.ServerBody }} var ( - body {{ .Payload.Request.ServerBody.VarName }} + body {{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }} err error ) err = decoder(r).Decode(&body) @@ -38,14 +30,18 @@ func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http } {{- end }} } - {{- if .Payload.Request.ServerBody.ValidateRef }} + {{- if and .Payload.Request.ServerBody.ValidatorDeclaration .Payload.Request.ServerBody.ValidationTarget }} + err = {{ .Payload.Request.ServerBody.ValidatorDeclaration.Name }}({{ .Payload.Request.ServerBody.ValidationTarget }}) + if err != nil { + return payload, err + } + {{- else if .Payload.Request.ServerBody.ValidateRef }} {{ .Payload.Request.ServerBody.ValidateRef }} if err != nil { return payload, err } {{- end }} {{- end }} -{{- if not .MultipartRequestDecoder }} {{- template "partial_request_elements" .Payload.Request }} {{- if .Payload.Request.MustValidate }} if err != nil { @@ -53,13 +49,12 @@ func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http } {{- end }} {{- if .Payload.Request.PayloadInit }} - payload = {{ .Payload.Request.PayloadInit.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) + payload = {{ .Payload.Request.PayloadInit.Declaration.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else if .Payload.DecoderReturnValue }} payload = {{ .Payload.DecoderReturnValue }} {{- else }} payload = body {{- end }} -{{- end }} {{- if .BasicScheme }}{{ with .BasicScheme }} user, pass, {{ if or .UsernameRequired .PasswordRequired }}ok{{ else }}_{{ end }} := r.BasicAuth() {{- if or .UsernameRequired .PasswordRequired}} diff --git a/http/codegen/templates/request_encoder.go.tpl b/http/codegen/templates/request_encoder.go.tpl index 38c8e18005..5afc55ca32 100644 --- a/http/codegen/templates/request_encoder.go.tpl +++ b/http/codegen/templates/request_encoder.go.tpl @@ -126,13 +126,11 @@ func {{ .RequestEncoderDeclaration.Name }}(encoder func(*http.Request) goahttp.E {{- if .FieldPointer }} if p.{{ .FieldName }} != nil { {{- end }} - values.Add("{{ .HTTPName }}", - {{- if or (eq .Type.Name "bytes") (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", + {{- $target := printf "p.%s" .FieldName }} + {{- if .FieldPointer }} + {{- $target = printf "*p.%s" .FieldName }} {{- end }} - {{- if .FieldPointer }}*{{ end }}p.{{ .FieldName }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }}) - {{- end }}) + values.Add("{{ .HTTPName }}", {{ template "partial_client_type_expression" (typeConversionData .Type .FieldType "" $target) }}) {{- if .FieldPointer }} } {{- end }} @@ -156,7 +154,7 @@ func {{ .RequestEncoderDeclaration.Name }}(encoder func(*http.Request) goahttp.E } {{- else if .Payload.Request.ClientBody }} {{- if .Payload.Request.ClientBody.Init }} - {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) + {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Declaration.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) {{- else }} {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := p{{ if .Payload.Request.PayloadAttr }}.{{ .Payload.Request.PayloadAttr }}{{ end }} {{- end }} diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index eb38281b67..edb3f5ff2c 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -9,19 +9,25 @@ // - error: internal error {{- end }} func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) {{ if .Method.SkipResponseBodyEncodeDecode }}(any, error){{ else }}(result any, decodeErr error){{ end }} { + {{- if not .Method.SkipResponseBodyEncodeDecode }} + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() - } - {{- if not .Method.SkipResponseBodyEncodeDecode }} else { - defer resp.Body.Close() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } {{- end }} switch resp.StatusCode { @@ -30,7 +36,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + p := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -48,7 +54,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- end }} res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) {{- else }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} @@ -79,7 +85,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- with .Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -88,14 +94,17 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" $.ServiceName }}, {{ printf "%q" $.Method.Name }}, resp.StatusCode, string(body)) } {{- else }} {{- with (index .Errors 0).Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -105,7 +114,10 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) } } diff --git a/http/codegen/templates/server_body_init.go.tpl b/http/codegen/templates/server_body_init.go.tpl index ee77042eb9..11c44ccb2d 100644 --- a/http/codegen/templates/server_body_init.go.tpl +++ b/http/codegen/templates/server_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ServerCode }} return body } diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl index 2870e32115..69c71dc3e9 100644 --- a/http/codegen/templates/server_configure.go.tpl +++ b/http/codegen/templates/server_configure.go.tpl @@ -13,7 +13,7 @@ ) { eh := errorHandler(ctx) - {{- if or (needDialer .Services) (needDialer .JSONRPCServices) }} + {{- if needDialer .Services }} upgrader := &websocket.Upgrader{} {{- end }} {{- range $svc := .Services }} @@ -23,12 +23,11 @@ {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) {{- end }} {{- end }} - {{- range $svcData := .JSONRPCServices }} - {{- if .Endpoints }} - {{- $svc := . }} - {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) - {{- end }} - {{- end }} + {{- range .JSONRPCServices }} + {{- if .Endpoints }} + {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh) + {{- end }} + {{- end }} } // Configure the mux. diff --git a/http/codegen/templates/server_handler.go.tpl b/http/codegen/templates/server_handler.go.tpl index d2746c609e..9091126752 100644 --- a/http/codegen/templates/server_handler.go.tpl +++ b/http/codegen/templates/server_handler.go.tpl @@ -1,5 +1,8 @@ {{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandlerDeclaration.Name .ServiceName .Method.Name | comment }} func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} f, ok := h.(http.HandlerFunc) if !ok { f = func(w http.ResponseWriter, r *http.Request) { diff --git a/http/codegen/templates/server_handler_init.go.tpl b/http/codegen/templates/server_handler_init.go.tpl index 45eb428e58..c2f8d0dbf2 100644 --- a/http/codegen/templates/server_handler_init.go.tpl +++ b/http/codegen/templates/server_handler_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s creates a HTTP handler which loads the HTTP request and calls the %q service %q endpoint." .HandlerInitDeclaration.Name .ServiceName .Method.Name | comment }} -func {{ .HandlerInitDeclaration.Name }}( +{{ printf "%s creates a HTTP handler which loads the HTTP request and calls the %q service %q endpoint." .HandlerInit .ServiceName .Method.Name | comment }} +func {{ .HandlerInit }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, @@ -183,7 +183,7 @@ func {{ .HandlerInitDeclaration.Name }}( var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .ServerWebSocket.VarName }}{ + Stream: &{{ .ServerWebSocket.VarDeclaration.Name }}{ upgrader: upgrader, configurer: configurer, cancel: cancel, @@ -232,11 +232,11 @@ func {{ .HandlerInitDeclaration.Name }}( {{- if not .Redirect }} if err != nil { {{- if isWebSocketEndpoint . }} - var stream *{{ .ServerWebSocket.VarName }} + var stream *{{ .ServerWebSocket.VarDeclaration.Name }} if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { - stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarName }}) + stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarDeclaration.Name }}) } else { - stream = v.Stream.(*{{ .ServerWebSocket.VarName }}) + stream = v.Stream.(*{{ .ServerWebSocket.VarDeclaration.Name }}) } if stream != nil && stream.conn != nil { // Response writer has been hijacked, do not encode the error diff --git a/http/codegen/templates/server_init.go.tpl b/http/codegen/templates/server_init.go.tpl index 1f21868771..894a3b903a 100644 --- a/http/codegen/templates/server_init.go.tpl +++ b/http/codegen/templates/server_init.go.tpl @@ -1,6 +1,6 @@ {{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInitDeclaration.Name .Service.Name | comment }} func {{ .ServerInitDeclaration.Name }}( - e *{{ .Service.PkgName }}.Endpoints, + e *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, @@ -47,9 +47,14 @@ func {{ .ServerInitDeclaration.Name }}( {"Serve {{ $filepath }}", "GET", "{{ . }}"}, {{- end }} {{- end }} + {{- range .ServerMounts }} + {{- range .MountPoints }} + { {{ printf "%q" .Method }}, {{ printf "%q" .Verb }}, {{ printf "%q" .Pattern }} }, + {{- end }} + {{- end }} }, {{- range .Endpoints }} - {{ .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitDeclaration.Name }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), + {{ .Method.VarName }}: {{ .HandlerInit }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), {{- end }} {{- range .FileServers }} {{ .VarName }}: http.FileServer({{ .ArgName }}), diff --git a/http/codegen/templates/server_method_names.go.tpl b/http/codegen/templates/server_method_names.go.tpl index c652d7fcd8..d6a7ddc2aa 100644 --- a/http/codegen/templates/server_method_names.go.tpl +++ b/http/codegen/templates/server_method_names.go.tpl @@ -1,2 +1,2 @@ {{ printf "MethodNames returns the methods served." | comment }} -func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.MethodNames[:] } +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/http/codegen/templates/server_mount.go.tpl b/http/codegen/templates/server_mount.go.tpl index 033530e4a6..fb7ab6edd5 100644 --- a/http/codegen/templates/server_mount.go.tpl +++ b/http/codegen/templates/server_mount.go.tpl @@ -1,7 +1,7 @@ {{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- range .Endpoints }} - {{ .MountHandlerDeclaration.Name }}(mux, h.{{ .Method.VarName }}) + {{ if .MountHandlerDeclaration }}{{ .MountHandlerDeclaration.Name }}{{ else }}{{ .MountHandler }}{{ end }}(mux, h.{{ .Method.VarName }}) {{- end }} {{- range .FileServers }} {{- if .Redirect }} @@ -18,13 +18,16 @@ func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDe {{- $stripped = (dir $stripped) }} {{- end }} {{- if eq $stripped "/" }} - {{ $mountHandler }}(mux, h.{{ $varName }}) + {{ $mountHandler }}(mux, h.{{ $varName }}) {{- else }} {{ $mountHandler }}(mux, http.StripPrefix("{{ $stripped }}", h.{{ $varName }})) {{- end }} {{- end }} {{- end }} {{- end }} + {{- range .ServerMounts }} + {{ .Declaration.Name }}(mux) + {{- end }} } {{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl index 61e4a695a0..98a8bead95 100644 --- a/http/codegen/templates/server_sse.go.tpl +++ b/http/codegen/templates/server_sse.go.tpl @@ -1,3 +1,7 @@ +{{/* +server_sse.go.tpl writes the HTTP server stream for one SSE endpoint. The plan +provides the exact response value and selected view used for each event. +*/ -}} {{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructDeclaration.Name .SSE.Interface | comment }} type {{ .SSE.StructDeclaration.Name }} struct { {{ comment "once ensures the headers are written once." }} @@ -59,7 +63,9 @@ func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx c {{- end }} var data string - var payload any + {{- if .SSE.Data.Pointer }} + hasData := true + {{- end }} {{- if .SSE.HasResponseBody }} {{- if .Method.ViewedResult }} {{- if .SSE.VariableView }} @@ -76,67 +82,31 @@ func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx c {{- end }} {{- else }} {{- if (index .SSE.Response.ServerBody 0).Init }} - body := {{ (index .SSE.Response.ServerBody 0).Init.Name }}({{ range (index .SSE.Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .SSE.Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else }} body := res {{- end }} {{- if .SSE.DataField }} - payload = body.{{ .SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" .SSE.DataField) "Encoding" .SSE.Data }} {{- else }} - payload = body + {{ template "partial_sse_format" dict "Value" "body" "Encoding" .SSE.Data }} {{- end }} {{- end }} {{- else }} {{- if .SSE.DataField }} - payload = {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.DataField }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" (printf "projected.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" (printf "res.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- end }} {{- else }} - payload = {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" "projected" "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "res" "Encoding" .SSE.Data }} + {{- end }} {{- end }} {{- end }} - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) - } {{- if .SSE.VariableView }} s.sentView = view {{- end }} @@ -175,15 +145,26 @@ func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx c {{- end }} {{- if .SSE.RetryField }} - if retry := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.RetryField }}; retry > 0 { - if _, err := fmt.Fprintf(s.w, "retry: %d\n", retry); err != nil { + if retry := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.RetryField }}; {{ if .SSE.Retry.Pointer }}retry != nil && *{{ end }}retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", {{ if .SSE.Retry.Pointer }}*{{ end }}retry); err != nil { return err } } {{- end }} + {{- if .SSE.Data.Pointer }} + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { + return err + } + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } + {{- else }} if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { return err } + {{- end }} if err := http.NewResponseController(s.w).Flush(); err != nil { return err @@ -194,11 +175,11 @@ func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx c {{- define "viewed_sse_server_body" }} {{- $endpoint := .Endpoint }} {{- with .Representation }} - body := {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- if $endpoint.SSE.DataField }} - payload = body.{{ $endpoint.SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" $endpoint.SSE.DataField) "Encoding" $endpoint.SSE.Data }} {{- else }} - payload = body + {{ template "partial_sse_format" dict "Value" "body" "Encoding" $endpoint.SSE.Data }} {{- end }} {{- end }} {{- end }} diff --git a/http/codegen/templates/server_start.go.tpl b/http/codegen/templates/server_start.go.tpl index f84053eab5..ef1a5e965f 100644 --- a/http/codegen/templates/server_start.go.tpl +++ b/http/codegen/templates/server_start.go.tpl @@ -1,3 +1,3 @@ {{ comment "handleHTTPServer starts configures and starts a HTTP server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleHTTPServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}{{ range $.JSONRPCServices }}, {{ .Service.VarName }}Svc {{ .Service.PkgName }}.Service{{- $serviceName := .Service.Name }}{{- $found := false }}{{- range $.Services }}{{- if eq .Service.Name $serviceName }}{{- $found = true }}{{- break }}{{- end }}{{- end }}{{ if not $found }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} +func handleHTTPServer(ctx context.Context, u *url.URL{{ range .HandlerArgs }}, {{ .Name }} {{ if .Pointer }}*{{ end }}{{ .PkgName }}.{{ .TypeName }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} {{ end -}} diff --git a/http/codegen/templates/server_type_init.go.tpl b/http/codegen/templates/server_type_init.go.tpl index 5c138ffa2a..bbc7ebe8c3 100644 --- a/http/codegen/templates/server_type_init.go.tpl +++ b/http/codegen/templates/server_type_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{- if .ServerCode }} {{ .ServerCode }} {{- if .ReturnTypeAttribute }} diff --git a/http/codegen/templates/type_decl.go.tpl b/http/codegen/templates/type_decl.go.tpl index d51ad9e777..9ab7fceb83 100644 --- a/http/codegen/templates/type_decl.go.tpl +++ b/http/codegen/templates/type_decl.go.tpl @@ -1,2 +1,2 @@ {{ comment .Description }} -type {{ .VarName }} {{ .Def }} +type {{ .Declaration.Name }} {{ .Def }} diff --git a/http/codegen/templates/union_type.go.tpl b/http/codegen/templates/union_type.go.tpl index 0cf73a8343..471b32328c 100644 --- a/http/codegen/templates/union_type.go.tpl +++ b/http/codegen/templates/union_type.go.tpl @@ -1,65 +1,65 @@ -{{- /* Union sum-type definition and helpers. */ -}} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} {{ .FieldName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// {{ .Constructor }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func {{ .Constructor }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, {{ .FieldName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } return u.{{ .FieldName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -67,14 +67,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -83,11 +83,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: value = u.{{ .FieldName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -99,7 +99,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -108,28 +108,28 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/http/codegen/templates/validate.go.tpl b/http/codegen/templates/validate.go.tpl index 0b635435ed..70bf1959f9 100644 --- a/http/codegen/templates/validate.go.tpl +++ b/http/codegen/templates/validate.go.tpl @@ -1,5 +1,13 @@ -{{ printf "%s runs the validations defined on %s" .ValidatorName .Name | comment }} -func {{ .ValidatorName }}(body {{ .Ref }}) (err error) { +{{ printf "%s runs the validations defined on %s" .ValidatorDeclaration.Name .Name | comment }} +func {{ .ValidatorDeclaration.Name }}(body {{ .Ref }}) (err error) { {{ .ValidateDef }} - return + return } + +{{- if .NestedValidatorDeclaration }} +{{ printf "%s checks %s and reports errors using the path supplied by its caller" .NestedValidatorDeclaration.Name .Name | comment }} +func {{ .NestedValidatorDeclaration.Name }}(body {{ .Ref }}, path string) (err error) { + {{ .NestedValidateDef }} + return +} +{{- end }} diff --git a/http/codegen/templates/websocket_recv.go.tpl b/http/codegen/templates/websocket_recv.go.tpl index 381a40647d..2298a7e285 100644 --- a/http/codegen/templates/websocket_recv.go.tpl +++ b/http/codegen/templates/websocket_recv.go.tpl @@ -4,12 +4,12 @@ func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, erro rv {{ .RecvTypeRef }} {{- if eq .Type "server" }} {{- if .RecvTypeIsPointer }} - body {{ .Payload.VarName }} + body {{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- else }} - msg *{{ .Payload.VarName }} + msg *{{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- end }} {{- else }} - body {{ .Response.ClientBody.VarName }} + body {{ if .Response.ClientBody.Declaration }}{{ .Response.ClientBody.Declaration.Name }}{{ else }}{{ .Response.ClientBody.VarName }}{{ end }} {{- end }} err error ) @@ -29,22 +29,26 @@ func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, erro {{- end }} return rv, io.EOF } - {{- if .Payload.ValidateRef }} + {{- if or (and .Payload.ValidatorDeclaration .Payload.ValidationTarget) .Payload.ValidateRef }} {{- if not .RecvTypeIsPointer }} body := *msg {{- end }} - {{ .Payload.ValidateRef }} + {{- if and .Payload.ValidatorDeclaration .Payload.ValidationTarget }} + err = {{ .Payload.ValidatorDeclaration.Name }}({{ .Payload.ValidationTarget }}) + {{- else }} + {{ .Payload.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Payload.Init }} - return {{ .Payload.Init.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil + return {{ .Payload.Init.Declaration.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil {{- else }} return {{ if .RecvTypeIsPointer }}body{{ else }}*msg{{ end }}, nil {{- end }} {{- else }} {{/* client side code */}} - {{- if eq .RecvName "CloseAndRecv" }} + {{- if isClientStreamKind .Kind }} defer s.conn.Close() {{ comment "Send a nil payload to the server implying end of message" }} if err = s.conn.WriteJSON(nil); err != nil { @@ -61,14 +65,18 @@ func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, erro if err != nil { return rv, err } - {{- if and .Response.ClientBody.ValidateRef (not .Endpoint.Method.ViewedResult) }} + {{- if and (or (and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget) .Response.ClientBody.ValidateRef) (not .Endpoint.Method.ViewedResult) }} + {{- if and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget }} + err = {{ .Response.ClientBody.ValidatorDeclaration.Name }}({{ .Response.ClientBody.ValidationTarget }}) + {{- else }} {{ .Response.ClientBody.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Response.ResultInit }} - res := {{ .Response.ResultInit.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .Response.ResultInit.Declaration.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .Endpoint.Method.ViewedResult }}{{ with .Endpoint.Method.ViewedResult }} vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: {{ if .ViewName }}{{ printf "%q" .ViewName }}{{ else }}s.view{{ end }} } if err := {{ .ViewsPkg }}.{{ .Validate.Declaration.Name }}(vres); err != nil { diff --git a/http/codegen/templates/websocket_send.go.tpl b/http/codegen/templates/websocket_send.go.tpl index c35af6ad7e..5cbf7ce92d 100644 --- a/http/codegen/templates/websocket_send.go.tpl +++ b/http/codegen/templates/websocket_send.go.tpl @@ -1,17 +1,40 @@ +{{/* +websocket_send.go.tpl writes one service result to a WebSocket. A method with +several views keeps only the caller's view choice in generated code. +*/ -}} {{ comment .SendDesc }} func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if eq .Type "server" }} - {{- if eq .SendName "Send" }} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) + } + {{- end }} + {{- if not (isClientStreamKind .Kind) }} var err error {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint .SendName) }} - {{- else }} {{/* SendAndClose */}} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + s.sentView = view + } + {{- end }} + {{- else }} defer s.conn.Close() {{- end }} {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) - {{- else }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, s.view) {{- end }} {{- else }} res := v @@ -22,21 +45,25 @@ func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} {{- $vsb := (viewedServerBody $.Response.ServerBody .Endpoint.Method.ViewedResult.ViewName) }} - body := {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else }} - var body any - switch s.view { + switch view { {{- range .Endpoint.Method.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: + res := {{ $.PkgName }}.{{ $.Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Name }}) {{- $vsb := (viewedServerBody $.Response.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + return s.conn.WriteJSON({{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }})) {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } {{- end }} {{- else }} - body := {{ (index .Response.ServerBody 0).Init.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ (index .Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} + {{- if or (not .Endpoint.Method.ViewedResult) .Endpoint.Method.ViewedResult.ViewName }} return s.conn.WriteJSON(body) + {{- end }} {{- else }} return s.conn.WriteJSON(res) {{- end }} @@ -45,7 +72,7 @@ func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error {{- end }} {{- else }} {{- if .Payload.Init }} - body := {{ .Payload.Init.Name }}(v) + body := {{ .Payload.Init.Declaration.Name }}(v) return s.conn.WriteJSON(body) {{- else }} return s.conn.WriteJSON(v) diff --git a/http/codegen/templates/websocket_struct_type.go.tpl b/http/codegen/templates/websocket_struct_type.go.tpl index e3c56bea37..8148a8ab2b 100644 --- a/http/codegen/templates/websocket_struct_type.go.tpl +++ b/http/codegen/templates/websocket_struct_type.go.tpl @@ -21,6 +21,10 @@ type {{ .VarDeclaration.Name }} struct { {{- if not .Endpoint.Method.ViewedResult.ViewName }} {{ printf "view is the view to render %s result type before sending to the websocket connection." .SendTypeName | comment }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named during the WebSocket upgrade. Later sends must use the same view." }} + sentView string + {{- end }} {{- end }} {{- end }} } diff --git a/http/codegen/testdata/error_response_dsls.go b/http/codegen/testdata/error_response_dsls.go index 1a24b6353c..b7ffd59f2a 100644 --- a/http/codegen/testdata/error_response_dsls.go +++ b/http/codegen/testdata/error_response_dsls.go @@ -234,6 +234,19 @@ var ErrorExamplesDSL = func() { var _ = Service("Errors", func() { Method("Error", func() { Error("not_found") // default example + Error("retry", func() { + Temporary() + }) + Error("deadline", func() { + Timeout() + }) + Error("retry_deadline", func() { + Temporary() + Timeout() + }) + Error("internal", func() { + Fault() + }) Error("bad_request", func() { Example("BadRequest example", func() { Value(Val{ @@ -250,6 +263,10 @@ var ErrorExamplesDSL = func() { HTTP(func() { GET("/") Response("not_found", StatusNotFound) + Response("retry", StatusTooManyRequests) + Response("deadline", StatusGatewayTimeout) + Response("retry_deadline", StatusServiceUnavailable) + Response("internal", StatusInternalServerError) Response("bad_request", StatusBadRequest) Response("custom", StatusConflict) }) diff --git a/http/codegen/testdata/golden/client-mixed-results.golden b/http/codegen/testdata/golden/client-mixed-results.golden new file mode 100644 index 0000000000..6276078067 --- /dev/null +++ b/http/codegen/testdata/golden/client-mixed-results.golden @@ -0,0 +1,36 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + endpoint, payload, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "mixed-results-service": + switch flag.Arg(1) { + case "create": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-no-server.golden b/http/codegen/testdata/golden/client-no-server.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-no-server.golden +++ b/http/codegen/testdata/golden/client-no-server.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden index 91381eb4ed..a820b824e2 100644 --- a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden +++ b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden +++ b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming-input-only.golden b/http/codegen/testdata/golden/client-streaming-input-only.golden new file mode 100644 index 0000000000..2e37ed9912 --- /dev/null +++ b/http/codegen/testdata/golden/client-streaming-input-only.golden @@ -0,0 +1,45 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + var ( + dialer *websocket.Dialer + ) + { + dialer = websocket.DefaultDialer + } + + switch flag.Arg(0) { + case "streaming-payload-service": + switch flag.Arg(1) { + case "streaming-payload-method": + return errors.New("example client does not support streamed input for service \"StreamingPayloadService\" method \"StreamingPayloadMethod\"") + } + } + _, _, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + dialer, + nil, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-streaming-multiple-services.golden b/http/codegen/testdata/golden/client-streaming-multiple-services.golden index 3d8ce10318..ba65d0dc66 100644 --- a/http/codegen/testdata/golden/client-streaming-multiple-services.golden +++ b/http/codegen/testdata/golden/client-streaming-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -16,6 +16,13 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er dialer = websocket.DefaultDialer } + switch flag.Arg(0) { + case "streaming-service-b": + switch flag.Arg(1) { + case "method": + return errors.New("example client does not support streamed input for service \"StreamingServiceB\" method \"Method\"") + } + } endpoint, payload, err := cli.ParseEndpoint( scheme, host, @@ -28,13 +35,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-service-a": + switch flag.Arg(1) { + case "method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingservicea.MethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming.golden b/http/codegen/testdata/golden/client-streaming.golden index fbd2b88761..995491ae9c 100644 --- a/http/codegen/testdata/golden/client-streaming.golden +++ b/http/codegen/testdata/golden/client-streaming.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -27,13 +27,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-result-service": + switch flag.Arg(1) { + case "streaming-result-method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingresultservice.StreamingResultMethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden index c63113e2cd..8890c273fc 100644 --- a/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_decl_body-user-inner.go.golden @@ -1,5 +1,5 @@ // MethodBodyUserInnerRequestBody is the type of the "ServiceBodyUserInner" // service "MethodBodyUserInner" endpoint HTTP request body. type MethodBodyUserInnerRequestBody struct { - Inner *InnerType `form:"inner,omitempty" json:"inner,omitempty" xml:"inner,omitempty"` + Inner *InnerTypeRequestBody `form:"inner,omitempty" json:"inner,omitempty" xml:"inner,omitempty"` } diff --git a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden index 814976049f..e2082919b0 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-primitive-array-user-validate.go.golden @@ -1,14 +1,14 @@ -// NewPayloadType builds the HTTP request body from the payload of the -// "MethodBodyPrimitiveArrayUserValidate" endpoint of the +// NewPayloadTypeRequestBody builds the HTTP request body from the payload of +// the "MethodBodyPrimitiveArrayUserValidate" endpoint of the // "ServiceBodyPrimitiveArrayUserValidate" service. -func NewPayloadType(p []*servicebodyprimitivearrayuservalidate.PayloadType) []*PayloadType { - body := make([]*PayloadType, len(p)) +func NewPayloadTypeRequestBody(p []*servicebodyprimitivearrayuservalidate.PayloadType) []*PayloadTypeRequestBody { + body := make([]*PayloadTypeRequestBody, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalPayloadTypeToPayloadType2(val) + body[i] = marshalServicebodyprimitivearrayuservalidatePayloadTypeToPayloadTypeRequestBody(val) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden index a56ea8ecb6..0e36b41475 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-streaming-aliased-array.go.golden @@ -3,9 +3,9 @@ func NewStreamStreamingBody(p *streamingaliasedarray.PayloadType) *StreamStreamingBody { body := &StreamStreamingBody{} if p.Values != nil { - body.Values = make([]CustomInt, len(p.Values)) + body.Values = make([]CustomIntStreamingBody, len(p.Values)) for i, val := range p.Values { - body.Values[i] = CustomInt(val) + body.Values[i] = CustomIntStreamingBody(val) } } return body diff --git a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden index d93aa3588b..5d2a771238 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerRequestBody(p *servicebodyuserinner.PayloadType) *MethodBodyUserInnerRequestBody { body := &MethodBodyUserInnerRequestBody{} if p.Inner != nil { - body.Inner = marshalInnerTypeToInnerType2(p.Inner) + body.Inner = marshalServicebodyuserinnerInnerTypeToInnerTypeRequestBodyOptional(p.Inner) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden index 366208ffc0..2f19bff0c4 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-body-inline-object.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyInlineObjectResultOK builds a "ServiceBodyInlineObject" service -// "MethodBodyInlineObject" endpoint result from a HTTP "OK" response. -func NewMethodBodyInlineObjectResultOK(body *MethodBodyInlineObjectResponseBody) *servicebodyinlineobject.ResultType { +// NewMethodBodyInlineObjectResultTypeOK builds a "ServiceBodyInlineObject" +// service "MethodBodyInlineObject" endpoint result from a HTTP "OK" response. +func NewMethodBodyInlineObjectResultTypeOK(body *MethodBodyInlineObjectResponseBody) *servicebodyinlineobject.ResultType { v := &servicebodyinlineobject.ResultType{} if body.Parent != nil { v.Parent = &struct { diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden index 803240fb6e..8751c3da3f 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden @@ -1,11 +1,11 @@ -// NewMethodExplicitBodyUserResultObjectMultipleViewResultOK builds a -// "ServiceExplicitBodyUserResultObjectMultipleView" service +// NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK +// builds a "ServiceExplicitBodyUserResultObjectMultipleView" service // "MethodExplicitBodyUserResultObjectMultipleView" endpoint result from a HTTP // "OK" response. -func NewMethodExplicitBodyUserResultObjectMultipleViewResultOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView{} if body.A != nil { - v.A = unmarshalUserTypeToUserTypeView(body.A) + v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeViewOptional(body.A) } v.C = c diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden index 2bce5ff48d..163571450e 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden @@ -1,11 +1,11 @@ -// NewMethodExplicitBodyUserResultObjectResultOK builds a +// NewMethodExplicitBodyUserResultObjectResulttypeOK builds a // "ServiceExplicitBodyUserResultObject" service // "MethodExplicitBodyUserResultObject" endpoint result from a HTTP "OK" // response. -func NewMethodExplicitBodyUserResultObjectResultOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { +func NewMethodExplicitBodyUserResultObjectResulttypeOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { v := &serviceexplicitbodyuserresultobjectviews.ResulttypeView{} if body.A != nil { - v.A = unmarshalUserTypeToUserTypeView(body.A) + v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeViewOptional(body.A) } v.C = c v.B = b diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden index 516e7dfaf9..8f3200dd01 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive.go.golden @@ -1,8 +1,8 @@ -// NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK builds a -// "ServiceExplicitBodyPrimitiveResultMultipleView" service +// NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK +// builds a "ServiceExplicitBodyPrimitiveResultMultipleView" service // "MethodExplicitBodyPrimitiveResultMultipleView" endpoint result from a HTTP // "OK" response. -func NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK(body string, c *string) *serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body string, c *string) *serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView { v := body res := &serviceexplicitbodyprimitiveresultmultipleviewviews.ResulttypemultipleviewsView{ A: &v, diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden index 9640e1c234..e363d520b2 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-user-type.go.golden @@ -1,8 +1,8 @@ -// NewMethodExplicitBodyUserResultMultipleViewResultOK builds a -// "ServiceExplicitBodyUserResultMultipleView" service +// NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK builds +// a "ServiceExplicitBodyUserResultMultipleView" service // "MethodExplicitBodyUserResultMultipleView" endpoint result from a HTTP "OK" // response. -func NewMethodExplicitBodyUserResultMultipleViewResultOK(body *MethodExplicitBodyUserResultMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultmultipleviewviews.ResulttypemultipleviewsView { +func NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultmultipleviewviews.UserTypeView{ X: body.X, Y: body.Y, diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index e3a4e99065..1279db874d 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -28,7 +28,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s } v := &servicemulti.MethodMultiPayloadPayload{} if body.C != nil { - v.C = marshalUserTypeToUserType2(body.C) + v.C = marshalUserTypeRequestBodyToServicemultiUserTypeOptional(body.C) } v.B = b v.A = a diff --git a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden index bcb2801b73..5ee5c870fb 100644 --- a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden @@ -12,8 +12,8 @@ func BuildMethodParamValidatePayload(serviceParamValidateMethodParamValidateA st if err != nil { return nil, fmt.Errorf("invalid value for a, must be INT") } - if *a < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("a", *a, 1, true)) + if val < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("a", val, 1, true)) } if err != nil { return nil, err diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index f5f9ab4a99..358794350b 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -2,7 +2,7 @@ // ServiceBodyInlineArrayUser MethodBodyInlineArrayUser endpoint from CLI flags. func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody string) ([]*servicebodyinlinearrayuser.ElemType, error) { var err error - var body []*ElemType + var body []*ElemTypeRequestBody { err = json.Unmarshal([]byte(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) if err != nil { @@ -15,7 +15,7 @@ func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyI v[i] = nil continue } - v[i] = marshalElemTypeToElemType(val) + v[i] = marshalElemTypeRequestBodyToServicebodyinlinearrayuserElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index 94985f1d5f..8835c80b21 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -2,7 +2,7 @@ // ServiceBodyInlineMapUser MethodBodyInlineMapUser endpoint from CLI flags. func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlineMapUserBody string) (map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, error) { var err error - var body map[*KeyType]*ElemType + var body map[*KeyTypeRequestBody]*ElemTypeRequestBody { err = json.Unmarshal([]byte(serviceBodyInlineMapUserMethodBodyInlineMapUserBody), &body) if err != nil { @@ -11,12 +11,12 @@ func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlin } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeToKeyType(key) + tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue } - v[tk] = marshalElemTypeToElemType(val) + v[tk] = marshalElemTypeRequestBodyToServicebodyinlinemapuserElemType(val) } return v, nil } diff --git a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden index 62aef60e85..9fb3ced583 100644 --- a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -33,7 +39,7 @@ func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.D if cRaw != "" { c = &cRaw } - p := NewMethodBodyMultipleViewResultOK(&body, c) + p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) view := resp.Header.Get("goa-view") vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { @@ -42,7 +48,10 @@ func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.D res := servicebodymultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden index 93973f6f9b..d4da39b9e5 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -25,13 +31,16 @@ func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response if cRaw != "" { c = &cRaw } - p := NewMethodEmptyBodyResultMultipleViewResultOK(c) + p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) view := resp.Header.Get("goa-view") vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-body.go.golden index 76693be53d..de696c4d18 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body.go.golden @@ -3,25 +3,34 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: res := NewMethodEmptyServerResponseResultOK() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden index e81fe10765..45abf451f5 100644 --- a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden @@ -7,18 +7,24 @@ // - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound // - error: internal error func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -100,7 +106,10 @@ func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goa } return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden index 486e080587..529e9df15a 100644 --- a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden @@ -3,18 +3,24 @@ // MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err)) + } + }() } switch resp.StatusCode { case http.StatusNotModified: @@ -25,7 +31,10 @@ func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response res := NewMethodEmptyServerResponseWithTagsResultNoContent() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden index b0167005cb..b67099477a 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden @@ -4,18 +4,24 @@ // MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -40,7 +46,7 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h if cRaw != "" { c = &cRaw } - p := NewMethodExplicitBodyPrimitiveResultMultipleViewResultOK(body, c) + p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) view := resp.Header.Get("goa-view") vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { @@ -49,7 +55,10 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden index 794f6fa6ba..3b33387e82 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden @@ -3,37 +3,46 @@ // MethodExplicitBodyResultCollection endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: var ( - body ResulttypeCollection + body ResulttypeResponseCollection err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } - err = ValidateResulttypeCollection(body) + err = ValidateResulttypeResponseCollection(body) if err != nil { return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } res := NewMethodExplicitBodyResultCollectionResultOK(body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden index 1c54168ba2..30b67796bf 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -33,7 +39,7 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R if cRaw != "" { c = &cRaw } - p := NewMethodExplicitBodyUserResultMultipleViewResultOK(&body, c) + p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) view := resp.Header.Get("goa-view") vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { @@ -42,7 +48,10 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden index be64f05f20..4b9d930c32 100644 --- a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -46,7 +52,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array.go.golden b/http/codegen/testdata/golden/client_decode_header-array.go.golden index b10174b8a2..d6c05ba6b8 100644 --- a/http/codegen/testdata/golden/client_decode_header-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -41,7 +47,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden index a6ff6af677..918677d89e 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody // controls whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden index 94a679e52c..5cd520ddd8 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -25,7 +31,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden index 2d2edbac47..505ac4c77a 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden @@ -3,18 +3,24 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goaht } return h, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden new file mode 100644 index 0000000000..b341d3d2ef --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden @@ -0,0 +1,48 @@ +// DecodeStoreResponse returns a decoder for responses returned by the +// RequiredArrays Store endpoint. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeStoreResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("RequiredArrays", "Store", err)) + } + }() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body StoreResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + err = ValidateStoreResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("RequiredArrays", "Store", err) + } + res := NewStoreResultOK(&body) + return res, nil + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + return nil, goahttp.ErrInvalidResponse("RequiredArrays", "Store", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden new file mode 100644 index 0000000000..4fc444bb66 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden @@ -0,0 +1,36 @@ +// DecodeMethodSkipResponseBodyEncodeDecodeResponse returns a decoder for +// responses returned by the ServiceSkipResponseBodyEncodeDecode +// MethodSkipResponseBodyEncodeDecode endpoint. restoreBody controls whether +// the response body should be restored after having been read. +// DecodeMethodSkipResponseBodyEncodeDecodeResponse may return the following +// errors: +// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError +// - error: internal error +func DecodeMethodSkipResponseBodyEncodeDecodeResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + switch resp.StatusCode { + case http.StatusOK: + return nil, nil + case http.StatusInternalServerError: + var ( + body MethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + err = ValidateMethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, NewMethodSkipResponseBodyEncodeDecodeInternalError(&body) + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, goahttp.ErrInvalidResponse("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden index 8f9004cb2a..4a5c17785f 100644 --- a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err)) + } + }() } switch resp.StatusCode { case http.StatusAccepted: @@ -33,7 +39,7 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D if cRaw != "" { c = &cRaw } - p := NewMethodTagMultipleViewsResultAccepted(&body, c) + p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) tmp := "value" p.B = &tmp view := resp.Header.Get("goa-view") @@ -52,7 +58,7 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D if err != nil { return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } - p := NewMethodTagMultipleViewsResultOK(&body) + p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) view := resp.Header.Get("goa-view") vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { @@ -61,7 +67,10 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D res := servicetagmultipleviews.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden index 6bcd622ff0..af4ac706d8 100644 --- a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden +++ b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden @@ -5,18 +5,24 @@ // - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest // - error: internal error func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -75,7 +81,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore } return nil, NewMethodASomeError(error_, numOccur) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden index 5458c34908..e472224457 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden @@ -2,18 +2,24 @@ // ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -65,7 +71,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := servicewithheadersblockviewedresult.NewAResult(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden index c80495a8d8..5fbe40cfd4 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden @@ -2,18 +2,24 @@ // ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -62,7 +68,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(required, optional, optionalButRequired) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden index 0b24c0dae6..1f2a6479f9 100644 --- a/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_body-primitive-array-user-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodBodyPrimitiveArrayUserValidateRequest(encoder func(*http.Reques if !ok { return goahttp.ErrInvalidType("ServiceBodyPrimitiveArrayUserValidate", "MethodBodyPrimitiveArrayUserValidate", "[]*servicebodyprimitivearrayuservalidate.PayloadType", v) } - body := NewPayloadType(p) + body := NewPayloadTypeRequestBody(p) if err := encoder(req).Encode(&body); err != nil { return goahttp.ErrEncodingError("ServiceBodyPrimitiveArrayUserValidate", "MethodBodyPrimitiveArrayUserValidate", err) } diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden index bc8331e979..55f4313620 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat32ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden index 6589621437..c4c139da56 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat32Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden index b933344476..58d87c3625 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat64ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden index 7a44c90fc7..611043286d 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat64Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden index 263eee9ae4..5797d576e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for _, value := range p.Array { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 64) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 64) values.Add("array", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden index 6d1cc0ac23..c5195f8124 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryBoolValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryBoolValidate", "MethodQueryBoolValidate", "*servicequeryboolvalidate.MethodQueryBoolValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatBool(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-bool.go.golden b/http/codegen/testdata/golden/client_encode_query-bool.go.golden index cdc6afae49..00936be1cd 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryBoolRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatBool(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden index 72fc208076..3f4ad6f02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat32ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat32Validate", "MethodQueryFloat32Validate", "*servicequeryfloat32validate.MethodQueryFloat32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(float64(p.Q), 'g', -1, 32)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-float32.go.golden index f75be80096..e7e1060fe7 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat32Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(float64(*p.Q), 'g', -1, 32)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden index 40c55bf7cc..ae96242174 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat64ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat64Validate", "MethodQueryFloat64Validate", "*servicequeryfloat64validate.MethodQueryFloat64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(p.Q, 'g', -1, 64)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-float64.go.golden index 7949ba85ee..532b403c57 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat64Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(*p.Q, 'g', -1, 64)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden index a06d7c3a09..40e75667d2 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden index e4033a47a7..84ef7aa3a5 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden index 1d51b83c44..9b07ffec50 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryIntValidateRequest(encoder func(*http.Request) goahttp.Enc return goahttp.ErrInvalidType("ServiceQueryIntValidate", "MethodQueryIntValidate", "*servicequeryintvalidate.MethodQueryIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.Itoa(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int.go.golden b/http/codegen/testdata/golden/client_encode_query-int.go.golden index cf129af0b3..66ad1cd02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-int.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryIntRequest(encoder func(*http.Request) goahttp.Encoder) fu } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.Itoa(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden index 6d1ee581d2..8e4e61624c 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt32ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt32Validate", "MethodQueryInt32Validate", "*servicequeryint32validate.MethodQueryInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(int64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int32.go.golden b/http/codegen/testdata/golden/client_encode_query-int32.go.golden index 2da13e32cf..cda9da2969 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(int64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden index 9409b6b703..5b366b0879 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt64ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt64Validate", "MethodQueryInt64Validate", "*servicequeryint64validate.MethodQueryInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int64.go.golden b/http/codegen/testdata/golden/client_encode_query-int64.go.golden index 98bc713141..24e74a0c4e 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden index 1bfa849733..19fe87a03c 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden index 0bae2e60fd..d40207f329 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden index 3784cc4fcc..603b7ff12e 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUIntValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryUIntValidate", "MethodQueryUIntValidate", "*servicequeryuintvalidate.MethodQueryUIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint.go.golden b/http/codegen/testdata/golden/client_encode_query-uint.go.golden index 5541e09192..00f05680e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUIntRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden index 8d749033d1..d2edbd9dac 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt32ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt32Validate", "MethodQueryUInt32Validate", "*servicequeryuint32validate.MethodQueryUInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden index b97ad4710f..17710b93ee 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden index 42975304c4..0622c17f8f 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt64ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt64Validate", "MethodQueryUInt64Validate", "*servicequeryuint64validate.MethodQueryUInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden index 0aaa4166d5..fb2f305cbd 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden new file mode 100644 index 0000000000..754d23d39d --- /dev/null +++ b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden @@ -0,0 +1,16 @@ +// EncodeUploadRequest returns an encoder for requests sent to the +// SkipRequestBodyEncodeDecodeHeader Upload server. +func EncodeUploadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + data, ok := v.(*skiprequestbodyencodedecodeheader.UploadRequestData) + if !ok { + return goahttp.ErrInvalidType("SkipRequestBodyEncodeDecodeHeader", "Upload", "*skiprequestbodyencodedecodeheader.UploadRequestData", v) + } + p := data.Payload + if p.ContentType != nil { + head := *p.ContentType + req.Header.Set("Content-Type", head) + } + return nil + } +} diff --git a/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden new file mode 100644 index 0000000000..040dc57a19 --- /dev/null +++ b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden @@ -0,0 +1,79 @@ +// Read returns an endpoint that makes HTTP requests to the body_lifecycle +// service read server. +func (c *Client) Read() goa.Endpoint { + var ( + decodeResponse = DecodeReadResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildReadRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.ReadDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "read", err) + } + return decodeResponse(resp) + } +} + +// Raw returns an endpoint that makes HTTP requests to the body_lifecycle +// service raw server. +func (c *Client) Raw() goa.Endpoint { + var ( + decodeResponse = DecodeRawResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildRawRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.RawDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "raw", err) + } + _, err = decodeResponse(resp) + if err != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("body_lifecycle", "raw", closeErr)) + } + return nil, err + } + return &bodylifecycle.RawResponseData{Body: resp.Body}, nil + } +} + +// Watch returns an endpoint that makes HTTP requests to the body_lifecycle +// service watch server. +func (c *Client) Watch() goa.Endpoint { + var ( + decodeResponse = DecodeWatchResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildWatchRequest(ctx, v) + if err != nil { + return nil, err + } + // For SSE endpoints, connect and return a stream + resp, err := c.WatchDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "watch", err) + } + + if resp.StatusCode != http.StatusOK { + // Decode designed errors (the decoder closes the response body). + return decodeResponse(resp) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("body_lifecycle", "watch", err)) + } + return nil, contentTypeErr + } + + return NewWatchStream(resp, c.decoder), nil + } +} diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden index 6929073169..0160eb2162 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden @@ -12,7 +12,7 @@ type ListResponseBody struct { } // ListSomethingWentWrongResponseBody is the type of the "ServiceA" service -// "list" endpoint HTTP response body. +// "list" endpoint HTTP response body for the "something_went_wrong" error. type ListSomethingWentWrongResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` @@ -52,7 +52,7 @@ func NewListResultOK(body *ListResponseBody) *servicea.ListResult { // NewListSomethingWentWrong builds a ServiceA service list endpoint // something_went_wrong error. func NewListSomethingWentWrong(body *ListSomethingWentWrongResponseBody) *goa.ServiceError { - v := &servicea.Error{ + v := &goa.ServiceError{ Name: *body.Name, ID: *body.ID, Message: *body.Message, diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden index 0b6a9257d5..66d816b2f2 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden @@ -12,7 +12,7 @@ type ListResponseBody struct { } // ListSomethingWentWrongResponseBody is the type of the "ServiceB" service -// "list" endpoint HTTP response body. +// "list" endpoint HTTP response body for the "something_went_wrong" error. type ListSomethingWentWrongResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` @@ -52,7 +52,7 @@ func NewListResultOK(body *ListResponseBody) *serviceb.ListResult { // NewListSomethingWentWrong builds a ServiceB service list endpoint // something_went_wrong error. func NewListSomethingWentWrong(body *ListSomethingWentWrongResponseBody) *goa.ServiceError { - v := &serviceb.Error{ + v := &goa.ServiceError{ Name: *body.Name, ID: *body.ID, Message: *body.Message, diff --git a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden index d931fcb331..5cadde07fb 100644 --- a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden @@ -1,15 +1,15 @@ // MethodARequestBody is the type of the "ServiceMixedPayloadInBody" service // "MethodA" endpoint HTTP request body. type MethodARequestBody struct { - Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` - Array []float32 `form:"array" json:"array" xml:"array"` - Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` - Object *BPayload `form:"object" json:"object" xml:"object"` - DupObj *BPayload `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` + Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` + Array []float32 `form:"array" json:"array" xml:"array"` + Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` + Object *BPayloadRequestBody `form:"object" json:"object" xml:"object"` + DupObj *BPayloadRequestBody `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` } -// BPayload is used to define fields on request body types. -type BPayload struct { +// BPayloadRequestBody is used to define fields on request body types. +type BPayloadRequestBody struct { Int int `form:"int" json:"int" xml:"int"` Bytes []byte `form:"bytes,omitempty" json:"bytes,omitempty" xml:"bytes,omitempty"` } @@ -37,10 +37,10 @@ func NewMethodARequestBody(p *servicemixedpayloadinbody.APayload) *MethodAReques } } if p.Object != nil { - body.Object = marshalBPayloadToBPayload(p.Object) + body.Object = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBody(p.Object) } if p.DupObj != nil { - body.DupObj = marshalBPayloadToBPayload2(p.DupObj) + body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBodyOptional(p.DupObj) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden index bcd85d7029..1b8a0d841a 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden @@ -1,52 +1,52 @@ -// PayloadA is used to define fields on request body types. -type PayloadA struct { +// PayloadARequestBody is used to define fields on request body types. +type PayloadARequestBody struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// PayloadB is used to define fields on request body types. -type PayloadB struct { +// PayloadBRequestBody is used to define fields on request body types. +type PayloadBRequestBody struct { A string `form:"a" json:"a" xml:"a"` B string `form:"b" json:"b" xml:"b"` } -// NewPayloadA builds the HTTP request body from the payload of the "MethodA" -// endpoint of the "ServiceMultipleMethods" service. -func NewPayloadA(p []*servicemultiplemethods.PayloadA) []*PayloadA { - body := make([]*PayloadA, len(p)) +// NewPayloadARequestBody builds the HTTP request body from the payload of the +// "MethodA" endpoint of the "ServiceMultipleMethods" service. +func NewPayloadARequestBody(p []*servicemultiplemethods.PayloadA) []*PayloadARequestBody { + body := make([]*PayloadARequestBody, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalPayloadAToPayloadA2(val) + body[i] = marshalServicemultiplemethodsPayloadAToPayloadARequestBody(val) } return body } -// NewPayloadB builds the HTTP request body from the payload of the "MethodB" -// endpoint of the "ServiceMultipleMethods" service. -func NewPayloadB(p []*servicemultiplemethods.PayloadB) []*PayloadB { - body := make([]*PayloadB, len(p)) +// NewPayloadBRequestBody builds the HTTP request body from the payload of the +// "MethodB" endpoint of the "ServiceMultipleMethods" service. +func NewPayloadBRequestBody(p []*servicemultiplemethods.PayloadB) []*PayloadBRequestBody { + body := make([]*PayloadBRequestBody, len(p)) for i, val := range p { if val == nil { body[i] = nil continue } - body[i] = marshalPayloadBToPayloadB2(val) + body[i] = marshalServicemultiplemethodsPayloadBToPayloadBRequestBody(val) } return body } -// ValidatePayloadA runs the validations defined on PayloadA -func ValidatePayloadA(body *PayloadA) (err error) { +// ValidatePayloadARequestBody runs the validations defined on PayloadA +func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } -// ValidatePayloadB runs the validations defined on PayloadB -func ValidatePayloadB(body *PayloadB) (err error) { +// ValidatePayloadBRequestBody runs the validations defined on PayloadB +func ValidatePayloadBRequestBody(body *PayloadBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", body.A, "patterna")) err = goa.MergeErrors(err, goa.ValidatePattern("body.b", body.B, "patternb")) return diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden index 7c72e0c962..f6a9ffa29d 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden @@ -7,13 +7,13 @@ type MethodARequestBody struct { // MethodBRequestBody is the type of the "ServiceMultipleMethods" service // "MethodB" endpoint HTTP request body. type MethodBRequestBody struct { - A string `form:"a" json:"a" xml:"a"` - B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` - C *APayload `form:"c" json:"c" xml:"c"` + A string `form:"a" json:"a" xml:"a"` + B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` + C *APayloadRequestBody `form:"c" json:"c" xml:"c"` } -// APayload is used to define fields on request body types. -type APayload struct { +// APayloadRequestBody is used to define fields on request body types. +type APayloadRequestBody struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } @@ -34,15 +34,24 @@ func NewMethodBRequestBody(p *servicemultiplemethods.PayloadType) *MethodBReques B: p.B, } if p.C != nil { - body.C = marshalAPayloadToAPayload2(p.C) + body.C = marshalServicemultiplemethodsAPayloadToAPayloadRequestBody(p.C) } return body } -// ValidateAPayload runs the validations defined on APayload -func ValidateAPayload(body *APayload) (err error) { +// ValidateAPayloadRequestBody runs the validations defined on APayload +func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..5e75b8f1aa --- /dev/null +++ b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden @@ -0,0 +1,76 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// NewStoreRequestBody builds the HTTP request body from the payload of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreRequestBody(p *requiredarrays.StorePayload) *StoreRequestBody { + body := &StoreRequestBody{} + if p.Names != nil { + body.Names = make([]string, len(p.Names)) + for i, val := range p.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if p.Aliases != nil { + body.Aliases = make([]string, len(p.Aliases)) + for i, val := range p.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStoreResultOK builds a "RequiredArrays" service "Store" endpoint result +// from a HTTP "OK" response. +func NewStoreResultOK(body *StoreResponseBody) *requiredarrays.StoreResult { + v := &requiredarrays.StoreResult{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreResponseBody runs the validations defined on StoreResponseBody +func ValidateStoreResponseBody(body *StoreResponseBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden b/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden index 33a875e306..8f9a8c97a6 100644 --- a/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden +++ b/http/codegen/testdata/golden/client_types_client-result-type-validate.go.golden @@ -5,9 +5,9 @@ type MethodResultTypeValidateResponseBody struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// NewMethodResultTypeValidateResultOK builds a "ServiceResultTypeValidate" +// NewMethodResultTypeValidateResultTypeOK builds a "ServiceResultTypeValidate" // service "MethodResultTypeValidate" endpoint result from a HTTP "OK" response. -func NewMethodResultTypeValidateResultOK(body *MethodResultTypeValidateResponseBody) *serviceresulttypevalidate.ResultType { +func NewMethodResultTypeValidateResultTypeOK(body *MethodResultTypeValidateResponseBody) *serviceresulttypevalidate.ResultType { v := &serviceresulttypevalidate.ResultType{ A: body.A, } diff --git a/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden index 93b37eac92..477eab8b1f 100644 --- a/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/client_types_client-streaming-payload-required-fields.go.golden @@ -1,15 +1,15 @@ // ClientStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "ClientStream" endpoint HTTP // request body. -type ClientStreamStreamingBody StreamingRequest +type ClientStreamStreamingBody StreamingRequestStreamingBody // BidirectionalStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "BidirectionalStream" // endpoint HTTP request body. -type BidirectionalStreamStreamingBody StreamingRequest +type BidirectionalStreamStreamingBody StreamingRequestStreamingBody -// StreamingRequest is used to define fields on request body types. -type StreamingRequest struct { +// StreamingRequestStreamingBody is used to define fields on request body types. +type StreamingRequestStreamingBody struct { Required string `form:"required" json:"required" xml:"required"` Optional *string `form:"optional,omitempty" json:"optional,omitempty" xml:"optional,omitempty"` BaseRequired string `form:"baseRequired" json:"baseRequired" xml:"baseRequired"` diff --git a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden index 6fabebb342..95cc3a5ee5 100644 --- a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden @@ -1,6 +1,6 @@ // MethodWithErrorCustomPkgErrorNameResponseBody is the type of the // "ServiceWithErrorCustomPkg" service "MethodWithErrorCustomPkg" endpoint HTTP -// response body. +// response body for the "error_name" error. type MethodWithErrorCustomPkgErrorNameResponseBody struct { Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` } diff --git a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden index aa568c458c..4af6473a52 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden @@ -2,19 +2,19 @@ // "ServiceResultWithResultCollection" service // "MethodResultWithResultCollection" endpoint HTTP response body. type MethodResultWithResultCollectionResponseBody struct { - A *Resulttype `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + A *ResulttypeResponseBody `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// Resulttype is used to define fields on response body types. -type Resulttype struct { - X RtCollection `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` +// ResulttypeResponseBody is used to define fields on response body types. +type ResulttypeResponseBody struct { + X RtCollectionResponseBody `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// RtCollection is used to define fields on response body types. -type RtCollection []*Rt +// RtCollectionResponseBody is used to define fields on response body types. +type RtCollectionResponseBody []*RtResponseBody -// Rt is used to define fields on response body types. -type Rt struct { +// RtResponseBody is used to define fields on response body types. +type RtResponseBody struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -24,7 +24,7 @@ type Rt struct { func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCollectionResponseBody) *serviceresultwithresultcollection.MethodResultWithResultCollectionResult { v := &serviceresultwithresultcollection.MethodResultWithResultCollectionResult{} if body.A != nil { - v.A = unmarshalResulttypeToResulttype(body.A) + v.A = unmarshalResulttypeResponseBodyToServiceresultwithresultcollectionResulttypeOptional(body.A) } return v @@ -34,28 +34,39 @@ func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCol // defined on MethodResultWithResultCollectionResponseBody func ValidateMethodResultWithResultCollectionResponseBody(body *MethodResultWithResultCollectionResponseBody) (err error) { if body.A != nil { - if err2 := ValidateResulttype(body.A); err2 != nil { + if err2 := validateResulttypeResponseBody(body.A, "body.a"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateResulttype runs the validations defined on Resulttype -func ValidateResulttype(body *Resulttype) (err error) { +// ValidateResulttypeResponseBody runs the validations defined on Resulttype +func ValidateResulttypeResponseBody(body *ResulttypeResponseBody) (err error) { if body.X != nil { - if err2 := ValidateRtCollection(body.X); err2 != nil { + if err2 := validateRtCollectionResponseBody(body.X, "body.x"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateRtCollection runs the validations defined on RtCollection -func ValidateRtCollection(body RtCollection) (err error) { +// validateResulttypeResponseBody checks Resulttype and reports errors using +// the path supplied by its caller +func validateResulttypeResponseBody(body *ResulttypeResponseBody, path string) (err error) { + if body.X != nil { + if err2 := validateRtCollectionResponseBody(body.X, path+".x"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateRtCollectionResponseBody runs the validations defined on RtCollection +func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) { for _, e := range body { if e != nil { - if err2 := ValidateRt(e); err2 != nil { + if err2 := validateRtResponseBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -63,8 +74,21 @@ func ValidateRtCollection(body RtCollection) (err error) { return } -// ValidateRt runs the validations defined on Rt -func ValidateRt(body *Rt) (err error) { +// validateRtCollectionResponseBody checks RtCollection and reports errors +// using the path supplied by its caller +func validateRtCollectionResponseBody(body RtCollectionResponseBody, path string) (err error) { + for _, e := range body { + if e != nil { + if err2 := validateRtResponseBody(e, path+"[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// ValidateRtResponseBody runs the validations defined on Rt +func ValidateRtResponseBody(body *RtResponseBody) (err error) { if body.X != nil { if utf8.RuneCountInString(*body.X) < 5 { err = goa.MergeErrors(err, goa.InvalidLengthError("body.x", *body.X, utf8.RuneCountInString(*body.X), 5, true)) @@ -72,3 +96,14 @@ func ValidateRt(body *Rt) (err error) { } return } + +// validateRtResponseBody checks Rt and reports errors using the path supplied +// by its caller +func validateRtResponseBody(body *RtResponseBody, path string) (err error) { + if body.X != nil { + if utf8.RuneCountInString(*body.X) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError(path+".x", *body.X, utf8.RuneCountInString(*body.X), 5, true)) + } + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden index fe1906ea5b..4650d541ee 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden @@ -2,24 +2,24 @@ // "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint // HTTP response body. type MethodResultWithResultViewResponseBodyFull struct { - Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` - Rt *Rt `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Rt *RtResponseBody `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` } -// Rt is used to define fields on response body types. -type Rt struct { +// RtResponseBody is used to define fields on response body types. +type RtResponseBody struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// NewMethodResultWithResultViewResultOK builds a "ServiceResultWithResultView" -// service "MethodResultWithResultView" endpoint result from a HTTP "OK" -// response. -func NewMethodResultWithResultViewResultOK(body *MethodResultWithResultViewResponseBodyFull) *serviceresultwithresultviewviews.ResulttypeView { +// NewMethodResultWithResultViewResulttypeOK builds a +// "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint +// result from a HTTP "OK" response. +func NewMethodResultWithResultViewResulttypeOK(body *MethodResultWithResultViewResponseBodyFull) *serviceresultwithresultviewviews.ResulttypeView { v := &serviceresultwithresultviewviews.ResulttypeView{ Name: body.Name, } if body.Rt != nil { - v.Rt = unmarshalRtToRtView(body.Rt) + v.Rt = unmarshalRtResponseBodyToServiceresultwithresultviewviewsRtViewOptional(body.Rt) } return v diff --git a/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden new file mode 100644 index 0000000000..3ef2477c0b --- /dev/null +++ b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden @@ -0,0 +1,49 @@ +// ChooseRequestBody2 is the type of the "Names" service "Choose" endpoint HTTP +// request body. +type ChooseRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateChooseRequestBody2 runs the validations defined on ChooseRequestBody2 +func ValidateChooseRequestBody2(body *ChooseRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + if utf8.RuneCountInString(*body.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.value", *body.Value, utf8.RuneCountInString(*body.Value), 2, true)) + } + } + return +} + +// DecodeChooseRequest2 returns a decoder for requests sent to the Names Choose +// endpoint. +func DecodeChooseRequest2(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *names.ChoosePayload + var ( + body ChooseRequestBody2 + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateChooseRequestBody2(&body) + if err != nil { + return payload, err + } + payload = NewChoosePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/planned_name_collisions.go.golden b/http/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..c5f25c75e1 --- /dev/null +++ b/http/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,184 @@ +// BuildCompleteRequest2 instantiates a HTTP request object with method and +// path set to call the "Names" service "Complete" endpoint +func (c *Client) BuildCompleteRequest2(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CompleteNamesPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Names", "Complete", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// Complete returns an endpoint that makes HTTP requests to the Names service +// Complete server. +func (c *Client) Complete() goa.Endpoint { + var ( + encodeRequest = EncodeCompleteRequest(c.encoder) + decodeResponse = DecodeCompleteResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCompleteRequest2(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.CompleteDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Names", "Complete", err) + } + return decodeResponse(resp) + } +} + +// ChildPayloadRequestBody2 is used to define fields on request body types. +type ChildPayloadRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateCompleteRequestBody runs the validations defined on +// CompleteRequestBody +func ValidateCompleteRequestBody(body *CompleteRequestBody) (err error) { + if body.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "body")) + } + if body.Child != nil { + if err2 := validateChildPayloadRequestBody2(body.Child, "body.child"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateChildPayloadRequestBody2 runs the validations defined on ChildPayload +func ValidateChildPayloadRequestBody2(body *ChildPayloadRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.value", *body.Value, "value")) + } + return +} + +// validateChildPayloadRequestBody2 checks ChildPayload and reports errors +// using the path supplied by its caller +func validateChildPayloadRequestBody2(body *ChildPayloadRequestBody2, path string) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", path)) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".value", *body.Value, "value")) + } + return +} + +// DecodeCompleteRequest returns a decoder for requests sent to the Names +// Complete endpoint. +func DecodeCompleteRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*names.CompletePayload, error) { + return func(r *http.Request) (*names.CompletePayload, error) { + var payload *names.CompletePayload + var ( + body CompleteRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateCompleteRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewCompletePayload(&body) + + return payload, nil + } +} + +// SocketServerStream2 implements the names.SocketServerStream interface. +type SocketServerStream2 struct { + once sync.Once + // upgradeErr is the error returned by the websocket upgrade attempt. + upgradeErr error + // upgrader is the websocket connection upgrader. + upgrader goahttp.Upgrader + // configurer is the websocket connection configurer. + configurer goahttp.ConnConfigureFunc + // cancel is the context cancellation function which cancels the request + // context when invoked. + cancel context.CancelFunc + // w is the HTTP response writer used in upgrading the connection. + w http.ResponseWriter + // r is the HTTP request. + r *http.Request + // conn is the underlying websocket connection. + conn *websocket.Conn +} + +// NewSocketHandler creates a HTTP handler which loads the HTTP request and +// calls the "Names" service "Socket" endpoint. +func NewSocketHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + upgrader goahttp.Upgrader, + configurer goahttp.ConnConfigureFunc, +) http.Handler { + var ( + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "Socket") + ctx = context.WithValue(ctx, goa.ServiceKey, "Names") + var err error + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + v := &names.SocketEndpointInput{ + Stream: &SocketServerStream2{ + upgrader: upgrader, + configurer: configurer, + cancel: cancel, + w: w, + r: r, + }, + } + _, err = endpoint(ctx, v) + if err != nil { + var stream *SocketServerStream2 + if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { + stream = wrapper.Unwrap().(*SocketServerStream2) + } else { + stream = v.Stream.(*SocketServerStream2) + } + if stream != nil && stream.conn != nil { + // Response writer has been hijacked, do not encode the error + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + }) +} diff --git a/http/codegen/testdata/golden/planned_service_name_uses.go.golden b/http/codegen/testdata/golden/planned_service_name_uses.go.golden new file mode 100644 index 0000000000..3ee1a1dd8f --- /dev/null +++ b/http/codegen/testdata/golden/planned_service_name_uses.go.golden @@ -0,0 +1,370 @@ +===== service method names definition ===== +// Service is the Collisions service interface. +type Service interface { + // Read implements Read. + Read(context.Context, *ReadPayload) (res string, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "Name Test" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "Collisions" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames2 = [1]string{"Read"} + +===== service endpoints definition ===== +// Endpoints2 wraps the "Collisions" service endpoints. +type Endpoints2 struct { + Read goa.Endpoint +} + +===== client interceptors definition ===== +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors2 interface { + Trace(ctx context.Context, info TraceInfo, next goa.Endpoint) (any, error) +} + +===== client endpoint wrapper definition ===== +// WrapReadClientEndpoint2 wraps the Read endpoint with the client interceptors +// defined in the design. +func WrapReadClientEndpoint2(endpoint goa.Endpoint, i ClientInterceptors2) goa.Endpoint { + if i != nil { + endpoint = wrapClientReadTrace(endpoint, i) + } + return endpoint +} + +===== HTTP server endpoints use ===== +// New instantiates HTTP handlers for all the Collisions service endpoints +// using the provided encoder and decoder. The handlers are mounted on the +// given mux using the HTTP verb and path defined in the design. errhandler is +// called whenever a response fails to be encoded. formatter is used to format +// errors returned by the service methods prior to encoding. Both errhandler +// and formatter are optional and can be nil. +func New( + e *collisions.Endpoints2, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Read", "POST", "/read"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + } +} + +===== HTTP server method names use ===== +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return collisions.MethodNames2[:] } + +===== HTTP command parser ===== +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, + collisionsInter collisions.ClientInterceptors2, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadBodyFlag = collisionsReadFlags.String("body", "REQUIRED", "") + ) + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(*collisionsReadBodyFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +===== HTTP example client interceptor use ===== +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + collisionsInterceptors collisions.ClientInterceptors2 + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + collisionsInterceptors = interceptors.NewCollisionsClientInterceptors() + } + + endpoint, payload, err := cli2.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + collisionsInterceptors, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "collisions": + switch flag.Arg(1) { + case "read": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli2.UsageExamples() +} + +===== gRPC server endpoints use ===== +// New instantiates the server struct with the Collisions service endpoints. +func New(e *collisions.Endpoints2, uh goagrpc.UnaryHandler) *Server { + return &Server{ + ReadH: NewReadHandler(e.Read, uh), + } +} + +===== gRPC example server endpoints use ===== +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, collisionsEndpoints *collisions.Endpoints2, wg *sync.WaitGroup, errc chan error, dbg bool) { + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + collisionsServer *collisionssvr.Server + ) + { + collisionsServer = collisionssvr.New(collisionsEndpoints, nil) + } + + // Create interceptor which sets up the logger in each request context. + chain := grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx)) + if dbg { + // Log request and response content if debug logs are enabled. + chain = grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx), debug.UnaryServerInterceptor()) + } + + // Initialize gRPC server + srv := grpc.NewServer(chain) + + // Register the servers. + collisionspb.RegisterCollisionsServer(srv, collisionsServer) + log.Printf(ctx, "serving gRPC method %s", "collisions.Collisions/Read") + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + if lis == nil { + errc <- fmt.Errorf("failed to listen on %q", u.Host) + } + log.Printf(ctx, "gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + log.Printf(ctx, "shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} + +===== gRPC command parser ===== +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + cc *grpc.ClientConn, + collisionsInter collisions.ClientInterceptors2, + opts ...grpc.CallOption, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadMessageFlag = collisionsReadFlags.String("message", "", "") + ) + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(cc, opts...) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(*collisionsReadMessageFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + diff --git a/http/codegen/testdata/golden/planned_union_name_collisions.go.golden b/http/codegen/testdata/golden/planned_union_name_collisions.go.golden new file mode 100644 index 0000000000..5f4a213423 --- /dev/null +++ b/http/codegen/testdata/golden/planned_union_name_collisions.go.golden @@ -0,0 +1,152 @@ +// Choice2 holds exactly one of its branch values. +type Choice2 struct { + kind Choice2Kind + Text ChoiceTextRequestBody + Count ChoiceCountRequestBody +} + +// Choice2Kind records which Choice2 branch is selected. +type Choice2Kind string + +const ( + // Choice2KindText identifies the text branch. + Choice2KindText Choice2Kind = "text" + // Choice2KindCount identifies the count branch. + Choice2KindCount Choice2Kind = "count" +) + +// Kind returns the selected branch. +func (u Choice2) Kind() Choice2Kind { + return u.kind +} + +// NewChoice2Text constructs Choice2 with the text branch set. +func NewChoice2Text(v ChoiceTextRequestBody) Choice2 { + return Choice2{ + kind: Choice2KindText, + Text: v, + } +} + +// AsText returns the value when the text branch is selected. +func (u Choice2) AsText() (_ ChoiceTextRequestBody, ok bool) { + if u.kind != Choice2KindText { + return + } + return u.Text, true +} + +// SetText selects the text branch and stores v. +func (u *Choice2) SetText(v ChoiceTextRequestBody) { + u.kind = Choice2KindText + u.Text = v +} + +// NewChoice2Count constructs Choice2 with the count branch set. +func NewChoice2Count(v ChoiceCountRequestBody) Choice2 { + return Choice2{ + kind: Choice2KindCount, + Count: v, + } +} + +// AsCount returns the value when the count branch is selected. +func (u Choice2) AsCount() (_ ChoiceCountRequestBody, ok bool) { + if u.kind != Choice2KindCount { + return + } + return u.Count, true +} + +// SetCount selects the count branch and stores v. +func (u *Choice2) SetCount(v ChoiceCountRequestBody) { + u.kind = Choice2KindCount + u.Count = v +} + +// Validate ensures exactly one valid branch is selected. +func (u Choice2) Validate() error { + switch u.kind { + case "": + return goa.InvalidEnumValueError("type", "", []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + case Choice2KindText: + return nil + case Choice2KindCount: + return nil + default: + return goa.InvalidEnumValueError("type", u.kind, []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + } +} + +// MarshalJSON marshals the union into the canonical {type,value} JSON shape. +func (u Choice2) MarshalJSON() ([]byte, error) { + if err := u.Validate(); err != nil { + return nil, err + } + var ( + value any + ) + switch u.kind { + case Choice2KindText: + value = u.Text + case Choice2KindCount: + value = u.Count + default: + return nil, fmt.Errorf("unexpected Choice2 kind %q", u.kind) + } + return json.Marshal(struct { + Type string `json:"type"` + Value any `json:"value"` + }{ + Type: string(u.kind), + Value: value, + }) +} + +// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. +func (u *Choice2) UnmarshalJSON(data []byte) error { + var raw struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Value) == 0 { + return goa.MissingFieldError("value", "Choice2") + } + if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { + return goa.InvalidFieldTypeError("value", nil, "non-null JSON value") + } + switch raw.Type { + case string(Choice2KindText): + var v ChoiceTextRequestBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.kind = Choice2KindText + u.Text = v + case string(Choice2KindCount): + var v ChoiceCountRequestBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.kind = Choice2KindCount + u.Count = v + default: + if raw.Type == "" { + return goa.MissingFieldError("type", "Choice2") + } + return goa.InvalidEnumValueError("type", raw.Type, []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + } + return nil +} diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden new file mode 100644 index 0000000000..790a102d4e --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden @@ -0,0 +1,5 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden new file mode 100644 index 0000000000..ece6314b98 --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden @@ -0,0 +1,21 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny + +// NewUsertypeResponseTinyCollection builds the HTTP response body from the +// result of the "StreamingPayloadResultCollectionWithExplicitViewMethod" +// endpoint of the "StreamingPayloadResultCollectionWithExplicitViewService" +// service. +func NewUsertypeResponseTinyCollection(res streamingpayloadresultcollectionwithexplicitviewserviceviews.UsertypeCollectionView) UsertypeResponseTinyCollection { + body := make([]*UsertypeResponseTiny, len(res)) + for i, val := range res { + if val == nil { + body[i] = nil + continue + } + body[i] = marshalStreamingpayloadresultcollectionwithexplicitviewserviceviewsUsertypeViewToUsertypeResponseTiny(val) + } + return body +} diff --git a/http/codegen/testdata/golden/server-multipart-array.golden b/http/codegen/testdata/golden/server-multipart-array.golden new file mode 100644 index 0000000000..44780f3455 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-array.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartarraytypesvr "generated.local/gen/http/service_multipart_array_type/server" + servicemultipartarraytype "generated.local/gen/service_multipart_array_type" +) + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc reads the +// multipart request body for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType" into body. +func ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc(mr *multipart.Reader, body *[]*servicemultipartarraytypesvr.PayloadTypeRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType". +func ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc(mw *multipart.Writer, p []*servicemultipartarraytype.PayloadType) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-map.golden b/http/codegen/testdata/golden/server-multipart-map.golden new file mode 100644 index 0000000000..31289d9360 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-map.golden @@ -0,0 +1,19 @@ +import ( + "mime/multipart" +) + +// ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc reads the multipart +// request body for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType" into body. +func ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc(mr *multipart.Reader, body *map[string]int) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType". +func ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc(mw *multipart.Writer, p map[string]int) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-object.golden b/http/codegen/testdata/golden/server-multipart-object.golden new file mode 100644 index 0000000000..778678dd79 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-object.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartvalidationsvr "generated.local/gen/http/service_multipart_validation/server" + servicemultipartvalidation "generated.local/gen/service_multipart_validation" +) + +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc reads the +// multipart request body for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation" into body. +func ServiceMultipartValidationMethodMultipartValidationDecoderFunc(mr *multipart.Reader, body *servicemultipartvalidationsvr.MethodMultipartValidationRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartValidationMethodMultipartValidationEncoderFunc implements +// the multipart encoder for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation". +func ServiceMultipartValidationMethodMultipartValidationEncoderFunc(mw *multipart.Writer, p *servicemultipartvalidation.MethodMultipartValidationPayload) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden index 4b5cce23f8..30c88c2011 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-array-user.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayload(body) + payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden index b500c1fe8d..509e45cd42 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayload(body) + payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden index 660b975de6..b975e9496c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-path-user-validate.go.golden @@ -33,7 +33,7 @@ func DecodeMethodUserBodyPathValidateRequest(mux goahttp.Muxer, decoder func(*ht if err != nil { return payload, err } - payload = NewMethodUserBodyPathValidatePayload(&body, b) + payload = NewMethodUserBodyPathValidatePayloadType(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden index af5a8aa1be..353fe7683b 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-path-user.go.golden @@ -25,7 +25,7 @@ func DecodeMethodBodyPathUserRequest(mux goahttp.Muxer, decoder func(*http.Reque params = mux.Vars(r) ) b = params["b"] - payload = NewMethodBodyPathUserPayload(&body, b) + payload = NewMethodBodyPathUserPayloadType(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden index 485963fea8..6aee8eac3d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden @@ -5,7 +5,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode return func(r *http.Request) ([]*servicebodyprimitivearrayuserrequired.PayloadType, error) { var payload []*servicebodyprimitivearrayuserrequired.PayloadType var ( - body []*PayloadType + body []*PayloadTypeRequestBody err error ) err = decoder(r).Decode(&body) @@ -21,7 +21,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadType(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -29,7 +29,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserRequiredPayload(body) + payload = NewMethodBodyPrimitiveArrayUserRequiredPayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden index 7c17fc79b5..49a33dbde9 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden @@ -5,7 +5,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode return func(r *http.Request) ([]*servicebodyprimitivearrayuservalidate.PayloadType, error) { var payload []*servicebodyprimitivearrayuservalidate.PayloadType var ( - body []*PayloadType + body []*PayloadTypeRequestBody err error ) err = decoder(r).Decode(&body) @@ -24,7 +24,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadType(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -32,7 +32,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserValidatePayload(body) + payload = NewMethodBodyPrimitiveArrayUserValidatePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden index 7b188533db..21b3bf22ae 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user-validate.go.golden @@ -28,7 +28,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode if err != nil { return payload, err } - payload = NewMethodBodyPrimitiveArrayUserValidatePayload(body) + payload = NewMethodBodyPrimitiveArrayUserValidatePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden index 4b5cce23f8..30c88c2011 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-field-array-user.go.golden @@ -20,7 +20,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return payload, goa.DecodePayloadError(err.Error()) } } - payload = NewMethodBodyPrimitiveArrayUserPayload(body) + payload = NewMethodBodyPrimitiveArrayUserPayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden index 66efbaaf27..3766c2c375 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user-validate.go.golden @@ -40,7 +40,7 @@ func DecodeMethodBodyQueryPathUserValidateRequest(mux goahttp.Muxer, decoder fun if err != nil { return payload, err } - payload = NewMethodBodyQueryPathUserValidatePayload(&body, c2, b) + payload = NewMethodBodyQueryPathUserValidatePayloadType(&body, c2, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden index 2fd66e8974..8625ce1373 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-path-user.go.golden @@ -30,7 +30,7 @@ func DecodeMethodBodyQueryPathUserRequest(mux goahttp.Muxer, decoder func(*http. if bRaw != "" { b = &bRaw } - payload = NewMethodBodyQueryPathUserPayload(&body, c2, b) + payload = NewMethodBodyQueryPathUserPayloadType(&body, c2, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden index 60d6ba91ce..2d8357c4f6 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-user-validate.go.golden @@ -34,7 +34,7 @@ func DecodeMethodBodyQueryUserValidateRequest(mux goahttp.Muxer, decoder func(*h if err != nil { return payload, err } - payload = NewMethodBodyQueryUserValidatePayload(&body, b) + payload = NewMethodBodyQueryUserValidatePayloadType(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden index ebd48574d0..c090216754 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-query-user.go.golden @@ -26,7 +26,7 @@ func DecodeMethodBodyQueryUserRequest(mux goahttp.Muxer, decoder func(*http.Requ if bRaw != "" { b = &bRaw } - payload = NewMethodBodyQueryUserPayload(&body, b) + payload = NewMethodBodyQueryUserPayloadType(&body, b) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..191f32022d --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden @@ -0,0 +1,29 @@ +// DecodeStoreRequest returns a decoder for requests sent to the RequiredArrays +// Store endpoint. +func DecodeStoreRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*requiredarrays.StorePayload, error) { + return func(r *http.Request) (*requiredarrays.StorePayload, error) { + var payload *requiredarrays.StorePayload + var ( + body StoreRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateStoreRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewStorePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden index 27d2ae4426..c7ec4b091c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-union-user.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUnionUserRequest(mux goahttp.Muxer, decoder func(*http.Requ } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUnionUserPayload(&body) + payload = NewMethodBodyUnionUserUnionUser(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden index 2b1036137e..d59ea889ec 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-union.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUnionRequest(mux goahttp.Muxer, decoder func(*http.Request) } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUnionPayload(&body) + payload = NewMethodBodyUnionUnion(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden index 7aa5c5d198..7f797ad350 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden @@ -23,7 +23,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) if err != nil { return payload, err } - payload = NewMethodBodyUserPayload(&body) + payload = NewMethodBodyUserPayloadType(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden index 825055b836..5f61f2b6be 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-required.go.golden @@ -22,7 +22,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) if err != nil { return payload, err } - payload = NewMethodBodyUserPayload(&body) + payload = NewMethodBodyUserPayloadType(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden index 05b8c50938..064adc369b 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden @@ -23,7 +23,7 @@ func DecodeMethodBodyUserValidateRequest(mux goahttp.Muxer, decoder func(*http.R if err != nil { return payload, err } - payload = NewMethodBodyUserValidatePayload(body) + payload = NewMethodBodyUserValidatePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden index 8f27eeaa7a..2697436ea5 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user.go.golden @@ -18,7 +18,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) } return payload, goa.DecodePayloadError(err.Error()) } - payload = NewMethodBodyUserPayload(&body) + payload = NewMethodBodyUserPayloadType(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden index 94aea4d1f8..431e5d354d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden @@ -1,13 +1,13 @@ -// marshalImmediatechildextenderViewToImmediatechildextender builds a value of -// type *Immediatechildextender from a value of type -// *servicedeepuserviews.ImmediatechildextenderView. -func marshalImmediatechildextenderViewToImmediatechildextender(v *servicedeepuserviews.ImmediatechildextenderView) *Immediatechildextender { +// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBodyOptional +// builds a value of type *ImmediatechildextenderResponseBody from a value of +// type *servicedeepuserviews.ImmediatechildextenderView. +func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBodyOptional(v *servicedeepuserviews.ImmediatechildextenderView) *ImmediatechildextenderResponseBody { if v == nil { return nil } - res := &Immediatechildextender{} + res := &ImmediatechildextenderResponseBody{} if v.DeepChild != nil { - res.DeepChild = marshalDeepchildViewToDeepchild(v.DeepChild) + res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchildResponseBodyOptional(v.DeepChild) } return res diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden index 5c31f80653..9e0135057d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-object.go.golden @@ -52,7 +52,7 @@ func DecodeMethodMapQueryObjectRequest(mux goahttp.Muxer, decoder func(*http.Req if err != nil { return payload, err } - payload = NewMethodMapQueryObjectPayload(&body, a, c) + payload = NewMethodMapQueryObjectPayloadType(&body, a, c) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden index 44258c1cb8..89e4cfc3b4 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden @@ -3,13 +3,32 @@ func DecodeMethodMultipartArrayTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) ([]*servicemultipartarraytype.PayloadType, error) { return func(r *http.Request) ([]*servicemultipartarraytype.PayloadType, error) { var payload []*servicemultipartarraytype.PayloadType - if err := decoder(r).Decode(&payload); err != nil { + var ( + body []*PayloadTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + for _, e := range body { + if e != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartArrayTypePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden index 37f333f73d..4cc86de67b 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartMapTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (map[string]int, error) { return func(r *http.Request) (map[string]int, error) { var payload map[string]int - if err := decoder(r).Decode(&payload); err != nil { + var ( + body map[string]int + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden index d763025157..44e8ad610c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartPrimitiveRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (string, error) { return func(r *http.Request) (string, error) { var payload string - if err := decoder(r).Decode(&payload); err != nil { + var ( + body string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden index 032f70ee31..d431496263 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden @@ -3,13 +3,26 @@ func DecodeMethodMultipartUserTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { return func(r *http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { var payload *servicemultipartusertype.MethodMultipartUserTypePayload - if err := decoder(r).Decode(&payload); err != nil { + var ( + body MethodMultipartUserTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + err = ValidateMethodMultipartUserTypeRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartUserTypePayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden new file mode 100644 index 0000000000..87397a5509 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden @@ -0,0 +1,29 @@ +// DecodeMethodMultipartValidationRequest returns a decoder for requests sent +// to the ServiceMultipartValidation MethodMultipartValidation endpoint. +func DecodeMethodMultipartValidationRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + return func(r *http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + var payload *servicemultipartvalidation.MethodMultipartValidationPayload + var ( + body MethodMultipartValidationRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartValidationRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartValidationPayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden new file mode 100644 index 0000000000..043170b1f3 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden @@ -0,0 +1,65 @@ +// DecodeMethodMultipartWithParamRequest returns a decoder for requests sent to +// the ServiceMultipartWithParam MethodMultipartWithParam endpoint. +func DecodeMethodMultipartWithParamRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparam.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparam.PayloadType, error) { + var payload *servicemultipartwithparam.PayloadType + var ( + body MethodMultipartWithParamRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + c2 map[int][]string + ) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamPayloadType(&body, c2) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden new file mode 100644 index 0000000000..2de7af9d40 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden @@ -0,0 +1,79 @@ +// DecodeMethodMultipartWithParamsAndHeadersRequest returns a decoder for +// requests sent to the ServiceMultipartWithParamsAndHeaders +// MethodMultipartWithParamsAndHeaders endpoint. +func DecodeMethodMultipartWithParamsAndHeadersRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + var payload *servicemultipartwithparamsandheaders.PayloadType + var ( + body MethodMultipartWithParamsAndHeadersRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamsAndHeadersRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + a string + c2 map[int][]string + b *string + + params = mux.Vars(r) + ) + a = params["a"] + err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + bRaw := r.Header.Get("Authorization") + if bRaw != "" { + b = &bRaw + } + if b != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamsAndHeadersPayloadType(&body, a, c2, b) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden b/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden index ea11341cac..8ace8679e3 100644 --- a/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden +++ b/http/codegen/testdata/golden/server_encode_body-result-collection-explicit-view.go.golden @@ -5,7 +5,7 @@ func EncodeMethodBodyCollectionExplicitViewResponse(encoder func(context.Context return func(ctx context.Context, w http.ResponseWriter, v any) error { res := v.(servicebodycollectionexplicitviewviews.ResulttypecollectionCollection) enc := encoder(ctx, w) - body := NewResulttypecollectionTinyCollection(res.Projected) + body := NewResulttypecollectionResponseTinyCollection(res.Projected) w.WriteHeader(http.StatusOK) return enc.Encode(body) } diff --git a/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden b/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden index b9a0f329cb..8e743d8c79 100644 --- a/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden +++ b/http/codegen/testdata/golden/server_encode_body-result-collection-multiple-views.go.golden @@ -8,9 +8,9 @@ func EncodeMethodBodyCollectionResponse(encoder func(context.Context, http.Respo var body any switch res.View { case "default", "": - body = NewResulttypecollectionCollection(res.Projected) + body = NewResulttypecollectionResponseCollection(res.Projected) case "tiny": - body = NewResulttypecollectionTinyCollection(res.Projected) + body = NewResulttypecollectionResponseTinyCollection(res.Projected) } w.WriteHeader(http.StatusOK) return enc.Encode(body) diff --git a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden index 43ba6e30a8..b681ede57e 100644 --- a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden @@ -5,7 +5,7 @@ func EncodeMethodExplicitBodyResultCollectionResponse(encoder func(context.Conte return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*serviceexplicitbodyresultcollection.MethodExplicitBodyResultCollectionResult) enc := encoder(ctx, w) - body := NewResulttypeCollection(res) + body := NewResulttypeResponseCollection(res) w.WriteHeader(http.StatusOK) return enc.Encode(body) } diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden index a99f378cae..1c8f253112 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalResultTypeToResultType builds a value of type -// *fooservice.ResultType from a value of type *ResultType2. -func unmarshalResultTypeToResultType(v *ResultType2) *fooservice.ResultType { +// unmarshalResultTypeRequestBodyToFooserviceResultType builds a value of type +// *fooservice.ResultType from a value of type *ResultTypeRequestBody. +func unmarshalResultTypeRequestBodyToFooserviceResultType(v *ResultTypeRequestBody) *fooservice.ResultType { res := &fooservice.ResultType{} if v.Foo != nil { foo := fooservice.Foo(*v.Foo) diff --git a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden index 5627ed1675..bbbcf31907 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_array-alias-extended_section1.go.golden @@ -1,7 +1,7 @@ -// marshalResultTypeToResultType builds a value of type *ResultType from a -// value of type *fooservice.ResultType. -func marshalResultTypeToResultType(v *fooservice.ResultType) *ResultType { - res := &ResultType{} +// marshalFooserviceResultTypeToResultTypeResponse builds a value of type +// *ResultTypeResponse from a value of type *fooservice.ResultType. +func marshalFooserviceResultTypeToResultTypeResponse(v *fooservice.ResultType) *ResultTypeResponse { + res := &ResultTypeResponse{} if v.Foo != nil { foo := string(*v.Foo) res.Foo = &foo diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden index 286aede630..9bab86ab57 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalFooToFooFoo builds a value of type *foo.Foo from a value of type -// *Foo2. -func unmarshalFooToFooFoo(v *Foo2) *foo.Foo { +// unmarshalFooRequestBodyToFooFooOptional builds a value of type *foo.Foo from +// a value of type *FooRequestBody. +func unmarshalFooRequestBodyToFooFooOptional(v *FooRequestBody) *foo.Foo { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden index 967a03a255..ce16450811 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden @@ -1,9 +1,10 @@ -// marshalFooFooToFoo builds a value of type *Foo from a value of type *foo.Foo. -func marshalFooFooToFoo(v *foo.Foo) *Foo { +// marshalFooFooToFooResponseBodyOptional builds a value of type +// *FooResponseBody from a value of type *foo.Foo. +func marshalFooFooToFooResponseBodyOptional(v *foo.Foo) *FooResponseBody { if v == nil { return nil } - res := &Foo{ + res := &FooResponseBody{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden index 8058dd504b..0027baa62c 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden @@ -1,12 +1,12 @@ -// unmarshalExtensionToExtension builds a value of type *fooservice.Extension -// from a value of type *Extension2. -func unmarshalExtensionToExtension(v *Extension2) *fooservice.Extension { +// unmarshalExtensionRequestBodyToFooserviceExtensionOptional builds a value of +// type *fooservice.Extension from a value of type *ExtensionRequestBody. +func unmarshalExtensionRequestBodyToFooserviceExtensionOptional(v *ExtensionRequestBody) *fooservice.Extension { if v == nil { return nil } res := &fooservice.Extension{} if v.Bar != nil { - res.Bar = unmarshalBarToBar(v.Bar) + res.Bar = unmarshalBarRequestBodyToFooserviceBarOptional(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden index 0044584907..d035f5011a 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden @@ -1,6 +1,6 @@ -// unmarshalBarToBar builds a value of type *fooservice.Bar from a value of -// type *Bar2. -func unmarshalBarToBar(v *Bar2) *fooservice.Bar { +// unmarshalBarRequestBodyToFooserviceBarOptional builds a value of type +// *fooservice.Bar from a value of type *BarRequestBody. +func unmarshalBarRequestBodyToFooserviceBarOptional(v *BarRequestBody) *fooservice.Bar { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden index adbfeb7efa..4029f08510 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden @@ -1,9 +1,9 @@ -// marshalResultTypeToResultType builds a value of type *ResultType from a -// value of type *fooservice.ResultType. -func marshalResultTypeToResultType(v *fooservice.ResultType) *ResultType { - res := &ResultType{} +// marshalFooserviceResultTypeToResultTypeResponse builds a value of type +// *ResultTypeResponse from a value of type *fooservice.ResultType. +func marshalFooserviceResultTypeToResultTypeResponse(v *fooservice.ResultType) *ResultTypeResponse { + res := &ResultTypeResponse{} if v.Extension != nil { - res.Extension = marshalExtensionToExtension(v.Extension) + res.Extension = marshalFooserviceExtensionToExtensionResponseOptional(v.Extension) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden index 3c25cec99e..85209b8a5a 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden @@ -1,12 +1,12 @@ -// marshalExtensionToExtension builds a value of type *Extension from a value -// of type *fooservice.Extension. -func marshalExtensionToExtension(v *fooservice.Extension) *Extension { +// marshalFooserviceExtensionToExtensionResponseOptional builds a value of type +// *ExtensionResponse from a value of type *fooservice.Extension. +func marshalFooserviceExtensionToExtensionResponseOptional(v *fooservice.Extension) *ExtensionResponse { if v == nil { return nil } - res := &Extension{} + res := &ExtensionResponse{} if v.Bar != nil { - res.Bar = marshalBarToBar(v.Bar) + res.Bar = marshalFooserviceBarToBarResponseOptional(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden index 4cf6cdd7ae..c6dca39665 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden @@ -1,10 +1,10 @@ -// marshalBarToBar builds a value of type *Bar from a value of type -// *fooservice.Bar. -func marshalBarToBar(v *fooservice.Bar) *Bar { +// marshalFooserviceBarToBarResponseOptional builds a value of type +// *BarResponse from a value of type *fooservice.Bar. +func marshalFooserviceBarToBarResponseOptional(v *fooservice.Bar) *BarResponse { if v == nil { return nil } - res := &Bar{ + res := &BarResponse{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden new file mode 100644 index 0000000000..31864b5c21 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden @@ -0,0 +1,12 @@ +// MountReadHandler configures the mux to serve the "Files" service "Read" +// endpoint. +func MountReadHandler(mux goahttp.Muxer, h http.Handler) { + h = First(Second(wrapEndpoint(h))) + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/items/{id}", f) +} diff --git a/http/codegen/testdata/golden/server_extensions_escaping.go.golden b/http/codegen/testdata/golden/server_extensions_escaping.go.golden new file mode 100644 index 0000000000..cb391c4ced --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_escaping.go.golden @@ -0,0 +1,22 @@ +// New instantiates HTTP handlers for all the Escape service endpoints using +// the provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *escape.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Ping", "GET", "/"}, + {"Quoted \"method\"\nnext", "CUSTOM\\VERB", "/quoted/\"value\"\\next\nline"}, + }, + Ping: NewPingHandler(e.Ping, mux, decoder, encoder, errhandler, formatter), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_file_helper.go.golden b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden new file mode 100644 index 0000000000..98ca985187 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden @@ -0,0 +1,6 @@ +// MountAssets configures the mux to serve GET request made to "/assets". +func MountAssets(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/assets/", h.ServeHTTP) + mux.Handle("GET", "/assets/{*path}", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_extensions_init.go.golden b/http/codegen/testdata/golden/server_extensions_init.go.golden new file mode 100644 index 0000000000..a0711e108c --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_init.go.golden @@ -0,0 +1,37 @@ +// New instantiates HTTP handlers for all the Files service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *files.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemAssets http.FileSystem, + fileSystemOldHTML http.FileSystem, +) *Server { + if fileSystemAssets == nil { + fileSystemAssets = http.Dir(".") + } + fileSystemAssets = appendPrefix(fileSystemAssets, "/assets") + if fileSystemOldHTML == nil { + fileSystemOldHTML = http.Dir(".") + } + fileSystemOldHTML = appendPrefix(fileSystemOldHTML, "/") + return &Server{ + Mounts: []*MountPoint{ + {"Read", "GET", "/items/{id}"}, + {"Serve assets", "GET", "/assets"}, + {"Serve old.html", "GET", "/old"}, + {"Preflight item", "OPTIONS", "/items/{id}"}, + {"Preflight assets", "OPTIONS", "/assets/{*path}"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + Assets: http.FileServer(fileSystemAssets), + OldHTML: http.FileServer(fileSystemOldHTML), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_mount.go.golden b/http/codegen/testdata/golden/server_extensions_mount.go.golden new file mode 100644 index 0000000000..20098b2236 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_mount.go.golden @@ -0,0 +1,14 @@ +// Mount configures the mux to serve the Files endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountReadHandler(mux, h.Read) + MountAssets(mux, http.StripPrefix("/assets", h.Assets)) + MountOldHTML(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/new", http.StatusMovedPermanently) + })) + MountPreflight(mux) +} + +// Mount configures the mux to serve the Files endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} diff --git a/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden new file mode 100644 index 0000000000..a4d72ff7ba --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden @@ -0,0 +1,5 @@ +// MountOldHTML configures the mux to serve GET request made to "/old". +func MountOldHTML(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/old", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden index 3d2bf2b323..1f2519773e 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartArrayType" service // "MethodMultipartArrayType" endpoint. -type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*servicemultipartarraytype.PayloadType) error +type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*PayloadTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden index d7dce056e7..83adb6442b 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, **servicemultipartusertype.MethodMultipartUserTypePayload) error +type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, *MethodMultipartUserTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden new file mode 100644 index 0000000000..3aaa0eebcf --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden @@ -0,0 +1,4 @@ +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc is the type +// to decode multipart request for the "ServiceMultipartValidation" service +// "MethodMultipartValidation" endpoint. +type ServiceMultipartValidationMethodMultipartValidationDecoderFunc func(*multipart.Reader, *MethodMultipartValidationRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden index 80d15bab46..5d1eca1a42 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartArrayType" // service "MethodMultipartArrayType" endpoint. -func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(mux goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(_ goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*[]*servicemultipartarraytype.PayloadType) - if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, p); err != nil { + body := v.(*[]*PayloadTypeRequestBody) + if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden index 604f597f90..d3540d9071 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder returns a decoder to // decode the multipart request for the "ServiceMultipartMapType" service // "MethodMultipartMapType" endpoint. -func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(mux goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(_ goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*map[string]int) - if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, p); err != nil { + body := v.(*map[string]int) + if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden index 1b90418bbc..f6774d6fec 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartPrimitive" // service "MethodMultipartPrimitive" endpoint. -func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(mux goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(_ goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*string) - if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, p); err != nil { + body := v.(*string) + if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden index 55e5dee3f3..cc61211248 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder returns a decoder // to decode the multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(mux goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(_ goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartusertype.MethodMultipartUserTypePayload) - if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartUserTypeRequestBody) + if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden new file mode 100644 index 0000000000..c1bdd3f3d4 --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden @@ -0,0 +1,18 @@ +// NewServiceMultipartValidationMethodMultipartValidationDecoder returns a +// decoder to decode the multipart request for the "ServiceMultipartValidation" +// service "MethodMultipartValidation" endpoint. +func NewServiceMultipartValidationMethodMultipartValidationDecoder(_ goahttp.Muxer, serviceMultipartValidationMethodMultipartValidationDecoderFn ServiceMultipartValidationMethodMultipartValidationDecoderFunc) func(r *http.Request) goahttp.Decoder { + return func(r *http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(v any) error { + mr, merr := r.MultipartReader() + if merr != nil { + return merr + } + body := v.(*MethodMultipartValidationRequestBody) + if err := serviceMultipartValidationMethodMultipartValidationDecoderFn(mr, body); err != nil { + return err + } + return nil + }) + } +} diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden index 673a61e26b..4a5a39c414 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden @@ -1,55 +1,17 @@ // NewServiceMultipartWithParamMethodMultipartWithParamDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartWithParam" // service "MethodMultipartWithParam" endpoint. -func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(mux goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(_ goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparam.PayloadType) - if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamRequestBody) + if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, body); err != nil { return err } - - var ( - c2 map[int][]string - err error - ) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - if err != nil { - return err - } - (*p).C = c2 return nil }) } diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden index 0fae06d72e..804bfb47a1 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden @@ -2,69 +2,17 @@ // returns a decoder to decode the multipart request for the // "ServiceMultipartWithParamsAndHeaders" service // "MethodMultipartWithParamsAndHeaders" endpoint. -func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(mux goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(_ goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparamsandheaders.PayloadType) - if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamsAndHeadersRequestBody) + if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, body); err != nil { return err } - var ( - a string - c2 map[int][]string - b *string - err error - - params = mux.Vars(r) - ) - a = params["a"] - err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - bRaw := r.Header.Get("Authorization") - if bRaw != "" { - b = &bRaw - } - if b != nil { - err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) - } - if err != nil { - return err - } - (*p).A = a - (*p).C = c2 - (*p).B = b return nil }) } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden index d2c02057c4..7148b9f7a6 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-array-user.go.golden @@ -1,13 +1,13 @@ -// NewMethodBodyInlineArrayUserPayload builds a ServiceBodyInlineArrayUser +// NewMethodBodyInlineArrayUserElemType builds a ServiceBodyInlineArrayUser // service MethodBodyInlineArrayUser endpoint payload. -func NewMethodBodyInlineArrayUserPayload(body []*ElemType) []*servicebodyinlinearrayuser.ElemType { +func NewMethodBodyInlineArrayUserElemType(body []*ElemTypeRequestBody) []*servicebodyinlinearrayuser.ElemType { v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) for i, val := range body { if val == nil { v[i] = nil continue } - v[i] = unmarshalElemTypeToElemType(val) + v[i] = unmarshalElemTypeRequestBodyToServicebodyinlinearrayuserElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index 6c570f008c..b2152d20f1 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -1,14 +1,14 @@ -// NewMethodBodyInlineMapUserPayload builds a ServiceBodyInlineMapUser service -// MethodBodyInlineMapUser endpoint payload. -func NewMethodBodyInlineMapUserPayload(body map[*KeyType]*ElemType) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { +// NewMethodBodyInlineMapUserMapKeyTypeElemType builds a +// ServiceBodyInlineMapUser service MethodBodyInlineMapUser endpoint payload. +func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyTypeRequestBody]*ElemTypeRequestBody) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeToKeyType(key) + tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue } - v[tk] = unmarshalElemTypeToElemType(val) + v[tk] = unmarshalElemTypeRequestBodyToServicebodyinlinemapuserElemType(val) } return v } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden index 275086cd2b..ad6f4a9f37 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-recursive-user.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyInlineRecursiveUserPayload builds a +// NewMethodBodyInlineRecursiveUserPayloadType builds a // ServiceBodyInlineRecursiveUser service MethodBodyInlineRecursiveUser // endpoint payload. -func NewMethodBodyInlineRecursiveUserPayload(body *MethodBodyInlineRecursiveUserRequestBody, a string, b *string) *servicebodyinlinerecursiveuser.PayloadType { +func NewMethodBodyInlineRecursiveUserPayloadType(body *MethodBodyInlineRecursiveUserRequestBody, a string, b *string) *servicebodyinlinerecursiveuser.PayloadType { v := &servicebodyinlinerecursiveuser.PayloadType{} - v.C = unmarshalPayloadTypeToPayloadType(body.C) + v.C = unmarshalPayloadTypeRequestBodyToServicebodyinlinerecursiveuserPayloadType(body.C) v.A = a v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden index 43ac8cff0f..6c448d700a 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-path-user-validate.go.golden @@ -1,6 +1,7 @@ -// NewMethodUserBodyPathValidatePayload builds a ServiceBodyPathUserValidate -// service MethodUserBodyPathValidate endpoint payload. -func NewMethodUserBodyPathValidatePayload(body *MethodUserBodyPathValidateRequestBody, b string) *servicebodypathuservalidate.PayloadType { +// NewMethodUserBodyPathValidatePayloadType builds a +// ServiceBodyPathUserValidate service MethodUserBodyPathValidate endpoint +// payload. +func NewMethodUserBodyPathValidatePayloadType(body *MethodUserBodyPathValidateRequestBody, b string) *servicebodypathuservalidate.PayloadType { v := &servicebodypathuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden index c3066cb509..ad367e6d6b 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-path-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyPathUserPayload builds a ServiceBodyPathUser service +// NewMethodBodyPathUserPayloadType builds a ServiceBodyPathUser service // MethodBodyPathUser endpoint payload. -func NewMethodBodyPathUserPayload(body *MethodBodyPathUserRequestBody, b string) *servicebodypathuser.PayloadType { +func NewMethodBodyPathUserPayloadType(body *MethodBodyPathUserRequestBody, b string) *servicebodypathuser.PayloadType { v := &servicebodypathuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden index 1faa5b1eea..dc825b939e 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-path-user-validate.go.golden @@ -1,7 +1,7 @@ -// NewMethodBodyQueryPathUserValidatePayload builds a +// NewMethodBodyQueryPathUserValidatePayloadType builds a // ServiceBodyQueryPathUserValidate service MethodBodyQueryPathUserValidate // endpoint payload. -func NewMethodBodyQueryPathUserValidatePayload(body *MethodBodyQueryPathUserValidateRequestBody, c2 string, b string) *servicebodyquerypathuservalidate.PayloadType { +func NewMethodBodyQueryPathUserValidatePayloadType(body *MethodBodyQueryPathUserValidateRequestBody, c2 string, b string) *servicebodyquerypathuservalidate.PayloadType { v := &servicebodyquerypathuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden index 4f642a9a58..534e69537c 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-path-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyQueryPathUserPayload builds a ServiceBodyQueryPathUser service -// MethodBodyQueryPathUser endpoint payload. -func NewMethodBodyQueryPathUserPayload(body *MethodBodyQueryPathUserRequestBody, c2 string, b *string) *servicebodyquerypathuser.PayloadType { +// NewMethodBodyQueryPathUserPayloadType builds a ServiceBodyQueryPathUser +// service MethodBodyQueryPathUser endpoint payload. +func NewMethodBodyQueryPathUserPayloadType(body *MethodBodyQueryPathUserRequestBody, c2 string, b *string) *servicebodyquerypathuser.PayloadType { v := &servicebodyquerypathuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden index 747436a53f..cbf59b00b7 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union-validate.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyQueryUserUnionValidatePayload builds a +// NewMethodBodyQueryUserUnionValidatePayloadType builds a // ServiceBodyQueryUserUnionValidate service MethodBodyQueryUserUnionValidate // endpoint payload. -func NewMethodBodyQueryUserUnionValidatePayload(body *MethodBodyQueryUserUnionValidateRequestBody, b string) *servicebodyqueryuserunionvalidate.PayloadType { +func NewMethodBodyQueryUserUnionValidatePayloadType(body *MethodBodyQueryUserUnionValidateRequestBody, b string) *servicebodyqueryuserunionvalidate.PayloadType { v := &servicebodyqueryuserunionvalidate.PayloadType{} - v.A = unmarshalUnionToUnion(body.A) + v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionvalidateUnion(body.A) v.B = b return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden index 127df06279..b4f87bd9b8 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyQueryUserUnionPayload builds a ServiceBodyQueryUserUnion +// NewMethodBodyQueryUserUnionPayloadType builds a ServiceBodyQueryUserUnion // service MethodBodyQueryUserUnion endpoint payload. -func NewMethodBodyQueryUserUnionPayload(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { +func NewMethodBodyQueryUserUnionPayloadType(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { v := &servicebodyqueryuserunion.PayloadType{} if body.A != nil { - v.A = unmarshalUnionToUnion(body.A) + v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionUnionOptional(body.A) } v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden index 30a5fa0c93..c94ed6d859 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-validate.go.golden @@ -1,6 +1,7 @@ -// NewMethodBodyQueryUserValidatePayload builds a ServiceBodyQueryUserValidate -// service MethodBodyQueryUserValidate endpoint payload. -func NewMethodBodyQueryUserValidatePayload(body *MethodBodyQueryUserValidateRequestBody, b string) *servicebodyqueryuservalidate.PayloadType { +// NewMethodBodyQueryUserValidatePayloadType builds a +// ServiceBodyQueryUserValidate service MethodBodyQueryUserValidate endpoint +// payload. +func NewMethodBodyQueryUserValidatePayloadType(body *MethodBodyQueryUserValidateRequestBody, b string) *servicebodyqueryuservalidate.PayloadType { v := &servicebodyqueryuservalidate.PayloadType{ A: *body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden index 6f0cc9392b..e80362dac6 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyQueryUserPayload builds a ServiceBodyQueryUser service +// NewMethodBodyQueryUserPayloadType builds a ServiceBodyQueryUser service // MethodBodyQueryUser endpoint payload. -func NewMethodBodyQueryUserPayload(body *MethodBodyQueryUserRequestBody, b *string) *servicebodyqueryuser.PayloadType { +func NewMethodBodyQueryUserPayloadType(body *MethodBodyQueryUserRequestBody, b *string) *servicebodyqueryuser.PayloadType { v := &servicebodyqueryuser.PayloadType{ A: body.A, } diff --git a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden index cbfdc77dd9..2858a747ac 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden @@ -1,6 +1,6 @@ -// NewMethodBodyUnionPayload builds a ServiceBodyUnion service MethodBodyUnion +// NewMethodBodyUnionUnion builds a ServiceBodyUnion service MethodBodyUnion // endpoint payload. -func NewMethodBodyUnionPayload(body *MethodBodyUnionRequestBody) *servicebodyunion.Union { +func NewMethodBodyUnionUnion(body *MethodBodyUnionRequestBody) *servicebodyunion.Union { v := &servicebodyunion.Union{} if body.Values != nil { switch string(body.Values.Kind()) { diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden index f2ec00cc6b..9a92393ca1 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden @@ -1,9 +1,10 @@ -// NewMethodBodyUserInnerDefaultPayload builds a ServiceBodyUserInnerDefault -// service MethodBodyUserInnerDefault endpoint payload. -func NewMethodBodyUserInnerDefaultPayload(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { +// NewMethodBodyUserInnerDefaultPayloadType builds a +// ServiceBodyUserInnerDefault service MethodBodyUserInnerDefault endpoint +// payload. +func NewMethodBodyUserInnerDefaultPayloadType(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { v := &servicebodyuserinnerdefault.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeToInnerType(body.Inner) + v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden index 592ca29245..f7fb24a8c8 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden @@ -1,9 +1,9 @@ -// NewMethodBodyUserInnerPayload builds a ServiceBodyUserInner service +// NewMethodBodyUserInnerPayloadType builds a ServiceBodyUserInner service // MethodBodyUserInner endpoint payload. -func NewMethodBodyUserInnerPayload(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { +func NewMethodBodyUserInnerPayloadType(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { v := &servicebodyuserinner.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeToInnerType(body.Inner) + v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerInnerTypeOptional(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden index ea64f5258f..1e735b8554 100644 --- a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden @@ -1,22 +1,22 @@ // MethodARequestBody is the type of the "ServiceMixedPayloadInBody" service // "MethodA" endpoint HTTP request body. type MethodARequestBody struct { - Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` - Array []float32 `form:"array,omitempty" json:"array,omitempty" xml:"array,omitempty"` - Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` - Object *BPayload `form:"object,omitempty" json:"object,omitempty" xml:"object,omitempty"` - DupObj *BPayload `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` + Any any `form:"any,omitempty" json:"any,omitempty" xml:"any,omitempty"` + Array []float32 `form:"array,omitempty" json:"array,omitempty" xml:"array,omitempty"` + Map map[uint]any `form:"map,omitempty" json:"map,omitempty" xml:"map,omitempty"` + Object *BPayloadRequestBody `form:"object,omitempty" json:"object,omitempty" xml:"object,omitempty"` + DupObj *BPayloadRequestBody `form:"dup_obj,omitempty" json:"dup_obj,omitempty" xml:"dup_obj,omitempty"` } -// BPayload is used to define fields on request body types. -type BPayload struct { +// BPayloadRequestBody is used to define fields on request body types. +type BPayloadRequestBody struct { Int *int `form:"int,omitempty" json:"int,omitempty" xml:"int,omitempty"` Bytes []byte `form:"bytes,omitempty" json:"bytes,omitempty" xml:"bytes,omitempty"` } -// NewMethodAPayload builds a ServiceMixedPayloadInBody service MethodA +// NewMethodAAPayload builds a ServiceMixedPayloadInBody service MethodA // endpoint payload. -func NewMethodAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APayload { +func NewMethodAAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APayload { v := &servicemixedpayloadinbody.APayload{ Any: body.Any, } @@ -32,9 +32,9 @@ func NewMethodAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APay v.Map[tk] = tv } } - v.Object = unmarshalBPayloadToBPayload(body.Object) + v.Object = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayload(body.Object) if body.DupObj != nil { - v.DupObj = unmarshalBPayloadToBPayload2(body.DupObj) + v.DupObj = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayloadOptional(body.DupObj) } return v @@ -49,22 +49,31 @@ func ValidateMethodARequestBody(body *MethodARequestBody) (err error) { err = goa.MergeErrors(err, goa.MissingFieldError("object", "body")) } if body.Object != nil { - if err2 := ValidateBPayload(body.Object); err2 != nil { + if err2 := validateBPayloadRequestBody(body.Object, "body.object"); err2 != nil { err = goa.MergeErrors(err, err2) } } if body.DupObj != nil { - if err2 := ValidateBPayload(body.DupObj); err2 != nil { + if err2 := validateBPayloadRequestBody(body.DupObj, "body.dup_obj"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateBPayload runs the validations defined on BPayload -func ValidateBPayload(body *BPayload) (err error) { +// ValidateBPayloadRequestBody runs the validations defined on BPayload +func ValidateBPayloadRequestBody(body *BPayloadRequestBody) (err error) { if body.Int == nil { err = goa.MergeErrors(err, goa.MissingFieldError("int", "body")) } return } + +// validateBPayloadRequestBody checks BPayload and reports errors using the +// path supplied by its caller +func validateBPayloadRequestBody(body *BPayloadRequestBody, path string) (err error) { + if body.Int == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("int", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden new file mode 100644 index 0000000000..7db641c70f --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden @@ -0,0 +1,64 @@ +// MethodMultipartValidationRequestBody is the type of the +// "ServiceMultipartValidation" service "MethodMultipartValidation" endpoint +// HTTP request body. +type MethodMultipartValidationRequestBody struct { + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Part *MultipartPartRequestBody `form:"part,omitempty" json:"part,omitempty" xml:"part,omitempty"` +} + +// MultipartPartRequestBody is used to define fields on request body types. +type MultipartPartRequestBody struct { + Code *string `form:"code,omitempty" json:"code,omitempty" xml:"code,omitempty"` +} + +// NewMethodMultipartValidationPayload builds a ServiceMultipartValidation +// service MethodMultipartValidation endpoint payload. +func NewMethodMultipartValidationPayload(body *MethodMultipartValidationRequestBody) *servicemultipartvalidation.MethodMultipartValidationPayload { + v := &servicemultipartvalidation.MethodMultipartValidationPayload{ + Name: *body.Name, + } + v.Part = unmarshalMultipartPartRequestBodyToServicemultipartvalidationMultipartPart(body.Part) + + return v +} + +// ValidateMethodMultipartValidationRequestBody runs the validations defined on +// MethodMultipartValidationRequestBody +func ValidateMethodMultipartValidationRequestBody(body *MethodMultipartValidationRequestBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Part == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("part", "body")) + } + if body.Part != nil { + if err2 := validateMultipartPartRequestBody(body.Part, "body.part"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateMultipartPartRequestBody runs the validations defined on +// MultipartPart +func ValidateMultipartPartRequestBody(body *MultipartPartRequestBody) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", "body")) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.code", *body.Code, "^[a-z]+$")) + } + return +} + +// validateMultipartPartRequestBody checks MultipartPart and reports errors +// using the path supplied by its caller +func validateMultipartPartRequestBody(body *MultipartPartRequestBody, path string) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", path)) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".code", *body.Code, "^[a-z]+$")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden index a2df4459a6..0e266e10ea 100644 --- a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden @@ -7,19 +7,19 @@ type MethodARequestBody struct { // MethodBRequestBody is the type of the "ServiceMultipleMethods" service // "MethodB" endpoint HTTP request body. type MethodBRequestBody struct { - A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` - B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` - C *APayload `form:"c,omitempty" json:"c,omitempty" xml:"c,omitempty"` + A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + B *string `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` + C *APayloadRequestBody `form:"c,omitempty" json:"c,omitempty" xml:"c,omitempty"` } -// APayload is used to define fields on request body types. -type APayload struct { +// APayloadRequestBody is used to define fields on request body types. +type APayloadRequestBody struct { A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// NewMethodAPayload builds a ServiceMultipleMethods service MethodA endpoint +// NewMethodAAPayload builds a ServiceMultipleMethods service MethodA endpoint // payload. -func NewMethodAPayload(body *MethodARequestBody) *servicemultiplemethods.APayload { +func NewMethodAAPayload(body *MethodARequestBody) *servicemultiplemethods.APayload { v := &servicemultiplemethods.APayload{ A: body.A, } @@ -27,14 +27,14 @@ func NewMethodAPayload(body *MethodARequestBody) *servicemultiplemethods.APayloa return v } -// NewMethodBPayload builds a ServiceMultipleMethods service MethodB endpoint -// payload. -func NewMethodBPayload(body *MethodBRequestBody) *servicemultiplemethods.PayloadType { +// NewMethodBPayloadType builds a ServiceMultipleMethods service MethodB +// endpoint payload. +func NewMethodBPayloadType(body *MethodBRequestBody) *servicemultiplemethods.PayloadType { v := &servicemultiplemethods.PayloadType{ A: *body.A, B: body.B, } - v.C = unmarshalAPayloadToAPayload(body.C) + v.C = unmarshalAPayloadRequestBodyToServicemultiplemethodsAPayload(body.C) return v } @@ -62,17 +62,26 @@ func ValidateMethodBRequestBody(body *MethodBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.b", *body.B, "patternb")) } if body.C != nil { - if err2 := ValidateAPayload(body.C); err2 != nil { + if err2 := validateAPayloadRequestBody(body.C, "body.c"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateAPayload runs the validations defined on APayload -func ValidateAPayload(body *APayload) (err error) { +// ValidateAPayloadRequestBody runs the validations defined on APayload +func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden index 09ca0a227b..05882b271c 100644 --- a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden +++ b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden @@ -1,11 +1,11 @@ // MethodStreamingBody is the type of the "ServicePayloadValidatedAlias" // service "Method" endpoint HTTP request body. type MethodStreamingBody struct { - Name *ValidatedString `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Name *ValidatedStringStreamingBody `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` } -// ValidatedString is used to define fields on request body types. -type ValidatedString string +// ValidatedStringStreamingBody is used to define fields on request body types. +type ValidatedStringStreamingBody string // NewMethodStreamingBody builds a ServicePayloadValidatedAlias service Method // endpoint payload. @@ -24,8 +24,6 @@ func NewMethodStreamingBody(body *MethodStreamingBody) *servicepayloadvalidateda func ValidateMethodStreamingBody(body *MethodStreamingBody) (err error) { if body.Name != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.name", string(*body.Name), "^[a-zA-Z]+$")) - } - if body.Name != nil { if utf8.RuneCountInString(string(*body.Name)) < 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("body.name", string(*body.Name), utf8.RuneCountInString(string(*body.Name)), 10, true)) } diff --git a/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..7911355343 --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden @@ -0,0 +1,75 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// NewStoreResponseBody builds the HTTP response body from the result of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreResponseBody(res *requiredarrays.StoreResult) *StoreResponseBody { + body := &StoreResponseBody{} + if res.Names != nil { + body.Names = make([]string, len(res.Names)) + for i, val := range res.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if res.Aliases != nil { + body.Aliases = make([]string, len(res.Aliases)) + for i, val := range res.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStorePayload builds a RequiredArrays service Store endpoint payload. +func NewStorePayload(body *StoreRequestBody) *requiredarrays.StorePayload { + v := &requiredarrays.StorePayload{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreRequestBody runs the validations defined on StoreRequestBody +func ValidateStoreRequestBody(body *StoreRequestBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden index 9bdde0ed41..169ff45382 100644 --- a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden @@ -1,15 +1,15 @@ // ClientStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "ClientStream" endpoint HTTP // request body. -type ClientStreamStreamingBody StreamingRequest +type ClientStreamStreamingBody StreamingRequestStreamingBody // BidirectionalStreamStreamingBody is the type of the // "StreamingPayloadRequiredFieldsService" service "BidirectionalStream" // endpoint HTTP request body. -type BidirectionalStreamStreamingBody StreamingRequest +type BidirectionalStreamStreamingBody StreamingRequestStreamingBody -// StreamingRequest is used to define fields on request body types. -type StreamingRequest struct { +// StreamingRequestStreamingBody is used to define fields on request body types. +type StreamingRequestStreamingBody struct { Required *string `form:"required,omitempty" json:"required,omitempty" xml:"required,omitempty"` Optional *string `form:"optional,omitempty" json:"optional,omitempty" xml:"optional,omitempty"` BaseRequired *string `form:"baseRequired,omitempty" json:"baseRequired,omitempty" xml:"baseRequired,omitempty"` @@ -64,8 +64,9 @@ func ValidateBidirectionalStreamStreamingBody(body *BidirectionalStreamStreaming return } -// ValidateStreamingRequest runs the validations defined on StreamingRequest -func ValidateStreamingRequest(body *StreamingRequest) (err error) { +// ValidateStreamingRequestStreamingBody runs the validations defined on +// StreamingRequest +func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) (err error) { if body.Required == nil { err = goa.MergeErrors(err, goa.MissingFieldError("required", "body")) } @@ -74,3 +75,15 @@ func ValidateStreamingRequest(body *StreamingRequest) (err error) { } return } + +// validateStreamingRequestStreamingBody checks StreamingRequest and reports +// errors using the path supplied by its caller +func validateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody, path string) (err error) { + if body.Required == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required", path)) + } + if body.BaseRequired == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("baseRequired", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden index b7fa46630b..bd7ddff0e3 100644 --- a/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-error-custom-pkg.go.golden @@ -1,6 +1,6 @@ // MethodWithErrorCustomPkgErrorNameResponseBody is the type of the // "ServiceWithErrorCustomPkg" service "MethodWithErrorCustomPkg" endpoint HTTP -// response body. +// response body for the "error_name" error. type MethodWithErrorCustomPkgErrorNameResponseBody struct { Name string `form:"name" json:"name" xml:"name"` } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden index 0b1a51656b..288dad2fad 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection-sibling-user-type-fields.go.golden @@ -1,32 +1,33 @@ -// ResulttypesiblingcollectionCollection is the type of the +// ResulttypesiblingcollectionResponseCollection is the type of the // "ServiceResultCollectionUserTypeSibling" service // "MethodResultCollectionUserTypeSibling" endpoint HTTP response body. -type ResulttypesiblingcollectionCollection []*Resulttypesiblingcollection +type ResulttypesiblingcollectionResponseCollection []*ResulttypesiblingcollectionResponse -// Resulttypesiblingcollection is used to define fields on response body types. -type Resulttypesiblingcollection struct { +// ResulttypesiblingcollectionResponse is used to define fields on response +// body types. +type ResulttypesiblingcollectionResponse struct { // Attribute A - A *UserType `json:"a"` + A *UserTypeResponse `json:"a"` // Attribute B - B *UserType `json:"b"` + B *UserTypeResponse `json:"b"` } -// UserType is used to define fields on response body types. -type UserType struct { +// UserTypeResponse is used to define fields on response body types. +type UserTypeResponse struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } -// NewResulttypesiblingcollectionCollection builds the HTTP response body from -// the result of the "MethodResultCollectionUserTypeSibling" endpoint of the -// "ServiceResultCollectionUserTypeSibling" service. -func NewResulttypesiblingcollectionCollection(res serviceresultcollectionusertypesiblingviews.ResulttypesiblingcollectionCollectionView) ResulttypesiblingcollectionCollection { - body := make([]*Resulttypesiblingcollection, len(res)) +// NewResulttypesiblingcollectionResponseCollection builds the HTTP response +// body from the result of the "MethodResultCollectionUserTypeSibling" endpoint +// of the "ServiceResultCollectionUserTypeSibling" service. +func NewResulttypesiblingcollectionResponseCollection(res serviceresultcollectionusertypesiblingviews.ResulttypesiblingcollectionCollectionView) ResulttypesiblingcollectionResponseCollection { + body := make([]*ResulttypesiblingcollectionResponse, len(res)) for i, val := range res { if val == nil { body[i] = nil continue } - body[i] = marshalResulttypesiblingcollectionViewToResulttypesiblingcollection(val) + body[i] = marshalServiceresultcollectionusertypesiblingviewsResulttypesiblingcollectionViewToResulttypesiblingcollectionResponse(val) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden index b306299286..a6b85deffc 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden @@ -2,19 +2,19 @@ // "ServiceResultWithResultCollection" service // "MethodResultWithResultCollection" endpoint HTTP response body. type MethodResultWithResultCollectionResponseBody struct { - A *Resulttype `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + A *ResulttypeResponseBody `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` } -// Resulttype is used to define fields on response body types. -type Resulttype struct { - X RtCollection `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` +// ResulttypeResponseBody is used to define fields on response body types. +type ResulttypeResponseBody struct { + X RtCollectionResponseBody `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } -// RtCollection is used to define fields on response body types. -type RtCollection []*Rt +// RtCollectionResponseBody is used to define fields on response body types. +type RtCollectionResponseBody []*RtResponseBody -// Rt is used to define fields on response body types. -type Rt struct { +// RtResponseBody is used to define fields on response body types. +type RtResponseBody struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -24,7 +24,7 @@ type Rt struct { func NewMethodResultWithResultCollectionResponseBody(res *serviceresultwithresultcollection.MethodResultWithResultCollectionResult) *MethodResultWithResultCollectionResponseBody { body := &MethodResultWithResultCollectionResponseBody{} if res.A != nil { - body.A = marshalResulttypeToResulttype(res.A) + body.A = marshalServiceresultwithresultcollectionResulttypeToResulttypeResponseBodyOptional(res.A) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden index 8676f69f44..f4737636bb 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden @@ -3,19 +3,19 @@ // HTTP response body. type MethodResultUserTypeNestedResponseBody struct { // Outer A - A *UserType `json:"outer_a"` - Nested *Wrapper `form:"nested,omitempty" json:"nested,omitempty" xml:"nested,omitempty"` + A *UserTypeResponseBody `json:"outer_a"` + Nested *WrapperResponseBody `form:"nested,omitempty" json:"nested,omitempty" xml:"nested,omitempty"` } -// UserType is used to define fields on response body types. -type UserType struct { +// UserTypeResponseBody is used to define fields on response body types. +type UserTypeResponseBody struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } -// Wrapper is used to define fields on response body types. -type Wrapper struct { +// WrapperResponseBody is used to define fields on response body types. +type WrapperResponseBody struct { // Inner A - A *UserType `json:"inner_a"` + A *UserTypeResponseBody `json:"inner_a"` } // NewMethodResultUserTypeNestedResponseBody builds the HTTP response body from @@ -24,10 +24,10 @@ type Wrapper struct { func NewMethodResultUserTypeNestedResponseBody(res *serviceresultusertypenestedviews.ResulttypenestedView) *MethodResultUserTypeNestedResponseBody { body := &MethodResultUserTypeNestedResponseBody{} if res.A != nil { - body.A = marshalUserTypeViewToUserType(res.A) + body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserTypeResponseBodyOptional(res.A) } if res.Nested != nil { - body.Nested = marshalWrapperViewToWrapper(res.Nested) + body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapperResponseBodyOptional(res.Nested) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden index 32e964b92a..d656efd8bf 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden @@ -3,13 +3,13 @@ // endpoint HTTP response body. type MethodResultUserTypeSiblingResponseBody struct { // Attribute A - A *UserType `json:"a"` + A *UserTypeResponseBody `json:"a"` // Attribute B - B *UserType `json:"b"` + B *UserTypeResponseBody `json:"b"` } -// UserType is used to define fields on response body types. -type UserType struct { +// UserTypeResponseBody is used to define fields on response body types. +type UserTypeResponseBody struct { U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"` } @@ -19,10 +19,10 @@ type UserType struct { func NewMethodResultUserTypeSiblingResponseBody(res *serviceresultusertypesiblingviews.ResulttypesiblingView) *MethodResultUserTypeSiblingResponseBody { body := &MethodResultUserTypeSiblingResponseBody{} if res.A != nil { - body.A = marshalUserTypeViewToUserType(res.A) + body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(res.A) } if res.B != nil { - body.B = marshalUserTypeViewToUserType2(res.B) + body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(res.B) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden index e953de3f90..60c4cac176 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden @@ -2,12 +2,12 @@ // "ServiceResultWithResultView" service "MethodResultWithResultView" endpoint // HTTP response body. type MethodResultWithResultViewResponseBodyFull struct { - Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` - Rt *Rt `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Rt *RtResponseBody `form:"rt,omitempty" json:"rt,omitempty" xml:"rt,omitempty"` } -// Rt is used to define fields on response body types. -type Rt struct { +// RtResponseBody is used to define fields on response body types. +type RtResponseBody struct { X *string `form:"x,omitempty" json:"x,omitempty" xml:"x,omitempty"` } @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResponseBodyFull(res *serviceresultwithresultv Name: res.Name, } if res.Rt != nil { - body.Rt = marshalRtViewToRt(res.Rt) + body.Rt = marshalServiceresultwithresultviewviewsRtViewToRtResponseBodyOptional(res.Rt) } return body } diff --git a/http/codegen/testdata/golden/sse-all-fields.golden b/http/codegen/testdata/golden/sse-all-fields.golden index 3df667134b..7e3daf3282 100644 --- a/http/codegen/testdata/golden/sse-all-fields.golden +++ b/http/codegen/testdata/golden/sse-all-fields.golden @@ -26,53 +26,13 @@ func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v res := v var data string - var payload any body := NewSSEAllFieldsMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + + byts, err := json.Marshal(body.Data) + if err != nil { + return err } + data = string(byts) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -98,8 +58,8 @@ func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v return err } } - if retry := res.Retry; retry > 0 { - if _, err := fmt.Fprintf(s.w, "retry: %d\n", retry); err != nil { + if retry := res.Retry; retry != nil && *retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", *retry); err != nil { return err } } diff --git a/http/codegen/testdata/golden/sse-bool.golden b/http/codegen/testdata/golden/sse-bool.golden index bb8c678880..a16fa5f3b5 100644 --- a/http/codegen/testdata/golden/sse-bool.golden +++ b/http/codegen/testdata/golden/sse-bool.golden @@ -23,52 +23,12 @@ func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) res := v var data string - var payload any body := res - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + + if body { + data = "true" + } else { + data = "false" } s.once.Do(func() { header := s.w.Header() diff --git a/http/codegen/testdata/golden/sse-client-all-fields.golden b/http/codegen/testdata/golden/sse-client-all-fields.golden index 9ceaf9cb32..7f4c9b54b4 100644 --- a/http/codegen/testdata/golden/sse-client-all-fields.golden +++ b/http/codegen/testdata/golden/sse-client-all-fields.golden @@ -198,28 +198,34 @@ func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *ss continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + event.ID = s.trimHeader(line[len("id:"):]) continue } if bytes.HasPrefix(line, []byte("event:")) { - event.Event = s.trimHeader(len("event:"), line) + event.Event = s.trimHeader(line[len("event:"):]) continue } if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + retryContent := s.trimHeader(line[len("retry:"):]) + + var val int64 + val, err = strconv.ParseInt(retryContent, 10, 0) + if err != nil { + return + } + value := int(val) + event.Retry = &value continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Use user-provided decoder for complex types + // The configured decoder handles structured event data. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), @@ -232,12 +238,8 @@ func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *ss return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEAllFieldsMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEAllFieldsMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-bool.golden b/http/codegen/testdata/golden/sse-client-bool.golden index c2e5a00a96..08e8f45a01 100644 --- a/http/codegen/testdata/golden/sse-client-bool.golden +++ b/http/codegen/testdata/golden/sse-client-bool.golden @@ -197,32 +197,25 @@ func (s *SSEBoolMethodStreamImpl) processEvent(eventData []byte) (event bool, er continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Use user-provided decoder for complex types - respBody := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), - } - err = s.decoder(respBody).Decode(&event) + var val bool + val, err = strconv.ParseBool(dataContent) if err != nil { return } + event = val } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEBoolMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEBoolMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-data-field.golden b/http/codegen/testdata/golden/sse-client-data-field.golden index 830c91bc51..b100a04bd0 100644 --- a/http/codegen/testdata/golden/sse-client-data-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-field.golden @@ -198,24 +198,21 @@ func (s *SSEDataFieldMethodStreamImpl) processEvent(eventData []byte) (event *ss continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - event.Data = dataContent + value := dataContent + event.Data = &value } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEDataFieldMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-data-id-field.golden b/http/codegen/testdata/golden/sse-client-data-id-field.golden index cbb7acf1f3..d41fb8eef9 100644 --- a/http/codegen/testdata/golden/sse-client-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-id-field.golden @@ -198,28 +198,25 @@ func (s *SSEDataIDFieldMethodStreamImpl) processEvent(eventData []byte) (event * continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + event.ID = s.trimHeader(line[len("id:"):]) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - event.Data = dataContent + value := dataContent + event.Data = &value } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataIDFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEDataIDFieldMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-int.golden b/http/codegen/testdata/golden/sse-client-int.golden index e4786f790c..59e45bd850 100644 --- a/http/codegen/testdata/golden/sse-client-int.golden +++ b/http/codegen/testdata/golden/sse-client-int.golden @@ -197,7 +197,7 @@ func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, err continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -214,12 +214,8 @@ func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, err return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEIntMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEIntMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-object.golden b/http/codegen/testdata/golden/sse-client-object.golden index f5dacee53c..fbe69db8e8 100644 --- a/http/codegen/testdata/golden/sse-client-object.golden +++ b/http/codegen/testdata/golden/sse-client-object.golden @@ -198,7 +198,7 @@ func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseob continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -217,12 +217,8 @@ func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseob return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEObjectMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEObjectMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-request-id.golden b/http/codegen/testdata/golden/sse-client-request-id.golden index cb31a59bde..4c5d735a33 100644 --- a/http/codegen/testdata/golden/sse-client-request-id.golden +++ b/http/codegen/testdata/golden/sse-client-request-id.golden @@ -197,7 +197,7 @@ func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event str continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -209,12 +209,8 @@ func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event str return } -// trimHeader removes the header prefix and optional leading space -func (s *SSERequestIDMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSERequestIDMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-string.golden b/http/codegen/testdata/golden/sse-client-string.golden index b418aa697b..7873b7e449 100644 --- a/http/codegen/testdata/golden/sse-client-string.golden +++ b/http/codegen/testdata/golden/sse-client-string.golden @@ -197,7 +197,7 @@ func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -209,12 +209,8 @@ func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEStringMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEStringMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-data-field.golden b/http/codegen/testdata/golden/sse-data-field.golden index f82c1fd502..0a035bd7b1 100644 --- a/http/codegen/testdata/golden/sse-data-field.golden +++ b/http/codegen/testdata/golden/sse-data-field.golden @@ -26,52 +26,13 @@ func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v res := v var data string - var payload any + hasData := true body := NewSSEDataFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false } s.once.Do(func() { header := s.w.Header() @@ -88,7 +49,12 @@ func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v s.attempted = true }) - if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { + return err + } + } + if _, err := fmt.Fprintln(s.w); err != nil { return err } diff --git a/http/codegen/testdata/golden/sse-data-id-field.golden b/http/codegen/testdata/golden/sse-data-id-field.golden index ef5d432250..558ca5ebdc 100644 --- a/http/codegen/testdata/golden/sse-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-data-id-field.golden @@ -26,52 +26,13 @@ func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, res := v var data string - var payload any + hasData := true body := NewSSEDataIDFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false } s.once.Do(func() { header := s.w.Header() @@ -93,7 +54,12 @@ func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, return err } } - if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { + return err + } + } + if _, err := fmt.Fprintln(s.w); err != nil { return err } diff --git a/http/codegen/testdata/golden/sse-int.golden b/http/codegen/testdata/golden/sse-int.golden index 8cd8be349d..d608256209 100644 --- a/http/codegen/testdata/golden/sse-int.golden +++ b/http/codegen/testdata/golden/sse-int.golden @@ -23,53 +23,9 @@ func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) e res := v var data string - var payload any body := res - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) - } + + data = fmt.Sprintf("%d", body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { diff --git a/http/codegen/testdata/golden/sse-object.golden b/http/codegen/testdata/golden/sse-object.golden index d576d17b41..bdd95f0f8b 100644 --- a/http/codegen/testdata/golden/sse-object.golden +++ b/http/codegen/testdata/golden/sse-object.golden @@ -25,53 +25,13 @@ func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *ss res := v var data string - var payload any body := NewSSEObjectMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + + byts, err := json.Marshal(body) + if err != nil { + return err } + data = string(byts) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { diff --git a/http/codegen/testdata/golden/sse-request-id.golden b/http/codegen/testdata/golden/sse-request-id.golden index 6d88b2618f..9806fab2f0 100644 --- a/http/codegen/testdata/golden/sse-request-id.golden +++ b/http/codegen/testdata/golden/sse-request-id.golden @@ -24,53 +24,9 @@ func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v res := v var data string - var payload any body := res - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) - } + + data = string(body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { diff --git a/http/codegen/testdata/golden/sse-string.golden b/http/codegen/testdata/golden/sse-string.golden index 42eac8dbf4..ee7822e07d 100644 --- a/http/codegen/testdata/golden/sse-string.golden +++ b/http/codegen/testdata/golden/sse-string.golden @@ -24,53 +24,9 @@ func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v str res := v var data string - var payload any body := res - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) - } + + data = string(body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { diff --git a/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden new file mode 100644 index 0000000000..a2e4081eeb --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden @@ -0,0 +1,41 @@ +// marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBodyOptional +// builds a value of type *InnerTypeRequestBody from a value of type +// *servicebodyuserinnerdefault.InnerType. +func marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBodyOptional(v *servicebodyuserinnerdefault.InnerType) *InnerTypeRequestBody { + if v == nil { + return nil + } + res := &InnerTypeRequestBody{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "defaultb" + } + } + + return res +} + +// marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional +// builds a value of type *servicebodyuserinnerdefault.InnerType from a value +// of type *InnerTypeRequestBody. +func marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional(v *InnerTypeRequestBody) *servicebodyuserinnerdefault.InnerType { + if v == nil { + return nil + } + res := &servicebodyuserinnerdefault.InnerType{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "defaultb" + } + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden new file mode 100644 index 0000000000..6bf068fc26 --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden @@ -0,0 +1,23 @@ +// unmarshalSharedChildRequestBodyToSharedhelpersSharedChild builds a value of +// type *sharedhelpers.SharedChild from a value of type *SharedChildRequestBody. +func unmarshalSharedChildRequestBodyToSharedhelpersSharedChild(v *SharedChildRequestBody) *sharedhelpers.SharedChild { + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} + +// unmarshalSharedChildRequestBodyToSharedhelpersSharedChildOptional builds a +// value of type *sharedhelpers.SharedChild from a value of type +// *SharedChildRequestBody. +func unmarshalSharedChildRequestBodyToSharedhelpersSharedChildOptional(v *SharedChildRequestBody) *sharedhelpers.SharedChild { + if v == nil { + return nil + } + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden new file mode 100644 index 0000000000..732ce9fe8c --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden @@ -0,0 +1,13 @@ +// marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional +// builds a value of type *UserTypeResponseBody from a value of type +// *serviceresultusertypesiblingviews.UserTypeView. +func marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(v *serviceresultusertypesiblingviews.UserTypeView) *UserTypeResponseBody { + if v == nil { + return nil + } + res := &UserTypeResponseBody{ + U: v.U, + } + + return res +} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden index dd38e7e8eb..654be71931 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden @@ -59,7 +59,7 @@ func (s *BidirectionalComplexClientStream) Recv() (*testservice.Response, error) if err != nil { return rv, err } - res := NewBidirectionalComplexResultOK(&body) + res := NewBidirectionalComplexResponseOK(&body) return res, nil } diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden index f70ffba2d2..268a18adc0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden @@ -31,9 +31,6 @@ type ConnConfigurer struct { type BidirectionalWithViewsClientStream struct { // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Request result type before sending to - // the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -49,7 +46,7 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, error) { var ( rv *testservice.Response - body BidirectionalWithViewsResponseBody + body BidirectionalWithViewsResponseBodyMinimal err error ) err = s.conn.ReadJSON(&body) @@ -59,8 +56,8 @@ func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, erro if err != nil { return rv, err } - res := NewBidirectionalWithViewsResultOK(&body) - vres := &testserviceviews.Response{Projected: res, View: s.view} + res := NewBidirectionalWithViewsResponseOK(&body) + vres := &testserviceviews.Response{Projected: res, View: "minimal"} if err := testserviceviews.ValidateResponse(vres); err != nil { return rv, goahttp.ErrValidationError("TestService", "BidirectionalWithViews", err) } @@ -95,9 +92,3 @@ func (s *BidirectionalWithViewsClientStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Request type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsClientStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden index 22f5c6970d..2a820a81da 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden @@ -44,9 +44,6 @@ type BidirectionalWithViewsServerStream struct { r *http.Request // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Response result type before sending - // to the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -65,10 +62,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -81,14 +76,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedResponse(v, s.view) - var body any - switch s.view { - case "default", "": - body = NewBidirectionalWithViewsResponseBody(res.Projected) - case "minimal": - body = NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) - } + res := testservice.NewViewedResponse(v, "minimal") + body := NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) return s.conn.WriteJSON(body) } @@ -159,9 +148,3 @@ func (s *BidirectionalWithViewsServerStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Response type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsServerStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden index 8cc2142ed3..9530157aa0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden @@ -47,6 +47,9 @@ type StreamUserWithViewsServerStream struct { // view is the view to render testservice.User result type before sending to // the websocket connection. view string + // sentView is the result view named during the WebSocket upgrade. Later sends + // must use the same view. + sentView string } // NewConnConfigurer initializes the websocket connection configurer function @@ -60,13 +63,26 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { // Send streams instances of "testservice.User" to the "StreamUserWithViews" // endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -81,15 +97,19 @@ func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedUser(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "default", "": - body = NewStreamUserWithViewsResponseBody(res.Projected) + res := testservice.NewViewedUser(v, "default") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBody(res.Projected)) case "tiny": - body = NewStreamUserWithViewsResponseBodyTiny(res.Projected) + res := testservice.NewViewedUser(v, "tiny") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBodyTiny(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of "testservice.User" to the diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go index e58211fdfe..d53ff2513a 100644 --- a/http/codegen/testdata/openapi_dsls.go +++ b/http/codegen/testdata/openapi_dsls.go @@ -146,6 +146,49 @@ var ExplicitViewDSL = func() { }) } +// ReleasedResponseCollectionNamesDSL exercises the public OpenAPI component +// names for response collections whose elements use fixed views. +var ReleasedResponseCollectionNamesDSL = func() { + var StoredBottle = ResultType("application/vnd.stored-bottle", func() { + TypeName("StoredBottle") + Attributes(func() { + Attribute("name", String, func() { + Example("Blue's Cuvee") + }) + Attribute("vintage", UInt32, func() { + Example(2003) + }) + Required("name", "vintage") + }) + View("default", func() { + Attribute("name") + Attribute("vintage") + }) + View("tiny", func() { + Attribute("name") + }) + }) + + Service("storage", func() { + Method("list_default", func() { + Result(CollectionOf(StoredBottle), func() { + View("default") + }) + HTTP(func() { + GET("/default") + }) + }) + Method("list_tiny", func() { + Result(CollectionOf(StoredBottle), func() { + View("tiny") + }) + HTTP(func() { + GET("/tiny") + }) + }) + }) +} + var InvalidDSL = func() { var _ = API("test", func() { Server("test", func() { @@ -264,20 +307,26 @@ var IntValidationDSL = func() { var ArrayValidationDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { MinLength(0) MaxLength(42) - Example("") + Example("item") }) }) var FooBar = Type("foobar", func() { - Attribute("foo", ArrayOf(String), func() { + Example(Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}) + Attribute("foo", ArrayOf(String, func() { + Example("item") + }), func() { MinLength(0) MaxLength(42) + Example([]any{"item"}) }) Attribute("bar", ArrayOf(Bar), func() { MinLength(0) MaxLength(42) + Example([]any{Val{"string": "item"}}) }) }) var _ = API("test", func() { @@ -289,7 +338,9 @@ var ArrayValidationDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Payload(ArrayOf(FooBar)) + Payload(ArrayOf(FooBar), func() { + Example([]any{Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}}) + }) Result(String, func() { MinLength(0) MaxLength(42) @@ -504,6 +555,7 @@ var ServerHostWithVariablesDSL = func() { var WithSpacesDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { Example("") }) @@ -513,12 +565,16 @@ var WithSpacesDSL = func() { Attribute("foo", String, func() { Example("") }) - Attribute("bar", ArrayOf(Bar)) + Attribute("bar", ArrayOf(Bar), func() { + Example([]any{Val{"string": "item"}}) + }) }) Service("test service", func() { Method("test endpoint", func() { Payload(Bar) - Result(FooBar) + Result(FooBar, func() { + Example(Val{"foo": "", "bar": []any{Val{"string": "item"}}}) + }) HTTP(func() { POST("/") Response(StatusOK) @@ -580,25 +636,33 @@ var WithAnyDSL = func() { Service("testService", func() { Method("testEndpoint", func() { Payload(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) }) Result(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) @@ -614,7 +678,9 @@ var PathWithWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { POST("/{*int_map}") @@ -627,8 +693,12 @@ var PathWithMultipleWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -644,8 +714,12 @@ var PathWithMultipleExplicitWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -663,8 +737,12 @@ var HeadersDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/") @@ -687,7 +765,9 @@ var WithTagsDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("openapi:tag:SomeTag") @@ -732,7 +812,9 @@ var WithTagsSwaggerDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("swagger:tag:SomeTag") @@ -872,7 +954,9 @@ var NotGenerateServerDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -891,7 +975,9 @@ var NotGenerateHostDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -1167,7 +1253,9 @@ var OpenAPIInvalidVersionDSL = func() { var TypeExtensionDSL = func() { var Notification = Type("Notification", func() { Meta("openapi:extension:x-test-include", "true") - Attribute("id", String) + Attribute("id", String, func() { + Example("notice") + }) }) Service("testService", func() { Method("testEndpoint", func() { @@ -1184,10 +1272,14 @@ var AliasTypeDSL = func() { var Stage = Type("Stage", String, func() { Description("Setup stage.") Enum("who", "when", "where", "what") + Example("who") }) var Setup = Type("Setup", func() { + Example(Val{"current": "who", "completed": []any{"when"}}) Attribute("current", Stage) - Attribute("completed", ArrayOf(Stage)) + Attribute("completed", ArrayOf(Stage), func() { + Example([]any{"when"}) + }) }) Service("testService", func() { Method("testEndpoint", func() { diff --git a/http/codegen/testdata/payload_dsls.go b/http/codegen/testdata/payload_dsls.go index ddd9359bf1..8d0945739b 100644 --- a/http/codegen/testdata/payload_dsls.go +++ b/http/codegen/testdata/payload_dsls.go @@ -3075,6 +3075,28 @@ var PayloadMultipartUserTypeDSL = func() { }) } +var PayloadMultipartValidationDSL = func() { + var Part = Type("MultipartPart", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Required("code") + }) + Service("ServiceMultipartValidation", func() { + Method("MethodMultipartValidation", func() { + Payload(func() { + Attribute("name", String) + Attribute("part", Part) + Required("name", "part") + }) + HTTP(func() { + POST("/") + MultipartRequest() + }) + }) + }) +} + var PayloadMultipartArrayTypeDSL = func() { var PayloadType = Type("PayloadType", func() { Attribute("a", String, func() { @@ -3142,7 +3164,8 @@ var PayloadMultipartWithParamsAndHeadersDSL = func() { Pattern("patternb") }) Attribute("c", MapOf(Int, ArrayOf(String))) - Required("a", "c") + Attribute("d", String) + Required("a", "c", "d") }) Service("ServiceMultipartWithParamsAndHeaders", func() { Method("MethodMultipartWithParamsAndHeaders", func() { diff --git a/http/codegen/testdata/required_array_dsls.go b/http/codegen/testdata/required_array_dsls.go new file mode 100644 index 0000000000..b2b32f080d --- /dev/null +++ b/http/codegen/testdata/required_array_dsls.go @@ -0,0 +1,32 @@ +// This file defines the HTTP service used to verify required primitive array +// elements in generated request and response types. +package testdata + +import ( + . "goa.design/goa/v3/dsl" +) + +// RequiredPrimitiveArrayDSL defines primitive and named primitive arrays whose +// JSON elements must not be null. +var RequiredPrimitiveArrayDSL = func() { + alias := Type("RequiredArrayAlias", String, func() { + Pattern("^[a-z]+$") + }) + Service("RequiredArrays", func() { + Method("Store", func() { + Payload(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + Result(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + HTTP(func() { + POST("/required-arrays") + }) + }) + }) +} diff --git a/http/codegen/testdata/result_decode_functions.go b/http/codegen/testdata/result_decode_functions.go deleted file mode 100644 index 6e08aae07d..0000000000 --- a/http/codegen/testdata/result_decode_functions.go +++ /dev/null @@ -1,936 +0,0 @@ -package testdata - -var EmptyServerResponseDecodeCode = `// DecodeMethodEmptyServerResponseResponse returns a decoder for responses -// returned by the ServiceEmptyServerResponse MethodEmptyServerResponse -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - res := NewMethodEmptyServerResponseResultOK() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) - } - } -} -` - -var ResultBodyMultipleViewsDecodeCode = `// DecodeMethodBodyMultipleViewResponse returns a decoder for responses -// returned by the ServiceBodyMultipleView MethodBodyMultipleView endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodBodyMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - res := servicebodymultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyBodyResultMultipleViewsDecodeCode = `// DecodeMethodEmptyBodyResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceEmptyBodyResultMultipleView -// MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) - view := resp.Header.Get("goa-view") - vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyPrimitiveResultDecodeCode = `// DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse returns a -// decoder for responses returned by the -// ServiceExplicitBodyPrimitiveResultMultipleView -// MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body string - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - if utf8.RuneCountInString(body) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("body", body, utf8.RuneCountInString(body), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyUserResultMultipleViewsDecodeCode = `// DecodeMethodExplicitBodyUserResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceExplicitBodyUserResultMultipleView -// MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodExplicitBodyUserResultMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyResultCollectionDecodeCode = `// DecodeMethodExplicitBodyResultCollectionResponse returns a decoder for -// responses returned by the ServiceExplicitBodyResultCollection -// MethodExplicitBodyResultCollection endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body ResulttypeCollection - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - err = ValidateResulttypeCollection(body) - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - res := NewMethodExplicitBodyResultCollectionResultOK(body) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) - } - } -} -` - -var ResultMultipleViewsTagDecodeCode = `// DecodeMethodTagMultipleViewsResponse returns a decoder for responses -// returned by the ServiceTagMultipleViews MethodTagMultipleViews endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusAccepted: - var ( - body MethodTagMultipleViewsAcceptedResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("C") - if cRaw != "" { - c = &cRaw - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) - tmp := "value" - p.B = &tmp - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - case http.StatusOK: - var ( - body MethodTagMultipleViewsOKResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyServerResponseWithTagsDecodeCode = `// DecodeMethodEmptyServerResponseWithTagsResponse returns a decoder for -// responses returned by the ServiceEmptyServerResponseWithTags -// MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusNotModified: - res := NewMethodEmptyServerResponseWithTagsResultNotModified() - res.H = "true" - return res, nil - case http.StatusNoContent: - res := NewMethodEmptyServerResponseWithTagsResultNoContent() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringImplicitResponseDecodeCode = `// DecodeMethodHeaderStringImplicitResponse returns a decoder for responses -// returned by the ServiceHeaderStringImplicit MethodHeaderStringImplicit -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - h string - err error - ) - hRaw := resp.Header.Get("H") - if hRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("h", "header")) - } - h = hRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) - } - return h, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - ) - array = resp.Header["Array"] - - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody -// controls whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - err error - ) - array = resp.Header["Array"] - - if len(array) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("array", array, len(array), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []uint - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]uint, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of unsigned integers")) - } - array[i] = uint(v) - } - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []int - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]int, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of integers")) - } - array[i] = int(v) - } - } - } - for _, e := range array { - if e < 5 { - err = goa.MergeErrors(err, goa.InvalidRangeError("array[*]", e, 5, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", err) - } - res := NewMethodAResultOK(required, optional, optionalButRequired) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockViewedResultResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) - } - p := NewMethodAAResultOK(required, optional, optionalButRequired) - view := resp.Header.Get("goa-view") - vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} - res := servicewithheadersblockviewedresult.NewAResult(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ValidateErrorResponseTypeDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ValidateErrorResponseType MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -// DecodeMethodAResponse may return the following errors: -// - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest -// - error: internal error -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - p := NewMethodAAResultOK(required) - view := "default" - vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} - res := validateerrorresponsetype.NewAResult(vres) - return res, nil - case http.StatusBadRequest: - var ( - error_ string - numOccur *int - err error - ) - error_Raw := resp.Header.Get("X-Application-Error") - if error_Raw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("error", "header")) - } - error_ = error_Raw - { - numOccurRaw := resp.Header.Get("X-Occur") - if numOccurRaw != "" { - v, err2 := strconv.ParseInt(numOccurRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("num_occur", numOccurRaw, "integer")) - } - pv := int(v) - numOccur = &pv - } - } - if numOccur != nil { - if *numOccur < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("num_occur", *numOccur, 1, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - return nil, NewMethodASomeError(error_, numOccur) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyErrorResponseBodyDecodeCode = `// DecodeMethodEmptyErrorResponseBodyResponse returns a decoder for responses -// returned by the ServiceEmptyErrorResponseBody MethodEmptyErrorResponseBody -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -// DecodeMethodEmptyErrorResponseBodyResponse may return the following errors: -// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError -// - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound -// - error: internal error -func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - return nil, nil - case http.StatusInternalServerError: - var ( - name string - id string - message string - temporary bool - timeout bool - fault bool - err error - ) - nameRaw := resp.Header.Get("Error-Name") - if nameRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("name", "header")) - } - name = nameRaw - idRaw := resp.Header.Get("Goa-Attribute-Id") - if idRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "header")) - } - id = idRaw - messageRaw := resp.Header.Get("Goa-Attribute-Message") - if messageRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("message", "header")) - } - message = messageRaw - { - temporaryRaw := resp.Header.Get("Goa-Attribute-Temporary") - if temporaryRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("temporary", "header")) - } - v, err2 := strconv.ParseBool(temporaryRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("temporary", temporaryRaw, "boolean")) - } - temporary = v - } - { - timeoutRaw := resp.Header.Get("Goa-Attribute-Timeout") - if timeoutRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("timeout", "header")) - } - v, err2 := strconv.ParseBool(timeoutRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("timeout", timeoutRaw, "boolean")) - } - timeout = v - } - { - faultRaw := resp.Header.Get("Goa-Attribute-Fault") - if faultRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("fault", "header")) - } - v, err2 := strconv.ParseBool(faultRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("fault", faultRaw, "boolean")) - } - fault = v - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyInternalError(name, id, message, temporary, timeout, fault) - case http.StatusNotFound: - var ( - inHeader string - err error - ) - inHeaderRaw := resp.Header.Get("In-Header") - if inHeaderRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("in-header", "header")) - } - inHeader = inHeaderRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) - } - } -} -` diff --git a/http/codegen/testdata/shared_error_description_dsl.go b/http/codegen/testdata/shared_error_description_dsl.go new file mode 100644 index 0000000000..4e90f5c10b --- /dev/null +++ b/http/codegen/testdata/shared_error_description_dsl.go @@ -0,0 +1,63 @@ +// This file defines HTTP designs that reuse one named error type from several +// methods so OpenAPI tests can distinguish type text from response text. +package testdata + +import . "goa.design/goa/v3/dsl" + +var ( + // SharedErrorDescriptionDSL describes the shared type and declares the first + // method before the second method. + SharedErrorDescriptionDSL = sharedErrorDescriptionDSL(false, "Shared error value") + + // ReversedSharedErrorDescriptionDSL declares the same methods in reverse + // order to prove that method order does not change the shared schema. + ReversedSharedErrorDescriptionDSL = sharedErrorDescriptionDSL(true, "Shared error value") + + // UndescribedSharedErrorDSL leaves the shared type without a description so + // a method description cannot become the shared schema description. + UndescribedSharedErrorDSL = sharedErrorDescriptionDSL(false, "") +) + +// sharedErrorDescriptionDSL returns a design with two method errors that share +// one type but explain different failures to callers. +func sharedErrorDescriptionDSL(reverse bool, typeDescription string) func() { + return func() { + sharedError := Type("SharedError", func() { + if typeDescription != "" { + Description(typeDescription) + } + Attribute("message", String, "Error message", func() { + Example("shared failure") + }) + Required("message") + }) + + Service("errors", func() { + first := func() { + Method("first", func() { + Error("first_error", sharedError, "First failure") + HTTP(func() { + GET("/first") + Response("first_error", StatusBadRequest) + }) + }) + } + second := func() { + Method("second", func() { + Error("second_error", sharedError, "Second failure") + HTTP(func() { + GET("/second") + Response("second_error", StatusBadRequest) + }) + }) + } + if reverse { + second() + first() + return + } + first() + second() + }) + } +} diff --git a/http/codegen/testdata/sse_dsls.go b/http/codegen/testdata/sse_dsls.go index d7de8bbf2b..ff84f9bf21 100644 --- a/http/codegen/testdata/sse_dsls.go +++ b/http/codegen/testdata/sse_dsls.go @@ -7,7 +7,9 @@ import ( var SSEStringDSL = func() { Service("SSEStringService", func() { Method("SSEStringMethod", func() { - StreamingResult(String) + StreamingResult(String, func() { + Example("event") + }) HTTP(func() { GET("/string") ServerSentEvents() @@ -44,9 +46,15 @@ var SSEObjectDSL = func() { Service("SSEObjectService", func() { Method("SSEObjectMethod", func() { StreamingResult(func() { - Attribute("id", String) - Attribute("value", Int) - Attribute("flag", Boolean) + Attribute("id", String, func() { + Example("event") + }) + Attribute("value", Int, func() { + Example(1) + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/object") @@ -60,8 +68,12 @@ var SSEDataFieldDSL = func() { Service("SSEDataFieldService", func() { Method("SSEDataFieldMethod", func() { StreamingResult(func() { - Attribute("data", String) - Attribute("flag", Boolean) + Attribute("data", String, func() { + Example("event") + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/data-field") @@ -92,9 +104,13 @@ var SSERequestIDDSL = func() { Service("SSERequestIDService", func() { Method("SSERequestIDMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) + }) + StreamingResult(String, func() { + Example("event") }) - StreamingResult(String) HTTP(func() { GET("/request-id") ServerSentEvents(func() { @@ -109,7 +125,9 @@ var SSEAllFieldsDSL = func() { Service("SSEAllFieldsService", func() { Method("SSEAllFieldsMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) }) StreamingResult(func() { Attribute("id", String, func() { diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index 9e02d59e06..43522d84d9 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -241,13 +241,27 @@ func (s *StreamingResultMethodServerStream) Close() error { var StreamingResultWithViewsServerStreamSendCode = `// Send streams instances of "streamingresultwithviewsservice.Usertype" to the // "StreamingResultWithViewsMethod" endpoint websocket connection. func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwithviewsservice.Usertype) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -262,17 +276,22 @@ func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwith if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected)) case "extended": - body = NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "extended") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected)) case "default", "": - body = NewStreamingResultWithViewsMethodResponseBody(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "default") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBody(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -419,7 +438,7 @@ func (s *StreamingResultMethodClientStream) Recv() (*streamingresultservice.User if err != nil { return rv, err } - res := NewStreamingResultMethodResultOK(&body) + res := NewStreamingResultMethodUserTypeOK(&body) return res, nil } @@ -487,7 +506,7 @@ func (s *StreamingResultWithViewsMethodClientStream) Recv() (*streamingresultwit if err != nil { return rv, err } - res := NewStreamingResultWithViewsMethodResultOK(&body) + res := NewStreamingResultWithViewsMethodUsertypeOK(&body) vres := &streamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} if err := streamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultWithViewsService", "StreamingResultWithViewsMethod", err) @@ -566,7 +585,7 @@ func (s *StreamingResultWithExplicitViewMethodClientStream) Recv() (*streamingre if err != nil { return rv, err } - res := NewStreamingResultWithExplicitViewMethodResultOK(&body) + res := NewStreamingResultWithExplicitViewMethodUsertypeOK(&body) vres := &streamingresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := streamingresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultWithExplicitViewService", "StreamingResultWithExplicitViewMethod", err) @@ -623,13 +642,27 @@ var StreamingResultCollectionWithViewsServerStreamSendCode = `// Send streams in // "streamingresultcollectionwithviewsservice.UsertypeCollection" to the // "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingresultcollectionwithviewsservice.UsertypeCollection) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -644,17 +677,22 @@ func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingr if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewUsertypeTinyCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + return s.conn.WriteJSON(NewUsertypeResponseTinyCollection(res.Projected)) case "extended": - body = NewUsertypeExtendedCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "extended") + return s.conn.WriteJSON(NewUsertypeResponseExtendedCollection(res.Projected)) case "default", "": - body = NewUsertypeCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "default") + return s.conn.WriteJSON(NewUsertypeResponseCollection(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -681,7 +719,7 @@ var StreamingResultCollectionWithViewsClientStreamRecvCode = `// Recv reads inst func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv streamingresultcollectionwithviewsservice.UsertypeCollection - body StreamingResultCollectionWithViewsMethodResponseBody + body UsertypeResponseCollection err error ) err = s.conn.ReadJSON(&body) @@ -692,7 +730,7 @@ func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streaming if err != nil { return rv, err } - res := NewStreamingResultCollectionWithViewsMethodResultOK(body) + res := NewStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) @@ -741,7 +779,7 @@ func (s *StreamingResultCollectionWithExplicitViewMethodServerStream) Send(v str return s.upgradeErr } res := streamingresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeTinyCollection(res.Projected) + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -799,7 +837,7 @@ var StreamingResultCollectionWithExplicitViewClientStreamRecvCode = `// Recv rea func (s *StreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (streamingresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv streamingresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeTinyCollection + body UsertypeResponseTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -810,7 +848,7 @@ func (s *StreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (st if err != nil { return rv, err } - res := NewStreamingResultCollectionWithExplicitViewMethodResultOK(body) + res := NewStreamingResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) vres := streamingresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingResultCollectionWithExplicitViewService", "StreamingResultCollectionWithExplicitViewMethod", err) @@ -1049,7 +1087,7 @@ var StreamingResultUserTypeArrayClientStreamRecvCode = `// Recv reads instances func (s *StreamingResultUserTypeArrayMethodClientStream) Recv() ([]*streamingresultusertypearrayservice.UserType, error) { var ( rv []*streamingresultusertypearrayservice.UserType - body []*UserType + body []*UserTypeResponse err error ) err = s.conn.ReadJSON(&body) @@ -1060,7 +1098,7 @@ func (s *StreamingResultUserTypeArrayMethodClientStream) Recv() ([]*streamingres if err != nil { return rv, err } - res := NewStreamingResultUserTypeArrayMethodResultOK(body) + res := NewStreamingResultUserTypeArrayMethodUserTypeOK(body) return res, nil } @@ -1116,7 +1154,7 @@ var StreamingResultUserTypeMapClientStreamRecvCode = `// Recv reads instances of func (s *StreamingResultUserTypeMapMethodClientStream) Recv() (map[string]*streamingresultusertypemapservice.UserType, error) { var ( rv map[string]*streamingresultusertypemapservice.UserType - body map[string]*UserType + body map[string]*UserTypeResponse err error ) err = s.conn.ReadJSON(&body) @@ -1127,7 +1165,7 @@ func (s *StreamingResultUserTypeMapMethodClientStream) Recv() (map[string]*strea if err != nil { return rv, err } - res := NewStreamingResultUserTypeMapMethodResultOK(body) + res := NewStreamingResultUserTypeMapMethodMapStringUserTypeOK(body) return res, nil } @@ -1371,7 +1409,7 @@ func (s *StreamingPayloadMethodClientStream) CloseAndRecv() (*streamingpayloadse if err != nil { return rv, err } - res := NewStreamingPayloadMethodResultOK(&body) + res := NewStreamingPayloadMethodUserTypeOK(&body) return res, nil } @@ -1504,7 +1542,7 @@ func (s *StreamingPayloadNoPayloadMethodClientStream) CloseAndRecv() (*streaming if err != nil { return rv, err } - res := NewStreamingPayloadNoPayloadMethodResultOK(&body) + res := NewStreamingPayloadNoPayloadMethodUserTypeOK(&body) return res, nil } @@ -1608,16 +1646,8 @@ var StreamingPayloadResultWithViewsServerStreamSendCode = `// SendAndClose strea // closes the connection. func (s *StreamingPayloadResultWithViewsMethodServerStream) SendAndClose(v *streamingpayloadresultwithviewsservice.Usertype) error { defer s.conn.Close() - res := streamingpayloadresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewStreamingPayloadResultWithViewsMethodResponseBody(res.Projected) - } + res := streamingpayloadresultwithviewsservice.NewViewedUsertype(v, "tiny") + body := NewStreamingPayloadResultWithViewsMethodResponseBodyTiny(res.Projected) return s.conn.WriteJSON(body) } @@ -1702,7 +1732,7 @@ var StreamingPayloadResultWithViewsClientStreamRecvCode = `// CloseAndRecv stops func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*streamingpayloadresultwithviewsservice.Usertype, error) { var ( rv *streamingpayloadresultwithviewsservice.Usertype - body StreamingPayloadResultWithViewsMethodResponseBody + body StreamingPayloadResultWithViewsMethodResponseBodyTiny err error ) defer s.conn.Close() @@ -1718,8 +1748,8 @@ func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*str if err != nil { return rv, err } - res := NewStreamingPayloadResultWithViewsMethodResultOK(&body) - vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} + res := NewStreamingPayloadResultWithViewsMethodUsertypeOK(&body) + vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: "tiny"} if err := streamingpayloadresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultWithViewsService", "StreamingPayloadResultWithViewsMethod", err) } @@ -1842,7 +1872,7 @@ func (s *StreamingPayloadResultWithExplicitViewMethodClientStream) CloseAndRecv( if err != nil { return rv, err } - res := NewStreamingPayloadResultWithExplicitViewMethodResultOK(&body) + res := NewStreamingPayloadResultWithExplicitViewMethodUsertypeOK(&body) vres := &streamingpayloadresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := streamingpayloadresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultWithExplicitViewService", "StreamingPayloadResultWithExplicitViewMethod", err) @@ -1866,16 +1896,8 @@ var StreamingPayloadResultCollectionWithViewsServerStreamSendCode = `// SendAndC // connection and closes the connection. func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithviewsservice.UsertypeCollection) error { defer s.conn.Close() - res := streamingpayloadresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeTinyCollection(res.Projected) - case "extended": - body = NewUsertypeExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeCollection(res.Projected) - } + res := streamingpayloadresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -1964,7 +1986,7 @@ var StreamingPayloadResultCollectionWithViewsClientStreamRecvCode = `// CloseAnd func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRecv() (streamingpayloadresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv streamingpayloadresultcollectionwithviewsservice.UsertypeCollection - body StreamingPayloadResultCollectionWithViewsMethodResponseBody + body UsertypeResponseTinyCollection err error ) defer s.conn.Close() @@ -1980,8 +2002,8 @@ func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRe if err != nil { return rv, err } - res := NewStreamingPayloadResultCollectionWithViewsMethodResultOK(body) - vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} + res := NewStreamingPayloadResultCollectionWithViewsMethodUsertypeCollectionOK(body) + vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingpayloadresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithViewsService", "StreamingPayloadResultCollectionWithViewsMethod", err) } @@ -2013,7 +2035,7 @@ var StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode = `// S func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { defer s.conn.Close() res := streamingpayloadresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeTinyCollection(res.Projected) + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -2093,7 +2115,7 @@ var StreamingPayloadResultCollectionWithExplicitViewClientStreamRecvCode = `// C func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) CloseAndRecv() (streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeTinyCollection + body UsertypeResponseTinyCollection err error ) defer s.conn.Close() @@ -2109,7 +2131,7 @@ func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) Clo if err != nil { return rv, err } - res := NewStreamingPayloadResultCollectionWithExplicitViewMethodResultOK(body) + res := NewStreamingPayloadResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) vres := streamingpayloadresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingpayloadresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithExplicitViewService", "StreamingPayloadResultCollectionWithExplicitViewMethod", err) @@ -2893,7 +2915,7 @@ func (s *BidirectionalStreamingMethodClientStream) Recv() (*bidirectionalstreami if err != nil { return rv, err } - res := NewBidirectionalStreamingMethodResultOK(&body) + res := NewBidirectionalStreamingMethodUserTypeOK(&body) return res, nil } @@ -3052,7 +3074,7 @@ func (s *BidirectionalStreamingNoPayloadMethodClientStream) Recv() (*bidirection if err != nil { return rv, err } - res := NewBidirectionalStreamingNoPayloadMethodResultOK(&body) + res := NewBidirectionalStreamingNoPayloadMethodUserTypeOK(&body) return res, nil } @@ -3086,10 +3108,8 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Send(v *bidire // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -3102,16 +3122,8 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Send(v *bidire if s.upgradeErr != nil { return s.upgradeErr } - res := bidirectionalstreamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBody(res.Projected) - } + res := bidirectionalstreamingresultwithviewsservice.NewViewedUsertype(v, "tiny") + body := NewBidirectionalStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) return s.conn.WriteJSON(body) } @@ -3214,7 +3226,7 @@ var BidirectionalStreamingResultWithViewsClientStreamRecvCode = `// Recv reads i func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidirectionalstreamingresultwithviewsservice.Usertype, error) { var ( rv *bidirectionalstreamingresultwithviewsservice.Usertype - body BidirectionalStreamingResultWithViewsMethodResponseBody + body BidirectionalStreamingResultWithViewsMethodResponseBodyTiny err error ) err = s.conn.ReadJSON(&body) @@ -3224,8 +3236,8 @@ func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidir if err != nil { return rv, err } - res := NewBidirectionalStreamingResultWithViewsMethodResultOK(&body) - vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} + res := NewBidirectionalStreamingResultWithViewsMethodUsertypeOK(&body) + vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithViewsService", "BidirectionalStreamingResultWithViewsMethod", err) } @@ -3374,7 +3386,7 @@ func (s *BidirectionalStreamingResultWithExplicitViewMethodClientStream) Recv() if err != nil { return rv, err } - res := NewBidirectionalStreamingResultWithExplicitViewMethodResultOK(&body) + res := NewBidirectionalStreamingResultWithExplicitViewMethodUsertypeOK(&body) vres := &bidirectionalstreamingresultwithexplicitviewserviceviews.Usertype{Projected: res, View: "extended"} if err := bidirectionalstreamingresultwithexplicitviewserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithExplicitViewService", "BidirectionalStreamingResultWithExplicitViewMethod", err) @@ -3401,10 +3413,8 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -3417,16 +3427,8 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send if s.upgradeErr != nil { return s.upgradeErr } - res := bidirectionalstreamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeTinyCollection(res.Projected) - case "extended": - body = NewUsertypeExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeCollection(res.Projected) - } + res := bidirectionalstreamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -3515,7 +3517,7 @@ var BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode = `// Re func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv() (bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection - body BidirectionalStreamingResultCollectionWithViewsMethodResponseBody + body UsertypeResponseTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -3525,8 +3527,8 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv if err != nil { return rv, err } - res := NewBidirectionalStreamingResultCollectionWithViewsMethodResultOK(body) - vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} + res := NewBidirectionalStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) + vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithViewsService", "BidirectionalStreamingResultCollectionWithViewsMethod", err) } @@ -3575,7 +3577,7 @@ func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodServerStrea return s.upgradeErr } res := bidirectionalstreamingresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeTinyCollection(res.Projected) + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -3654,7 +3656,7 @@ var BidirectionalStreamingResultCollectionWithExplicitViewClientStreamRecvCode = func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodClientStream) Recv() (bidirectionalstreamingresultcollectionwithexplicitviewservice.UsertypeCollection, error) { var ( rv bidirectionalstreamingresultcollectionwithexplicitviewservice.UsertypeCollection - body UsertypeTinyCollection + body UsertypeResponseTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -3664,7 +3666,7 @@ func (s *BidirectionalStreamingResultCollectionWithExplicitViewMethodClientStrea if err != nil { return rv, err } - res := NewBidirectionalStreamingResultCollectionWithExplicitViewMethodResultOK(body) + res := NewBidirectionalStreamingResultCollectionWithExplicitViewMethodUsertypeCollectionOK(body) vres := bidirectionalstreamingresultcollectionwithexplicitviewserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultcollectionwithexplicitviewserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithExplicitViewService", "BidirectionalStreamingResultCollectionWithExplicitViewMethod", err) @@ -4137,7 +4139,7 @@ var BidirectionalStreamingUserTypeArrayClientStreamRecvCode = `// Recv reads ins func (s *BidirectionalStreamingUserTypeArrayMethodClientStream) Recv() ([]*bidirectionalstreamingusertypearrayservice.ResultType, error) { var ( rv []*bidirectionalstreamingusertypearrayservice.ResultType - body []*ResultType + body []*ResultTypeResponse err error ) err = s.conn.ReadJSON(&body) @@ -4147,7 +4149,7 @@ func (s *BidirectionalStreamingUserTypeArrayMethodClientStream) Recv() ([]*bidir if err != nil { return rv, err } - res := NewBidirectionalStreamingUserTypeArrayMethodResultOK(body) + res := NewBidirectionalStreamingUserTypeArrayMethodResultTypeOK(body) return res, nil } @@ -4265,7 +4267,7 @@ var BidirectionalStreamingUserTypeMapClientStreamRecvCode = `// Recv reads insta func (s *BidirectionalStreamingUserTypeMapMethodClientStream) Recv() (map[string]*bidirectionalstreamingusertypemapservice.ResultType, error) { var ( rv map[string]*bidirectionalstreamingusertypemapservice.ResultType - body map[string]*ResultType + body map[string]*ResultTypeResponse err error ) err = s.conn.ReadJSON(&body) @@ -4275,7 +4277,7 @@ func (s *BidirectionalStreamingUserTypeMapMethodClientStream) Recv() (map[string if err != nil { return rv, err } - res := NewBidirectionalStreamingUserTypeMapMethodResultOK(body) + res := NewBidirectionalStreamingUserTypeMapMethodMapStringResultTypeOK(body) return res, nil } diff --git a/http/codegen/testdata/streaming_dsls.go b/http/codegen/testdata/streaming_dsls.go index 4bd2bb815e..74d525c3a9 100644 --- a/http/codegen/testdata/streaming_dsls.go +++ b/http/codegen/testdata/streaming_dsls.go @@ -34,6 +34,22 @@ var SkipRequestBodyEncodeDecodeDSL = func() { }) } +var SkipRequestBodyEncodeDecodeHeaderDSL = func() { + Service("SkipRequestBodyEncodeDecodeHeader", func() { + Method("Upload", func() { + Payload(func() { + Attribute("contentType", String) + }) + HTTP(func() { + POST("/") + Header("contentType:Content-Type") + SkipRequestBodyEncodeDecode() + Response(StatusNoContent) + }) + }) + }) +} + var StreamingMultipleServicesDSL = func() { Service("StreamingServiceA", func() { Method("Method", func() { @@ -57,10 +73,14 @@ var StreamingMultipleServicesDSL = func() { var StreamingResultDSL = func() { var Request = Type("Request", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) }) var Result = Type("UserType", func() { - Attribute("a", String) + Attribute("a", String, func() { + Example("event") + }) }) Service("StreamingResultService", func() { Method("StreamingResultMethod", func() { @@ -76,15 +96,21 @@ var StreamingResultDSL = func() { var MixedResultsDSL = func() { var PayloadType = Type("Payload", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) Required("x") }) var ResultType = Type("Result", func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("result") + }) Required("id") }) var EventType = Type("Event", func() { - Attribute("message", String) + Attribute("message", String, func() { + Example("event") + }) Required("message") }) Service("MixedResultsService", func() { @@ -491,7 +517,9 @@ var StreamingPayloadResultWithViewsDSL = func() { Service("StreamingPayloadResultWithViewsService", func() { Method("StreamingPayloadResultWithViewsMethod", func() { StreamingPayload(Float32) - Result(ResultT) + Result(ResultT, func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -549,7 +577,9 @@ var StreamingPayloadResultCollectionWithViewsDSL = func() { Service("StreamingPayloadResultCollectionWithViewsService", func() { Method("StreamingPayloadResultCollectionWithViewsMethod", func() { StreamingPayload(Any) - Result(CollectionOf(ResultT)) + Result(CollectionOf(ResultT), func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -730,7 +760,9 @@ var BidirectionalStreamingResultWithViewsDSL = func() { Service("BidirectionalStreamingResultWithViewsService", func() { Method("BidirectionalStreamingResultWithViewsMethod", func() { StreamingPayload(Float32) - StreamingResult(ResultT) + StreamingResult(ResultT, func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -788,7 +820,9 @@ var BidirectionalStreamingResultCollectionWithViewsDSL = func() { Service("BidirectionalStreamingResultCollectionWithViewsService", func() { Method("BidirectionalStreamingResultCollectionWithViewsMethod", func() { StreamingPayload(Any) - StreamingResult(CollectionOf(ResultT)) + StreamingResult(CollectionOf(ResultT), func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index c4dc48dd4e..5942319e9a 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -1,59 +1,586 @@ +// This file verifies HTTP and JSON-RPC conversion functions use the exact +// declarations selected while their generated packages are planned. package codegen import ( + "bytes" + "fmt" "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" - "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - // "goa.design/goa/v3/http/codegen/testdata" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" ) -func TestTransformHelperServer(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int - }{ - // {"body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSL1, 1}, - // {"body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSL1, 1}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - plan := linkedHTTPPlanForRoot(t, root) - f := plan.ServerFiles()[1] - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) +// TestTransformHelperOrderingSupportsMoreThan255Functions catches helper name +// ordering that narrows a plan position to one byte. +func TestTransformHelperOrderingSupportsMoreThan255Functions(t *testing.T) { + source, target := manyDistinctTransformChildren(257, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy, "") + catalog.collectTransform(source, target, "marshal", "many helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) +} + +// TestTransformHandleSelectsTheCollectedPlan catches structurally identical +// conversions being exchanged when rendering happens in a different order. +func TestTransformHandleSelectsTheCollectedPlan(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy, "") + first := catalog.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + second := catalog.collectTransform(source, target, "marshal", "second", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + _, _, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.False(t, first.record.used) + require.True(t, second.record.used) + _, _, err = renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) +} + +// TestTransformHandleRejectsAnotherCatalog catches a planned conversion being +// rendered into a package that did not claim its declarations. +func TestTransformHandleRejectsAnotherCatalog(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + first, firstGeneration := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + first.collect(target, wireRequestBody, policy, "") + handle := first.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, firstGeneration, first) + + second, secondGeneration := testWireTypeCatalog(t) + linkTestWireTypeCatalog(t, secondGeneration, second) + _, _, err := renderTestTransform(second, handle, "inventory") + require.ErrorContains(t, err, "different generated package") +} + +// TestTransformDefinitionsMatchAcrossPlans proves one package declaration may +// be shared by equivalent helper definitions produced by separate plans. +func TestTransformDefinitionsMatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, firstHelpers, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, secondHelpers, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.Same(t, firstHelpers[0].Declaration, secondHelpers[0].Declaration) +} + +// TestTransformDefinitionsRejectMismatchAcrossPlans catches AppendHelpers +// hiding two different functions assigned to one package declaration. +func TestTransformDefinitionsRejectMismatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, _, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, _, err = renderTestTransform(catalog, second, "different") + require.ErrorContains(t, err, "has different definitions") + require.False(t, second.record.used) +} + +// TestPlanLinkRejectsUnusedTransform catches planned conversions that no +// generated constructor or stream method renders. +func TestPlanLinkRejectsUnusedTransform(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("audit", func() { + dsl.Method("show", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/show") + }) + }) }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + transportService := root.API.HTTP.Service("audit") + planned := plans[0].wireTypes[transportService] + record := &wireTransformRecord{owner: "unused audit transform", prefix: "marshal"} + planned.client.transforms = append(planned.client.transforms, record) + planned.transforms.streamingResults[transportService.HTTPEndpoints[0]] = &plannedResponseTransforms{ + clientDecode: wireTransformHandle{catalog: planned.client, record: record}, } + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + err = plans[0].Link() + require.ErrorContains(t, err, "unused audit transform") +} + +// TestPlanLinkReturnsForeignTransformError catches render failures escaping as +// panics instead of the error returned by Plan.Link. +func TestPlanLinkReturnsForeignTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + planned := plan.wireTypes[root.API.HTTP.Services[0]] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = response.clientDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "different generated package") +} + +// TestPlanLinkReturnsReusedTransformError catches a second production render +// of one handle escaping as a panic. +func TestPlanLinkReturnsReusedTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + serviceExpr := root.API.HTTP.Services[0] + endpoint := serviceExpr.HTTPEndpoints[0] + planned := plan.wireTypes[serviceExpr] + request := planned.transforms.requests[clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody}] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = request.serverDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "already rendered") } -func TestTransformHelperCLI(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int +// TestTransformHelperOrderingDoesNotDependOnTraversalOrder catches suffixes +// changing when the same child conversions are collected in reverse order. +func TestTransformHelperOrderingDoesNotDependOnTraversalOrder(t *testing.T) { + require.Equal(t, plannedTransformHelperNames(t, false), plannedTransformHelperNames(t, true)) +} + +// TestTransformHelperUsesRetainedServicePackagePreference catches helper names +// derived from the HTTP output directory or a copied view type's spelling. +func TestTransformHelperUsesRetainedServicePackagePreference(t *testing.T) { + for _, test := range []struct { + name string + preference codegen.ImportSpec + want string }{ - // {"cli-body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSLCLI1, 1}, - // {"cli-body-user-inner-default-2", testdata.PayloadBodyUserInnerDefaultDSLCLI2, 2}, - // {"cli-body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSLCLI1, 1}, - // {"cli-body-user-recursive-default-2", testdata.PayloadBodyInlineRecursiveUserDSLCLI2, 2}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - plan := linkedHTTPPlanForRoot(t, root) - f := plan.ClientFiles()[1] - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) + {"service", codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"}, "InventoryChild"}, + {"views", codegen.ImportSpec{Name: "inventoryviews", Path: "generated.local/gen/inventory/views"}, "InventoryviewsChild"}, + } { + t.Run(test.name, func(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy, "") + catalog.collectTransform(source, target, "marshal", test.name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: test.preference, + }) + linkTestWireTypeCatalog(t, generation, catalog) + + require.Len(t, catalog.transformHelpers, 1) + require.Contains(t, catalog.transformHelpers[0].declaration.Name(), test.want) + require.NotContains(t, catalog.transformHelpers[0].declaration.Name(), "TestChild") }) } } + +func TestClientTransformHelpersNameExactSourceAndTargetTypes(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDefaultDSL) + plan := linkedHTTPPlanForRoot(t, root) + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ClientFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "client-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 2, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_bidirectional-client.go.golden", + codegen.FormatTestCode(t, "package client\n"+code.String()), + ) +} + +func TestViewedTransformHelpersNameViewsPackage(t *testing.T) { + root := expr.RunDSL(t, testdata.ExplicitBodyUserResultObjectDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceExplicitBodyUserResultObject") + var names []string + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeViewOptional") +} + +func TestTransformHelpersUseConciseServiceAndWireTypeNames(t *testing.T) { + root := expr.RunDSL(t, conciseTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("Storage") + var names []string + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "marshalStorageWineryToWineryRequestBody") + require.Contains(t, names, "marshalWineryRequestBodyToStorageWinery") +} + +func TestSiblingTransformHelpersShareOneDefinition(t *testing.T) { + root := expr.RunDSL(t, testdata.ResultTypeSiblingUserTypeFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceResultUserTypeSibling") + require.Len(t, service.ServerTransformHelpers, 1) + name := "marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional" + require.Equal(t, name, service.ServerTransformHelpers[0].Name) + result := service.Endpoint("MethodResultUserTypeSibling").Result + require.NotEmpty(t, result.Responses) + require.NotEmpty(t, result.Responses[0].ServerBody) + require.NotNil(t, result.Responses[0].ServerBody[0].Init) + constructor := result.Responses[0].ServerBody[0].Init.ServerCode + require.Contains(t, constructor, name+"(res.A)") + require.Contains(t, constructor, name+"(res.B)") + + var code bytes.Buffer + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name == "server-transform-helper" { + require.NoError(t, section.Write(&code)) + } + } + } + testutil.AssertGo( + t, + "testdata/golden/transform_helper_sibling-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestTransformHelpersShareExactPackageDeclaration(t *testing.T) { + root := expr.RunDSL(t, sharedTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("SharedHelpers") + requiredName, optionalName := transformHelperNamesByRequired(t, service.serverWireTypes) + require.Contains(t, service.Endpoint("First").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, service.Endpoint("Second").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, service.Endpoint("Optional").Payload.Request.PayloadInit.ServerCode, optionalName+"(body.Child)") + require.NotContains(t, service.Endpoint("Optional").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 2, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_shared-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestJSONRPCTransformHelpersUseHTTPPackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, sharedJSONRPCTransformHelperDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("SharedHelpers") + requiredName, optionalName := transformHelperNamesByRequired(t, serviceData.serverWireTypes) + snapshot, ok := plans[0].JSONRPCService("SharedHelpers") + require.True(t, ok) + require.Contains(t, snapshot.Endpoints[0].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, snapshot.Endpoints[1].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, optionalName+"(body.Child)") + require.NotContains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + + first := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + second := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Len(t, first, 2) + require.Len(t, second, 2) + require.ElementsMatch(t, []string{requiredName, optionalName}, []string{first[0].Name, first[1].Name}) + first[0].Name = "changed" + fresh := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Equal(t, second[0].Name, fresh[0].Name) + require.NotEqual(t, first[0].Name, fresh[0].Name) +} + +// jsonRPCTransformHelpers returns the copied conversion functions from one +// JSON-RPC codec file so the test can change one copy without changing another. +func jsonRPCTransformHelpers(file *codegen.File) []*jsonRPCTransformFunctionData { + var helpers []*jsonRPCTransformFunctionData + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + helpers = append(helpers, section.Data.(*jsonRPCTransformFunctionData)) + } + return helpers +} + +// transformHelperNamesByRequired returns the two function names from the test +// catalog according to whether they accept a missing source value. +func transformHelperNamesByRequired(t *testing.T, catalog *wireTypeCatalog) (string, string) { + t.Helper() + var required, optional string + for _, helper := range catalog.transformHelpers { + if helper.identity.required { + required = helper.declaration.Name() + } else { + optional = helper.declaration.Name() + } + } + require.NotEmpty(t, required) + require.NotEmpty(t, optional) + return required, optional +} + +// sharedTransformHelperDSL uses one named child in two required fields and one +// optional field so functions share only when missing values behave the same. +func sharedTransformHelperDSL() { + sharedTransformHelperDesign(false) +} + +// sharedJSONRPCTransformHelperDSL applies the same service types to JSON-RPC. +func sharedJSONRPCTransformHelperDSL() { + sharedTransformHelperDesign(true) +} + +// conciseTransformHelperDSL names the service and nested type like a normal +// application so generated functions should use those public names directly. +func conciseTransformHelperDSL() { + winery := dsl.ResultType("application/vnd.transform-helper.winery", func() { + dsl.TypeName("Winery") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + bottle := dsl.Type("Bottle", func() { + dsl.Attribute("winery", winery) + dsl.Required("winery") + }) + dsl.Service("Storage", func() { + dsl.Method("Create", func() { + dsl.Payload(bottle) + dsl.HTTP(func() { + dsl.POST("/") + }) + }) + }) +} + +// sharedTransformHelperDesign creates the service used to test normal HTTP and +// JSON-RPC package generation. +func sharedTransformHelperDesign(jsonrpc bool) { + child := dsl.Type("SharedChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SharedHelpers", func() { + for _, method := range []struct { + name string + path string + required bool + }{ + {name: "First", path: "/first", required: true}, + {name: "Second", path: "/second", required: true}, + {name: "Optional", path: "/optional"}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + if method.required { + dsl.Required("child") + } + }) + if jsonrpc { + dsl.JSONRPC(func() {}) + } else { + dsl.HTTP(func() { + dsl.POST(method.path) + }) + } + }) + } + }) +} + +// manyDistinctTransformChildren builds one object conversion with more helper +// functions than fit in one byte. Every child requests the same preferred type +// name but has a different field, so declaration ordering must use its complete +// source and target type identity. +func manyDistinctTransformChildren(count int, reverse bool) (*expr.AttributeExpr, *expr.AttributeExpr) { + sourceObject := make(expr.Object, 0, count) + targetObject := make(expr.Object, 0, count) + required := make([]string, count) + for position := range count { + index := position + if reverse { + index = count - position - 1 + } + field := fmt.Sprintf("field_%d", index) + value := fmt.Sprintf("value_%d", index) + sourceChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + targetChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + sourceObject = append(sourceObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: sourceChild}}) + targetObject = append(targetObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: targetChild}}) + required[index] = field + } + return &expr.AttributeExpr{ + Type: &sourceObject, + Validation: &expr.ValidationExpr{Required: required}, + }, &expr.AttributeExpr{ + Type: &targetObject, + Validation: &expr.ValidationExpr{Required: append([]string(nil), required...)}, + } +} + +// plannedTransformHelperNames returns each distinct child field and the helper +// name assigned to its conversion. +func plannedTransformHelperNames(t *testing.T, reverse bool) map[string]string { + t.Helper() + source, target := manyDistinctTransformChildren(3, reverse) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy, "") + catalog.collectTransform(source, target, "marshal", "ordered helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make(map[string]string, len(catalog.transformHelpers)) + for _, helper := range catalog.transformHelpers { + object := expr.AsObject(helper.identity.source.attribute.Type) + names[(*object)[0].Name] = helper.declaration.Name() + } + return names +} + +// plannedMatchingTransforms returns two independent plans whose child helpers +// share one declaration in the generated package. +func plannedMatchingTransforms( + t *testing.T, +) (*wireTypeCatalog, wireTransformHandle, wireTransformHandle) { + t.Helper() + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy, "") + layout := wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + } + first := catalog.collectTransform(source, target, "marshal", "first", layout) + second := catalog.collectTransform(source, target, "marshal", "second", layout) + linkTestWireTypeCatalog(t, generation, catalog) + return catalog, first, second +} + +// renderTestTransform renders one service-to-wire conversion using the given +// service package qualifier. +func renderTestTransform( + catalog *wireTypeCatalog, + handle wireTransformHandle, + servicePackage string, +) (string, []*codegen.TransformFunctionData, error) { + serviceContext := codegen.NewAttributeContext(false, false, true, servicePackage, codegen.NewNameScope()) + wireContext := jsonBodyContext(catalog, catalog.scope, true, false) + return catalog.renderTransform(handle, handle.record.target, "source", "target", serviceContext, wireContext) +} + +// testServicePackage is the retained service package used by direct wire +// catalog tests. +func testServicePackage() codegen.ImportSpec { + return codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"} +} + +// plannedTransformErrorService returns an unlinked plan with request and +// response conversions that tests can replace with an invalid handle. +func plannedTransformErrorService( + t *testing.T, +) (*expr.RootExpr, *codegen.Generation, *service.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + result := dsl.Type("Result", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("transform errors", func() { + dsl.Method("show", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + return root, generation, servicePlan, plans[0] +} diff --git a/http/codegen/typedef.go b/http/codegen/typedef.go index a46fb67498..350b0e1ce9 100644 --- a/http/codegen/typedef.go +++ b/http/codegen/typedef.go @@ -40,7 +40,7 @@ func goTypeDefForContext(att *expr.AttributeExpr, ctx *codegen.AttributeContext) return codegen.GoNativeTypeName(actual) case *expr.Array: d := goTypeDefForContext(actual.ElemType, ctx) - if expr.IsObject(actual.ElemType.Type) { + if expr.IsObject(actual.ElemType.Type) || ctx.IsArrayElementPointer(actual) { d = "*" + d } return "[]" + d diff --git a/http/codegen/types.go b/http/codegen/types.go index b522becd1d..20fbccd869 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -13,7 +13,7 @@ import ( func serverTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = addEndpointImports(typesFile(svc, true, data), data, svc.HTTPEndpoints...) + fw[i] = addPlannedFileImports(typesFile(svc, true, data), data) } return fw } @@ -22,7 +22,7 @@ func serverTypeFiles(data *ServicesData) []*codegen.File { func clientTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = addEndpointImports(typesFile(svc, false, data), data, svc.HTTPEndpoints...) + fw[i] = addPlannedFileImports(typesFile(svc, false, data), data) } return fw } @@ -82,14 +82,16 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod } unionTypes := data.wireTypes(svr).unionTypes() path := filepath.Join(codegen.Gendir, services.dir(), svcName, side, "types.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) imports := []*codegen.ImportSpec{ {Path: "encoding/json"}, {Path: "fmt"}, {Path: "unicode/utf8"}, - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } if serviceHasViewedResult(data, nil) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } if len(unionTypes) > 0 { imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) @@ -103,9 +105,9 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod sections = []*codegen.SectionTemplate{header} - // seen tracks the canonical package records already emitted. Type names - // and references are outputs of these records, never declaration - // identity. + // seen records each generated type declaration already written to this + // file. Two declarations may have similar Go type text, so the declaration + // itself decides whether another definition is needed. seen = make(map[*wireTypeRecord]struct{}) seenInits = make(map[string]struct{}) seenValidated = make(map[*wireTypeRecord]struct{}) @@ -129,7 +131,8 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod }) } } - // addValidated records each package-owned validation helper once. + // addValidated records each validation helper declared in this generated + // package once. addValidated := func(td *TypeData) { if td.declaration == nil || td.ValidateDef == "" { return @@ -147,7 +150,7 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod var body, wsPayload *TypeData if svr { body = adata.Payload.Request.ServerBody - if adata.ServerWebSocket != nil && !adata.IsJSONRPC { + if adata.ServerWebSocket != nil { wsPayload = adata.ServerWebSocket.Payload } } else { @@ -207,6 +210,28 @@ func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *cod addValidated(td) } } + if !adata.HasMixedResults || adata.SSE == nil || adata.SSE.Response == nil { + continue + } + var bodies []*TypeData + if svr { + bodies = adata.SSE.Response.ServerBody + } else if adata.SSE.Response.ClientBody != nil { + bodies = []*TypeData{adata.SSE.Response.ClientBody} + } + for _, td := range bodies { + if td == nil { + continue + } + addDecl(responseBodySection, td) + if td.Init != nil { + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } + } + addValidated(td) + } } // error body types diff --git a/http/codegen/validation_path_test.go b/http/codegen/validation_path_test.go new file mode 100644 index 0000000000..1b8c84555d --- /dev/null +++ b/http/codegen/validation_path_test.go @@ -0,0 +1,198 @@ +// This file verifies that generated HTTP validators keep complete error paths +// while reusing named validators for nested and recursive values. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + gencodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestHTTPValidationPathsUseGeneratedCalls(t *testing.T) { + root := expr.RunDSL(t, recursiveValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateNodeRequestBody(body.First, "body.first")`) + require.Contains(t, code, `validateNodeRequestBody(body.Second, "body.second")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, "body.next")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, path+".next")`) + require.Contains(t, code, `validateNodeRequestBody(e, path+".children[*]")`) + require.Contains(t, code, `validateNodeRequestBody(v, path+".children_by_name[key]")`) + require.Contains(t, code, `goa.InvalidLengthError("body.value"`) + require.Contains(t, code, `goa.InvalidLengthError(path+".value"`) + require.Equal(t, 1, strings.Count(code, "func validateNodeRequestBody(")) + require.NotContains(t, code, "fmt.Sprintf") +} + +func TestHTTPValidationPathsKeepMutualRecursion(t *testing.T) { + root := expr.RunDSL(t, mutualValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateLeftRequestBody(body.Left, "body.left")`) + require.Contains(t, code, `validateRightRequestBody(body.Right, path+".right")`) + require.Contains(t, code, `validateLeftRequestBody(body.Left, path+".left")`) + require.Equal(t, 1, strings.Count(code, "func validateLeftRequestBody(")) + require.Equal(t, 1, strings.Count(code, "func validateRightRequestBody(")) +} + +func TestHTTPValidationPathsOmitUnusedNestedHelper(t *testing.T) { + root := expr.RunDSL(t, unusedClientNestedValidationDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ClientTypeFiles()) + + require.Contains(t, code, "func ValidateChildRequestBody(") + require.NotContains(t, code, "func validateChildRequestBody(") +} + +func TestHTTPValidationPathsOmitUnusedViewedSSENestedHelper(t *testing.T) { + root := expr.RunDSL(t, viewedSSENestedValidationDSL) + var plan *Plan + require.NotPanics(t, func() { + plan = linkedHTTPPlanForRoot(t, root) + }) + types := renderedFiles(t, plan.ClientTypeFiles()) + serviceFiles, err := service.Files(plan.servicePlan) + require.NoError(t, err) + views := renderedFiles(t, serviceFiles) + + require.NotContains(t, types, "func validateProfile(") + require.Contains(t, views, `goa.ValidatePattern("result.code"`) +} + +func TestHTTPValidationPathsOmitUnusedClientArrayElementValidator(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveArrayUserRequiredDSL) + generation, err := gencodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + planned := plans[0].wireTypes[root.API.HTTP.Services[0]] + var clientElement, serverElement *wireTypeRecord + for _, record := range planned.client.records { + if record.identity.preferred == "PayloadType" { + clientElement = record + break + } + } + for _, record := range planned.server.records { + if record.identity.preferred == "PayloadType" { + serverElement = record + break + } + } + require.NotNil(t, clientElement) + require.False(t, clientElement.needsNestedCall) + require.Nil(t, clientElement.nestedValidator) + require.NotNil(t, serverElement) + require.True(t, serverElement.needsNestedCall) + require.NotNil(t, serverElement.nestedValidator) +} + +// recursiveValidationPathDSL defines one named type used by two fields and by +// its own object, array, and map fields. +func recursiveValidationPathDSL() { + node := Type("Node", func() { + Attribute("value", String, func() { + MinLength(1) + }) + Attribute("next", "Node") + Attribute("children", ArrayOf("Node")) + Attribute("children_by_name", MapOf(String, "Node")) + }) + payload := Type("Payload", func() { + Attribute("first", node) + Attribute("second", node) + }) + Service("RecursiveValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// mutualValidationPathDSL defines two named types that refer to each other. +func mutualValidationPathDSL() { + left := Type("Left", func() { + Attribute("right", "Right") + }) + Type("Right", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("left", "Left") + }) + payload := Type("MutualPayload", func() { + Attribute("left", left) + }) + Service("MutualValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// unusedClientNestedValidationDSL defines a child validator whose client +// request body has no generated validation call to that child. +func unusedClientNestedValidationDSL() { + child := Type("Child", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + payload := Type("UnusedNestedPayload", func() { + Attribute("child", child) + }) + Service("UnusedNestedValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// viewedSSENestedValidationDSL defines a viewed event whose views package +// validates one nested named value. +func viewedSSENestedValidationDSL() { + profile := Type("Profile", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + event := ResultType("application/vnd.viewed-sse-nested-validation", func() { + TypeName("ViewedSSENestedValidation") + Attribute("profile", profile) + Required("profile") + View("summary", func() { + Attribute("profile") + }) + View("detailed", func() { + Attribute("profile") + }) + }) + Service("Viewed SSE Nested Validation", func() { + Method("Watch", func() { + StreamingResult(event) + HTTP(func() { + GET("/watch") + ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/viewed_sse_test.go b/http/codegen/viewed_sse_test.go index 1705526bc9..abe0e22e93 100644 --- a/http/codegen/viewed_sse_test.go +++ b/http/codegen/viewed_sse_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" @@ -22,7 +21,7 @@ import ( func TestViewedSSEServerLocksFirstView(t *testing.T) { root := expr.RunDSL(t, viewedSSEDSL) plan := linkedHTTPPlanForRoot(t, root) - code := renderedFile(t, plan.ServerFiles(), "sse.go") + code := renderedFile(t, plan.ServerFiles()) require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) require.Contains(t, code, `goa.InvalidEnumValueError("view", view, []any{s.sentView})`) @@ -42,7 +41,7 @@ func TestViewedSSEServerLocksFirstView(t *testing.T) { func TestViewedSSEClientReconstructsCollections(t *testing.T) { root := expr.RunDSL(t, viewedSSECollectionDSL) plan := linkedHTTPPlanForRoot(t, root) - code := renderedFile(t, plan.ClientFiles(), "sse.go") + code := renderedFile(t, plan.ClientFiles()) require.Contains(t, code, "switch view {") require.Contains(t, code, "Decode(&body)") @@ -57,13 +56,15 @@ func TestViewedSSEClientReconstructsCollections(t *testing.T) { func TestViewedSSEUsesConfiguredDataField(t *testing.T) { root := expr.RunDSL(t, viewedSSEDataFieldDSL) plan := linkedHTTPPlanForRoot(t, root) - client := renderedFile(t, plan.ClientFiles(), "sse.go") - server := renderedFile(t, plan.ServerFiles(), "sse.go") + client := renderedFile(t, plan.ClientFiles()) + server := renderedFile(t, plan.ServerFiles()) - require.Contains(t, client, "Decode(&body.Data)") + require.Contains(t, client, "value := dataContent") + require.Contains(t, client, "body.Data = &value") require.Contains(t, client, "projected := New") require.Contains(t, client, "views.Validate") - require.Contains(t, server, "payload = body.Data") + require.Contains(t, server, "data = string(body.Data)") + require.NotContains(t, server, "var payload any") } // TestViewedSSERebuildsRequiredResponseFields checks that the client reads the @@ -72,12 +73,13 @@ func TestViewedSSEUsesConfiguredDataField(t *testing.T) { func TestViewedSSERebuildsRequiredResponseFields(t *testing.T) { root := expr.RunDSL(t, viewedSSERequiredFieldsDSL) plan := linkedHTTPPlanForRoot(t, root) - client := renderedFile(t, plan.ClientFiles(), "sse.go") + client := renderedFile(t, plan.ClientFiles()) for _, assignment := range []string{ "body.ID = event.ID", "body.Kind = event.Kind", - "Decode(&body.Data)", + "value := dataContent", + "body.Data = &value", } { require.Contains(t, client, assignment) require.Less(t, strings.Index(client, assignment), strings.Index(client, "projected := New")) @@ -100,7 +102,6 @@ func TestViewedClientsUseAssignedValidator(t *testing.T) { require.NoError(t, viewsPackage.DeclareName(codegen.NewExactName(codegen.NameFunction, preferred))) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -142,7 +143,6 @@ func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { require.NoError(t, err) plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, plans[0].Link()) @@ -155,7 +155,7 @@ func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { names := make(map[string]struct{}, len(retained.Representations)) collidingNames := make(map[string]string, 2) definitions := renderedFiles(t, plans[0].ClientTypeFiles()) - calls := renderedFile(t, plans[0].ClientFiles(), "sse.go") + calls := renderedFile(t, plans[0].ClientFiles()) for _, representation := range retained.Representations { declaration := plans[0].constructors[viewedConstructorKey{ endpoint: endpoint, @@ -175,15 +175,15 @@ func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { require.NotEqual(t, collidingNames["foo-bar"], collidingNames["foo bar"]) } -// renderedFile renders all sections of the file whose base name is suffix. -func renderedFile(t *testing.T, files []*codegen.File, suffix string) string { +// renderedFile renders the generated sse.go file. +func renderedFile(t *testing.T, files []*codegen.File) string { t.Helper() for _, file := range files { - if strings.HasSuffix(file.Path, suffix) { + if strings.HasSuffix(file.Path, "sse.go") { return renderedFiles(t, []*codegen.File{file}) } } - t.Fatalf("generated file ending in %q was not planned", suffix) + t.Error("generated sse.go file was not planned") return "" } @@ -268,6 +268,32 @@ func viewedSSEDataFieldDSL() { }) } +// viewedSSEPrimitiveAliasDataFieldDSL defines a viewed stream whose data line +// carries a required field declared with a named string type. +func viewedSSEPrimitiveAliasDataFieldDSL() { + text := dsl.Type("ViewedEventText", dsl.String) + event := dsl.ResultType("application/vnd.viewed-sse-alias-data", func() { + dsl.TypeName("ViewedSSEAliasData") + dsl.Attribute("data", text) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Alias Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + // viewedSSERequiredFieldsDSL maps required result fields across every input a // streamed HTTP response can carry. func viewedSSERequiredFieldsDSL() { diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index b94d79d905..ad7908ec97 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -23,9 +23,11 @@ type ( // WebSocketData contains the data needed to render struct type that // implements the server and client stream interfaces. WebSocketData struct { - // VarName is the name of the struct. + // VarName is the stream implementation type name kept for existing plugins. + // + // Deprecated: Use VarDeclaration.Name() after planning so name collisions are handled. VarName string - // VarDeclaration is the package name used by the stream implementation type. + // VarDeclaration is the generated Go type name used by the stream implementation. VarDeclaration *codegen.NameDeclaration // Type is type of the stream (server or client). Type string @@ -113,7 +115,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin streamOwner := expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr) svrRecvTypeName = svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true) svrRecvTypeRef = svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)) - svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd, streamOwner, streamOwner) + svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, true, sd, streamOwner, streamOwner) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type // generate constructor function to transform request body, @@ -139,7 +141,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin var svcode string if ut, ok := body.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { - httpctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) svcode = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") } } @@ -159,8 +161,9 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin } if body != expr.Empty { var helpers []*codegen.TransformFunctionData - httpctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) - serverCode, helpers, err = sd.serverWireTypes.renderTransform(streamBody, e.MethodExpr.StreamingPayload, "body", "v", "marshal", httpctx, svcctx) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireStreamPayload}] + serverCode, helpers, err = sd.serverWireTypes.renderTransform(transforms.serverDecode, streamBody, "body", "v", httpctx, svcctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -180,7 +183,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ServerCode: serverCode, } } - cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd, streamOwner, streamOwner) + cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, false, sd, streamOwner, streamOwner) if e.MethodExpr.Stream == expr.ClientStreamKind { svrSendDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection and closes the connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) svrSendWithContextDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection with context and closes the connection.", md.ServerStream.SendWithContextName, svrSendTypeName, md.Name) @@ -249,6 +252,9 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -259,7 +265,7 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } structSections := serverStructWSSections(data) wsSections := serverWSSections(data) @@ -269,7 +275,7 @@ func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } @@ -282,6 +288,9 @@ func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket client streaming", svc.Name()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -292,10 +301,10 @@ func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - services.ServiceImport(svc.Name()), + services.ServiceImport(outputPackage, svc.Name()), } if serviceHasViewedResult(data, IsWebSocketEndpoint) { - imports = append(imports, services.ViewImport(svc.Name())) + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } structSections := clientStructWSSections(data) wsSections := clientWSSections(data) @@ -305,7 +314,7 @@ func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *cod sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } @@ -353,26 +362,33 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ServerWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } switch e.ServerWebSocket.Kind { case expr.ClientStreamKind, expr.BidirectionalStreamKind: sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.ServerWebSocket.MustClose { sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-close", - Source: httpTemplates.Read(websocketCloseT), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-close", + Source: httpTemplates.Read(websocketCloseT), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.Method.ViewedResult != nil && e.Method.ViewedResult.ViewName == "" { @@ -425,10 +441,13 @@ func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { if e.ClientWebSocket != nil { if e.ClientWebSocket.RecvTypeRef != "" { sections = append(sections, &codegen.SectionTemplate{ - Name: "client-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ClientWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "client-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ClientWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } switch e.ClientWebSocket.Kind { @@ -438,8 +457,9 @@ func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ClientWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } @@ -469,6 +489,17 @@ func HasWebSocket(sd *ServiceData) bool { return slices.ContainsFunc(sd.Endpoints, IsWebSocketEndpoint) } +// isClientStreamKind reports whether the client finishes sending before it +// receives the server's single result. +func isClientStreamKind(kind expr.StreamKind) bool { + return kind == expr.ClientStreamKind +} + +// isServerStreamKind reports whether the client only receives stream values. +func isServerStreamKind(kind expr.StreamKind) bool { + return kind == expr.ServerStreamKind +} + // IsWebSocketEndpoint returns true if the endpoint defines a streaming payload // or result. func IsWebSocketEndpoint(ed *EndpointData) bool { diff --git a/http/codegen/websocket_golden_test.go b/http/codegen/websocket_golden_test.go index 7499627e14..d7bb344da7 100644 --- a/http/codegen/websocket_golden_test.go +++ b/http/codegen/websocket_golden_test.go @@ -388,7 +388,9 @@ func bidirectionalStreamingWithViewsDSL() { Service("TestService", func() { Method("BidirectionalWithViews", func() { StreamingPayload(Request) - StreamingResult(Response) + StreamingResult(Response, func() { + View("minimal") + }) HTTP(func() { GET("/bidirectional/views") }) @@ -511,7 +513,9 @@ func comprehensiveWebSocketDSL() { Method("BidirectionalStreaming", func() { StreamingPayload(UserType) - StreamingResult(UserType) + StreamingResult(UserType, func() { + View("tiny") + }) HTTP(func() { GET("/bidirectional") }) diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go index 1baae246f3..7ea3d3a955 100644 --- a/http/codegen/wire_catalog.go +++ b/http/codegen/wire_catalog.go @@ -20,16 +20,28 @@ type ( // wireTypeCatalog stores every request or response type written into one Go // package and the Go name chosen for each type. wireTypeCatalog struct { - pkg *codegen.GeneratedPackage - scope *codegen.NameScope - records []*wireTypeRecord - transforms []*wireTransformRecord - unionOccurrences []wireUnionOccurrence - unions []*wireUnionRecord - declared bool - linked bool - bindings map[*expr.AttributeExpr]*wireTypeRecord - unionBindings map[*expr.Union]*wireUnionRecord + pkg *codegen.GeneratedPackage + scope *codegen.NameScope + records []*wireTypeRecord + transforms []*wireTransformRecord + unionOccurrences []wireUnionOccurrence + unions []*wireUnionRecord + validationRoots []wireValidationRoot + transformHelpers []*wireTransformHelperRecord + transformBindings map[codegen.TransformHelperID]*wireTransformHelperRecord + transformDefinitions map[*codegen.NameDeclaration]*codegen.TransformFunctionData + releasedSuffixes map[*expr.AttributeExpr]string + declared bool + linked bool + bindings map[*expr.AttributeExpr]*wireTypeRecord + unionBindings map[*expr.Union]*wireUnionRecord + } + + // wireTransformHandle identifies one exact request or response conversion + // recorded before generated package names are assigned. + wireTransformHandle struct { + catalog *wireTypeCatalog + record *wireTransformRecord } // wireTransformRecord stores one value conversion and any extra functions it @@ -39,10 +51,49 @@ type ( target *expr.AttributeExpr prefix string owner string + layout wireTransformLayout plan *codegen.TransformPlan used bool } + // wireTransformLayout records which value belongs to the transport package, + // which pointer rules it uses, and whether the other value belongs to the + // generated service package or its views package. + wireTransformLayout struct { + wireSide wireTransformSide + wirePolicy wireTypePolicy + servicePointer bool + servicePackage codegen.ImportSpec + } + + // wireTransformHelperRecord stores one function declaration shared by + // matching conversions in the same generated package. + wireTransformHelperRecord struct { + identity wireTransformHelperIdentity + declaration *codegen.NameDeclaration + prefix string + preferred string + order wireNameOrder + } + + // wireTransformHelperIdentity contains the generated source and target Go + // types plus the nil behavior of one conversion function. + wireTransformHelperIdentity struct { + source wireTransformTypeIdentity + target wireTransformTypeIdentity + required bool + } + + // wireTransformTypeIdentity selects either one HTTP package declaration or + // one service type and records the field layout used by its generated code. + wireTransformTypeIdentity struct { + wire *wireTypeRecord + origin expr.UserType + attribute *expr.AttributeExpr + layout codegen.GoLayoutPolicy + servicePackage codegen.ImportSpec + } + // wireUnionRecord stores one generated union and the Go names used for its // type, branches, constants, and functions. wireUnionRecord struct { @@ -64,12 +115,22 @@ type ( union *expr.Union role wireTypeRole policy wireTypePolicy + api string + } + + // wireValidationRoot stores one HTTP value whose generated code runs + // validation directly instead of calling a named validator. + wireValidationRoot struct { + attribute *expr.AttributeExpr + policy wireTypePolicy } // wireUnionIdentity pairs a union definition with the Go type used by each branch. wireUnionIdentity struct { - definition codegen.UnionTypeID - declarations []*wireTypeRecord + definition codegen.UnionTypeID + declarations []*wireTypeRecord + releasedOrder uint8 + api string } // wireTypeRecord stores one generated type and its optional functions. @@ -77,16 +138,29 @@ type ( identity wireTypeIdentity declaration *codegen.NameDeclaration validator *codegen.NameDeclaration + nestedValidator *codegen.NameDeclaration constructor *codegen.NameDeclaration needsValidator bool + needsNestedCall bool needsConstructor bool name string ref string data *TypeData + errorUses []wireErrorUse + releasedNames []string + } + + // wireErrorUse records one service error whose HTTP body uses a generated + // type declaration. + wireErrorUse struct { + service string + method string + name string } // wireTypeIdentity contains a designed type and the rules that change its Go definition. wireTypeIdentity struct { + api string sourceID string resultID string role wireTypeRole @@ -98,17 +172,26 @@ type ( // wireTypePolicy records how one copied type represents fields, pointers, // default values, validation, and result views. wireTypePolicy struct { - request bool - pointer bool - useDefault bool - validate bool - view string + request bool + pointer bool + useDefault bool + validate bool + arrayElementPointer bool + view string } // wireTypeRole says whether an unnamed designed type is used for a request, // response, field, or stream value. wireTypeRole uint8 + // wireTransformSide identifies which value in a conversion is declared in + // the generated HTTP package. + wireTransformSide uint8 + + // wireNameKind identifies the declaration being ordered for a generated + // package name. + wireNameKind uint8 + // wireAttributePair remembers two attributes already compared so values that // refer back to themselves do not cause an endless loop. wireAttributePair struct { @@ -119,23 +202,30 @@ type ( // wireNameOrder contains designed values used to choose stable suffixes when // several declarations ask for the same Go name. wireNameOrder struct { - family string - source string - role uint8 - preferred string - shape string - view string - request bool - pointer bool - defaults bool + kind wireNameKind + unionUse uint8 + api string + source string + target string + role uint8 + preferred string + shape string + view string + request bool + pointer bool + arrayElementPointer bool + defaults bool + required bool } // wireAttributeScope chooses the Go type name for each copied HTTP field. wireAttributeScope struct { - catalog *wireTypeCatalog - base codegen.Attributor - pkg string - policy wireTypePolicy + catalog *wireTypeCatalog + base codegen.Attributor + pkg string + policy wireTypePolicy + viewRoot *wireTypeRecord + exactOccurrence bool } ) @@ -146,12 +236,33 @@ const ( wireStreamPayload ) +const ( + wireTransformSource wireTransformSide = iota + 1 + wireTransformTarget +) + +// These values preserve the former alphabetical category order so replacing +// text keys does not change generated names. +const ( + wireNameConstructor wireNameKind = iota + 1 + wireNameNestedValidator + wireNameTransformHelper + wireNameType + wireNameUnion + wireNameUnionConstant + wireNameUnionConstructor + wireNameUnionKind + wireNameValidator +) + // newWireTypeCatalog creates the type list for one generated Go package. Tests // may omit the package when they only compare copied attributes. func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { catalog := &wireTypeCatalog{ - bindings: make(map[*expr.AttributeExpr]*wireTypeRecord), - unionBindings: make(map[*expr.Union]*wireUnionRecord), + bindings: make(map[*expr.AttributeExpr]*wireTypeRecord), + unionBindings: make(map[*expr.Union]*wireUnionRecord), + transformBindings: make(map[codegen.TransformHelperID]*wireTransformHelperRecord), + releasedSuffixes: make(map[*expr.AttributeExpr]string), } if len(pkg) > 0 { catalog.pkg = pkg[0] @@ -160,21 +271,44 @@ func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { } // collect records attribute and every named type it contains. -func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { +func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, api ...string) *wireTypeRecord { + return c.collectWithReleasedNames(attribute, role, policy, preferred, nil, api...) +} + +// collectWithReleasedNames records a response while keeping the public names +// produced before view selection moved into this package. +func (c *wireTypeCatalog) collectWithReleasedNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, releasedNames map[expr.UserType]string, api ...string) *wireTypeRecord { if c.declared { panic("cannot collect an HTTP type after its package declarations are submitted") } - return c.collectRecursive(attribute, role, policy, preferred, make(map[expr.UserType]struct{})) + suffix := releasedWireTypeSuffix(attribute, role) + c.releasedSuffixes[attribute] = suffix + root := "" + if len(api) > 0 { + root = api[0] + } + return c.collectRecursive(attribute, role, policy, preferred, suffix, root, true, releasedNames, make(map[expr.UserType]struct{})) } // collectChildren records named types inside attribute without recording its // top-level named type a second time. -func (c *wireTypeCatalog) collectChildren(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { +func (c *wireTypeCatalog) collectChildren(attribute *expr.AttributeExpr, policy wireTypePolicy, api ...string) { + c.collectChildrenWithReleasedNames(attribute, policy, nil, api...) +} + +// collectChildrenWithReleasedNames records response fields with their released +// names when selecting a view changed the order of name suffixes. +func (c *wireTypeCatalog) collectChildrenWithReleasedNames(attribute *expr.AttributeExpr, policy wireTypePolicy, releasedNames map[expr.UserType]string, api ...string) { + suffix := c.releasedSuffixes[attribute] + root := "" + if len(api) > 0 { + root = api[0] + } if userType, ok := attribute.Type.(expr.UserType); ok { - c.collectRecursive(userType.Attribute(), role, policy, "", make(map[expr.UserType]struct{})) + c.collectRecursive(userType.Attribute(), wireAttribute, policy, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) return } - c.collectRecursive(attribute, role, policy, "", make(map[expr.UserType]struct{})) + c.collectRecursive(attribute, wireAttribute, policy, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) } // Declare requests every type and function name this HTTP package can write. @@ -187,12 +321,13 @@ func (c *wireTypeCatalog) Declare() error { if c.pkg == nil { return fmt.Errorf("HTTP type declarations require a generated package") } + c.planNestedValidators() for _, record := range c.records { record.declaration = codegen.NewPreferredName( codegen.NameType, - record.identity.preferred, + record.preferredName(), codegen.ExportedName, - record.identity.order("type"), + record.identity.order(wireNameType), ) if err := c.pkg.DeclareName(record.declaration); err != nil { return err @@ -203,12 +338,25 @@ func (c *wireTypeCatalog) Declare() error { record.declaration, "Validate", "", - record.identity.order("validator"), + record.identity.order(wireNameValidator), ) if err != nil { return err } record.validator = declaration + if record.needsNestedCall { + declaration, err = c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "validate", + "", + record.identity.order(wireNameNestedValidator), + ) + if err != nil { + return err + } + record.nestedValidator = declaration + } } if record.needsConstructor { declaration, err := c.pkg.DeclareDependentName( @@ -216,7 +364,7 @@ func (c *wireTypeCatalog) Declare() error { record.declaration, "New", "", - record.identity.order("constructor"), + record.identity.order(wireNameConstructor), ) if err != nil { return err @@ -225,8 +373,13 @@ func (c *wireTypeCatalog) Declare() error { } } for _, occurrence := range c.unionOccurrences { - identity := c.unionIdentity(occurrence.union, occurrence.role, occurrence.policy) - if c.findUnion(identity) == nil { + identity := c.unionIdentity(occurrence.union, occurrence.role, occurrence.policy, occurrence.api) + if record := c.findUnion(identity); record != nil { + record.identity.releasedOrder = min(record.identity.releasedOrder, identity.releasedOrder) + if record.identity.api == "" || identity.api != "" && identity.api < record.identity.api { + record.identity.api = identity.api + } + } else { c.unions = append(c.unions, &wireUnionRecord{identity: identity, union: occurrence.union}) } } @@ -235,7 +388,7 @@ func (c *wireTypeCatalog) Declare() error { codegen.NameType, union.union.Name(), codegen.ExportedName, - union.identity.order("union", union.union.Name(), ""), + union.identity.order(wireNameUnion, union.union.Name(), ""), ) if err := c.pkg.DeclareName(union.declaration); err != nil { return err @@ -245,7 +398,7 @@ func (c *wireTypeCatalog) Declare() error { union.declaration, "", "Kind", - union.identity.order("union kind", union.union.Name(), ""), + union.identity.order(wireNameUnionKind, union.union.Name(), ""), ) if err != nil { return err @@ -259,7 +412,7 @@ func (c *wireTypeCatalog) Declare() error { union.kind, "", codegen.Goify(branch.Name, true), - union.identity.order("union constant", union.union.Name(), branch.Name), + union.identity.order(wireNameUnionConstant, union.union.Name(), branch.Name), ) if err != nil { return err @@ -269,7 +422,7 @@ func (c *wireTypeCatalog) Declare() error { union.declaration, "New", codegen.Goify(branch.Name, true), - union.identity.order("union constructor", union.union.Name(), branch.Name), + union.identity.order(wireNameUnionConstructor, union.union.Name(), branch.Name), ) if err != nil { return err @@ -280,24 +433,52 @@ func (c *wireTypeCatalog) Declare() error { } for _, transform := range c.transforms { for _, helper := range transform.plan.Helpers() { - preferred := transform.prefix + codegen.Goify(wireTransformTypeName(helper.Source), true) + "To" + codegen.Goify(wireTransformTypeName(helper.Target), true) - declaration := codegen.NewPreferredName( - codegen.NameFunction, - preferred, - codegen.UnexportedName, - wireNameOrder{ - family: "transform helper", - source: expr.Hash(transform.source.Type, false, false, false), - preferred: preferred, - shape: expr.Hash(transform.target.Type, false, false, false), - role: uint8(helper.Occurrence), - view: transform.owner, - }, - ) - if err := c.pkg.DeclareName(declaration); err != nil { + identity, err := c.transformHelperIdentity(transform, helper) + if err != nil { + return err + } + preferred, err := c.transformHelperPreferredName(transform.prefix, identity) + if err != nil { return err } - if err := transform.plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + order := wireNameOrder{ + kind: wireNameTransformHelper, + source: identity.source.orderKey(), + target: identity.target.orderKey(), + preferred: preferred, + required: identity.required, + } + record := c.findTransformHelper(identity) + if record == nil { + record = &wireTransformHelperRecord{ + identity: identity, + prefix: transform.prefix, + preferred: preferred, + order: order, + } + c.transformHelpers = append(c.transformHelpers, record) + } else if order.ComparePackageName(record.order) < 0 { + record.prefix = transform.prefix + record.preferred = preferred + record.order = order + } + c.transformBindings[helper.ID] = record + } + } + for _, helper := range c.transformHelpers { + declaration, err := c.declareTransformHelper(helper) + if err != nil { + return err + } + helper.declaration = declaration + } + for _, transform := range c.transforms { + for _, planned := range transform.plan.Helpers() { + helper := c.transformBindings[planned.ID] + if helper == nil { + return fmt.Errorf("HTTP conversion function declaration was not recorded") + } + if err := transform.plan.BindHelperDeclaration(planned.ID, helper.declaration); err != nil { return err } } @@ -307,44 +488,313 @@ func (c *wireTypeCatalog) Declare() error { } // collectTransform records one request or response conversion before Goa -// chooses the names of any extra conversion functions. Calls use these records -// in the same order. -func (c *wireTypeCatalog) collectTransform(source, target *expr.AttributeExpr, prefix, owner string) { +// chooses the names of any extra conversion functions. The returned handle +// selects this record when the caller later writes the conversion. +func (c *wireTypeCatalog) collectTransform(source, target *expr.AttributeExpr, prefix, owner string, layout wireTransformLayout) wireTransformHandle { if c.declared { panic("cannot collect an HTTP conversion after package declarations are submitted") } source = expr.DupAtt(source) target = expr.DupAtt(target) - plan, err := codegen.NewTransformPlan(source, target) + plan, err := codegen.NewTransformPlan(source, target, "", nil) if err != nil { panic(err) } - c.transforms = append(c.transforms, &wireTransformRecord{source: source, target: target, prefix: prefix, owner: owner, plan: plan}) + record := &wireTransformRecord{ + source: source, + target: target, + prefix: prefix, + owner: owner, + layout: layout, + plan: plan, + } + c.transforms = append(c.transforms, record) + return wireTransformHandle{catalog: c, record: record} } -// renderTransform writes the next matching conversion with the function names -// chosen by Declare. It returns an error when collectTransform did not record -// the conversion. -func (c *wireTypeCatalog) renderTransform(source, target *expr.AttributeExpr, sourceVar, targetVar, prefix string, sourceContext, targetContext *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - for _, transform := range c.transforms { - if transform.used || transform.prefix != prefix || - !wireAttributesEqual(transform.source, source, make(map[wireAttributePair]struct{})) || - !wireAttributesEqual(transform.target, target, make(map[wireAttributePair]struct{})) { - continue +// transformHelperIdentity resolves the exact generated declarations and field +// layouts used by one planned conversion function. +func (c *wireTypeCatalog) transformHelperIdentity(transform *wireTransformRecord, helper codegen.TransformHelper) (wireTransformHelperIdentity, error) { + sourceWire := transform.layout.wireSide == wireTransformSource + targetWire := transform.layout.wireSide == wireTransformTarget + source, err := c.transformTypeIdentity(helper.Source, sourceWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + target, err := c.transformTypeIdentity(helper.Target, targetWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + return wireTransformHelperIdentity{ + source: source, + target: target, + required: helper.Required, + }, nil +} + +// transformTypeIdentity returns the declaration and field rules that determine +// a generated conversion function's parameter or result type. +func (c *wireTypeCatalog) transformTypeIdentity(attribute *expr.AttributeExpr, wire bool, policy wireTypePolicy, servicePointer bool, servicePackage codegen.ImportSpec) (wireTransformTypeIdentity, error) { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q is not named", attribute.Type.Name()) + } + if !wire { + if location := codegen.UserTypeLocation(userType); location != nil { + servicePackage = codegen.ImportSpec{Name: location.PackageName(), Path: location.RelImportPath} } - if err := transform.plan.BindContexts(sourceContext, targetContext); err != nil { - return "", nil, err + if servicePackage.Name == "" || servicePackage.Path == "" { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q has no service package", userType.Name()) } - c.bindTransformOccurrence(transform.source, source, sourceContext) - c.bindTransformOccurrence(transform.target, target, targetContext) - for _, helper := range transform.plan.Helpers() { - c.bindTransformHelper(helper.Source, sourceContext) - c.bindTransformHelper(helper.Target, targetContext) + return wireTransformTypeIdentity{ + origin: userType.Origin(), + attribute: attribute, + servicePackage: servicePackage, + layout: codegen.GoLayoutPolicy{ + Pointer: servicePointer, + UseDefault: true, + SumType: true, + }, + }, nil + } + policy.view = "" + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q was not recorded", preferred) + } + return wireTransformTypeIdentity{ + wire: record, + attribute: attribute, + layout: codegen.GoLayoutPolicy{ + Pointer: policy.pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + }, + }, nil +} + +// transformHelperPreferredName describes both generated types converted by +// one helper before their package selects final declaration names. +func (c *wireTypeCatalog) transformHelperPreferredName(prefix string, identity wireTransformHelperIdentity) (string, error) { + source, err := c.transformTypeRoleName(identity.source) + if err != nil { + return "", err + } + target, err := c.transformTypeRoleName(identity.target) + if err != nil { + return "", err + } + return prefix + codegen.Goify(source, true) + "To" + codegen.Goify(target, true) + identity.behaviorSuffix(), nil +} + +// transformTypeRoleName returns the package and type role used in a helper +// name. Wire values use their planned request or response declaration. +func (c *wireTypeCatalog) transformTypeRoleName(identity wireTransformTypeIdentity) (string, error) { + if identity.wire != nil { + return identity.wire.preferredName(), nil + } + userType, ok := identity.attribute.Type.(expr.UserType) + if !ok { + return "", fmt.Errorf("HTTP conversion function type %q is not named", identity.attribute.Type.Name()) + } + typeName := codegen.Goify(wireTypeDeclaredName(userType), true) + return identity.servicePackage.Name + typeName, nil +} + +// declareTransformHelper makes the function name depend on the wire type it +// converts. If that type receives a suffix, the helper receives it too. +func (c *wireTypeCatalog) declareTransformHelper(helper *wireTransformHelperRecord) (*codegen.NameDeclaration, error) { + sourceWire := helper.identity.source.wire + targetWire := helper.identity.target.wire + if (sourceWire == nil) == (targetWire == nil) { + return nil, fmt.Errorf("HTTP conversion function must convert between one wire type and one service type") + } + if sourceWire != nil { + target, err := c.transformTypeRoleName(helper.identity.target) + if err != nil { + return nil, err } - transform.used = true - return transform.plan.Render(sourceVar, targetVar, true) + return c.pkg.DeclareDependentName( + codegen.NameFunction, + sourceWire.declaration, + helper.prefix, + "To"+codegen.Goify(target, true)+helper.identity.behaviorSuffix(), + helper.order, + ) } - return "", nil, fmt.Errorf("HTTP %s conversion was not submitted before package names were assigned", prefix) + source, err := c.transformTypeRoleName(helper.identity.source) + if err != nil { + return nil, err + } + return c.pkg.DeclareDependentName( + codegen.NameFunction, + targetWire.declaration, + helper.prefix+codegen.Goify(source, true)+"To", + helper.identity.behaviorSuffix(), + helper.order, + ) +} + +// behaviorSuffix distinguishes a conversion that preserves a missing source +// value from one whose caller guarantees that the source is present. +func (i wireTransformHelperIdentity) behaviorSuffix() string { + if !i.required { + return "Optional" + } + return "" +} + +// findTransformHelper returns the declaration for an equivalent conversion. +func (c *wireTypeCatalog) findTransformHelper(identity wireTransformHelperIdentity) *wireTransformHelperRecord { + for _, record := range c.transformHelpers { + if wireTransformHelperIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// wireTransformHelperIdentitiesEqual reports whether two functions have the +// same generated parameter, result, field layout, and nil behavior. +func wireTransformHelperIdentitiesEqual(left, right wireTransformHelperIdentity) bool { + return left.required == right.required && + wireTransformTypeIdentitiesEqual(left.source, right.source) && + wireTransformTypeIdentitiesEqual(left.target, right.target) +} + +// wireTransformTypeIdentitiesEqual compares exact generated declarations and +// the concrete fields written inside service types. +func wireTransformTypeIdentitiesEqual(left, right wireTransformTypeIdentity) bool { + if left.wire != right.wire || left.origin != right.origin || left.layout != right.layout || left.servicePackage != right.servicePackage { + return false + } + if left.wire != nil { + return true + } + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) +} + +// orderKey describes every generated fact that changes a conversion +// function's parameter or result type. +func (i wireTransformTypeIdentity) orderKey() string { + if i.wire != nil { + order := i.wire.identity.order(wireNameType) + return fmt.Sprintf( + "wire:%q:%d:%q:%q:%q:%t:%t:%t:%t", + order.source, + order.role, + order.preferred, + order.shape, + order.view, + order.request, + order.pointer, + order.arrayElementPointer, + order.defaults, + ) + } + return fmt.Sprintf( + "service:%q:%q:%q:%q:%t:%t:%t:%t:%t", + i.servicePackage.Path, + i.servicePackage.Name, + wireTypeDeclaredName(i.origin), + expr.Hash(i.attribute.Type, false, false, false), + i.layout.Pointer, + i.layout.IgnoreRequired, + i.layout.UseDefault, + i.layout.UnionPointer, + i.layout.ArrayElementPointer, + ) +} + +// renderTransform writes the conversion selected when wire types were +// collected. The handle prevents two structurally identical conversions from +// being exchanged when callers render them in a different order. +func (c *wireTypeCatalog) renderTransform( + handle wireTransformHandle, + wireAttribute *expr.AttributeExpr, + sourceVar, targetVar string, + sourceContext, targetContext *codegen.AttributeContext, +) (string, []*codegen.TransformFunctionData, error) { + if handle.catalog != c || handle.record == nil { + return "", nil, fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + transform := handle.record + if transform.used { + return "", nil, fmt.Errorf("HTTP %s conversion for %s was already rendered", transform.prefix, transform.owner) + } + if err := transform.plan.BindContexts(sourceContext, targetContext); err != nil { + return "", nil, err + } + if transform.layout.wireSide == wireTransformSource { + c.bindTransformOccurrence(transform.source, wireAttribute, sourceContext) + } else { + c.bindTransformOccurrence(transform.target, wireAttribute, targetContext) + } + for _, helper := range transform.plan.Helpers() { + c.bindTransformHelper(helper.Source, sourceContext) + c.bindTransformHelper(helper.Target, targetContext) + } + code, helpers, err := transform.plan.Render(sourceVar, targetVar, true) + if err != nil { + return "", nil, err + } + if err := c.retainTransformDefinitions(helpers); err != nil { + return "", nil, err + } + transform.used = true + return code, helpers, nil +} + +// checkTransformUsed rejects a planned conversion that was never written. A +// generated package may contain records from several transport plans, so the +// caller checks only handles owned by the plan currently linking. +func (c *wireTypeCatalog) checkTransformUsed(handle wireTransformHandle) error { + if handle.record == nil { + return nil + } + if handle.catalog != c { + return fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + if !handle.record.used { + return fmt.Errorf("HTTP %s conversion for %s was planned but not rendered", handle.record.prefix, handle.record.owner) + } + return nil +} + +// retainTransformDefinitions verifies that every independently planned use of +// one package function has the same parameter type, result type, and body. +func (c *wireTypeCatalog) retainTransformDefinitions(helpers []*codegen.TransformFunctionData) error { + if c.transformDefinitions == nil { + c.transformDefinitions = make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + } + pending := make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + for _, helper := range helpers { + previous := c.transformDefinitions[helper.Declaration] + if previous == nil { + previous = pending[helper.Declaration] + } + if previous != nil && !wireTransformDefinitionsEqual(previous, helper) { + return fmt.Errorf("HTTP transform helper declaration %q has different definitions", helper.Declaration.Name()) + } + pending[helper.Declaration] = helper + } + for declaration, helper := range pending { + c.transformDefinitions[declaration] = helper + } + return nil +} + +// wireTransformDefinitionsEqual reports whether two planned conversions emit +// the same package-level function. +func wireTransformDefinitionsEqual(left, right *codegen.TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code } // bindTransformHelper gives a nested copied field the same Go type name used by @@ -376,22 +826,6 @@ func (c *wireTypeCatalog) bindTransformOccurrence(planned, rendered *expr.Attrib c.applyNamesRecursive(planned, record.identity.role, record.identity.policy, make(map[expr.UserType]struct{})) } -// wireTransformTypeName returns the designed type name used in a generated -// conversion function name. -func wireTransformTypeName(attribute *expr.AttributeExpr) string { - if userType, ok := attribute.Type.(expr.UserType); ok { - name := wireTypeDeclaredName(userType) - if location := codegen.UserTypeLocation(userType); location != nil { - return location.PackageName() + codegen.Goify(name, true) - } - return name - } - if union, ok := attribute.Type.(*expr.Union); ok { - return union.Name() - } - return attribute.Type.Name() -} - // Link reads the assigned package names and builds the type definitions, // references, unions, and validation functions written to files. func (c *wireTypeCatalog) Link() { @@ -418,7 +852,7 @@ func (c *wireTypeCatalog) Link() { c.applyUnionRecord(union.union, union) } for _, union := range c.unions { - union.data = buildHTTPUnionTypeData(union.union, c.resolver(c.scope, wireTypePolicy{}), union) + union.data = buildHTTPUnionTypeData(union.union, c.occurrenceResolver(c.scope), union) } c.linked = true } @@ -460,11 +894,7 @@ func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTyp if !ok { return nil } - preferred := wireTypeDeclaredName(userType.Origin()) - if policy.view != "" { - preferred = wireTypeDeclaredName(userType) - } - return c.lookup(attribute, role, policy, codegen.Goify(preferred, true)) + return c.lookup(attribute, role, policy, wireTypePreferredName(userType, policy)) } // applyNames associates every copied nested attribute with the Go type name @@ -528,14 +958,23 @@ func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { // several equivalent copies need validation, it stores their shared validator. func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData { data.declaration = record - if data.ValidateDef != "" { + data.Declaration = record.declaration + data.VarName = record.declaration.Name() + data.ValidatorDeclaration = record.validator + data.NestedValidatorDeclaration = record.nestedValidator + if record.validator != nil { data.ValidatorName = record.validator.Name() - data.ValidateRef = strings.Replace(data.ValidateRef, "Validate"+record.name, data.ValidatorName, 1) + } + if record.nestedValidator != nil { + data.NestedValidatorName = record.nestedValidator.Name() } if data.Init != nil { data.Init.Declaration = record.constructor data.Init.Name = record.constructor.Name() } + if description := record.errorDescription(); description != "" { + data.Description = description + } if record.data == nil { if data.Def == "" && data.ValidateDef == "" { return data @@ -556,27 +995,105 @@ func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData if record.data.ValidateDef == "" { record.data.ValidateDef = data.ValidateDef record.data.ValidateRef = data.ValidateRef + record.data.ValidatorName = data.ValidatorName } else if record.data.ValidateDef != data.ValidateDef || record.data.ValidateRef != data.ValidateRef { panic(fmt.Sprintf("HTTP type %q produced conflicting validators", record.name)) } } + if data.NestedValidateDef != "" { + if record.data.NestedValidateDef == "" { + record.data.NestedValidateDef = data.NestedValidateDef + record.data.NestedValidatorName = data.NestedValidatorName + } else if record.data.NestedValidateDef != data.NestedValidateDef { + panic(fmt.Sprintf("HTTP type %q produced conflicting nested validators", record.name)) + } + } return data } +// addErrorUse records one error body role and keeps all roles in a stable +// order before generated source is written. +func (r *wireTypeRecord) addErrorUse(use wireErrorUse) { + for _, existing := range r.errorUses { + if existing == use { + return + } + } + r.errorUses = append(r.errorUses, use) + slices.SortFunc(r.errorUses, func(left, right wireErrorUse) int { + for _, compared := range []int{ + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.name, right.name), + } { + if compared != 0 { + return compared + } + } + return 0 + }) +} + +// errorDescription describes every designed error that uses the generated +// type. Two errors on one endpoint remain short enough for one sentence. +func (r *wireTypeRecord) errorDescription() string { + if len(r.errorUses) == 0 { + return "" + } + first := r.errorUses[0] + if len(r.errorUses) == 1 { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q error.", + r.name, + first.service, + first.method, + first.name, + ) + } + if len(r.errorUses) == 2 && first.service == r.errorUses[1].service && first.method == r.errorUses[1].method { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q and %q errors.", + r.name, + first.service, + first.method, + first.name, + r.errorUses[1].name, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is the HTTP response body type for these service errors:", r.name) + for _, use := range r.errorUses { + fmt.Fprintf( + &description, + "\n- %q service %q endpoint: %q error", + use.service, + use.method, + use.name, + ) + } + return description.String() +} + // collectRecursive records named types and stops when a type refers back to one -// it is already reading. -func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, seen map[expr.UserType]struct{}) *wireTypeRecord { +// it is already reading. Released names are kept separately from current type +// identity so several old declarations can share one current declaration. +func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred, releasedSuffix, api string, root bool, releasedNames map[expr.UserType]string, seen map[expr.UserType]struct{}) *wireTypeRecord { if attribute.Type == expr.Empty { return nil } var record *wireTypeRecord if userType, ok := attribute.Type.(expr.UserType); ok { - name := wireTypeDeclaredName(userType.Origin()) - if policy.view != "" { - name = wireTypeDeclaredName(userType) + preferred = wireTypePreferredName(userType, policy) + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + released := preferred + if name := releasedNames[userType]; name != "" { + released = name + } else if !root { + released += releasedSuffix } - preferred = codegen.Goify(name, true) - record = c.findOrAppend(newWireTypeIdentity(attribute, role, policy, preferred)) + record.addReleasedName(released) origin := userType.Origin() if _, ok := seen[origin]; ok { return record @@ -584,41 +1101,71 @@ func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role w seen[origin] = struct{}{} nestedPolicy := policy nestedPolicy.view = "" - c.collectRecursive(userType.Attribute(), wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(userType.Attribute(), wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) delete(seen, origin) return record } if preferred != "" { - record = c.findOrAppend(newWireTypeIdentity(attribute, role, policy, preferred)) + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + record.addReleasedName(preferred) } switch actual := attribute.Type.(type) { case *expr.Object: nestedPolicy := policy nestedPolicy.view = "" for _, named := range sortedWireAttributes(*actual) { - c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) } case *expr.Array: nestedPolicy := policy nestedPolicy.view = "" - c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) case *expr.Map: nestedPolicy := policy nestedPolicy.view = "" - c.collectRecursive(actual.KeyType, wireAttribute, nestedPolicy, "", seen) - c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(actual.KeyType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) case *expr.Union: nestedPolicy := policy nestedPolicy.view = "" union := expr.Dup(actual).(*expr.Union) - c.unionOccurrences = append(c.unionOccurrences, wireUnionOccurrence{union: union, role: role, policy: policy}) + c.unionOccurrences = append(c.unionOccurrences, wireUnionOccurrence{union: union, role: role, policy: policy, api: api}) for _, named := range actual.Values { - c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", seen) + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) } } return record } +// addReleasedName records one spelling used before HTTP types were retained. +func (r *wireTypeRecord) addReleasedName(name string) { + if name == "" || slices.Contains(r.releasedNames, name) { + return + } + r.releasedNames = append(r.releasedNames, name) + slices.Sort(r.releasedNames) +} + +// replaceReleasedName records the public spelling used for one response after +// removing the name that the shared type planner first assigned to that use. +func (r *wireTypeRecord) replaceReleasedName(current, released string) { + r.releasedNames = slices.DeleteFunc(r.releasedNames, func(name string) bool { + return name == current + }) + r.addReleasedName(released) +} + +// preferredName keeps a released spelling only when it still names exactly one +// retained declaration. Shared declarations use their current designed name. +func (r *wireTypeRecord) preferredName() string { + if len(r.releasedNames) == 1 { + return r.releasedNames[0] + } + return r.identity.preferred +} + // findOrAppend reuses a record with the same generated type definition or adds // a new record. func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecord { @@ -626,11 +1173,98 @@ func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecor record.needsValidator = record.needsValidator || identity.policy.validate return record } - record := &wireTypeRecord{identity: identity, needsValidator: identity.policy.validate} + record := &wireTypeRecord{ + identity: identity, + needsValidator: identity.policy.validate, + } c.records = append(c.records, record) return record } +// addValidationRoot records an inline HTTP value whose generated decoder or +// constructor runs validation. +func (c *wireTypeCatalog) addValidationRoot(attribute *expr.AttributeExpr, policy wireTypePolicy) { + c.validationRoots = append(c.validationRoots, wireValidationRoot{ + attribute: expr.DupAtt(attribute), + policy: policy, + }) +} + +// planNestedValidators marks the named validators called by generated public +// validators and inline validation code. +func (c *wireTypeCatalog) planNestedValidators() { + for _, record := range c.records { + record.needsNestedCall = false + } + for _, record := range c.records { + if record.needsValidator { + c.markNestedValidatorCalls(record.identity.attribute, record.identity.policy) + } + } + for _, root := range c.validationRoots { + c.markNestedValidatorCalls(root.attribute, root.policy) + } +} + +// markNestedValidatorCalls follows inline validation until it reaches a named +// type. A named type gets one private helper because the caller supplies its +// complete error path. +func (c *wireTypeCatalog) markNestedValidatorCalls(attribute *expr.AttributeExpr, policy wireTypePolicy) { + if userType, ok := attribute.Type.(expr.UserType); ok && !expr.IsAlias(userType) { + attribute = userType.Attribute() + } + policy.view = "" + c.markInlineValidationCalls(attribute, policy, policy.pointer) +} + +// markInlineValidationCalls records named calls inside one generated +// validation body. Anonymous values and aliases remain inside their caller. +func (c *wireTypeCatalog) markInlineValidationCalls(attribute *expr.AttributeExpr, policy wireTypePolicy, pointer bool) { + if userType, ok := attribute.Type.(expr.UserType); ok { + if expr.IsAlias(userType) { + c.markInlineValidationCalls(userType.Attribute(), policy, pointer) + return + } + layout := codegen.GoLayoutPolicy{ + Pointer: pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + } + if !codegen.NeedsValidation(userType.Attribute(), layout) { + return + } + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP nested validator for %q was not collected", preferred)) + } + if !record.needsValidator { + panic(fmt.Sprintf("HTTP nested validator for %q has no public validator", record.name)) + } + record.needsNestedCall = true + return + } + + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, field := range *actual { + c.markInlineValidationCalls(field.Attribute, policy, pointer) + } + case *expr.Array: + c.markInlineValidationCalls(actual.ElemType, policy, pointer) + case *expr.Map: + c.markInlineValidationCalls(actual.KeyType, policy, false) + c.markInlineValidationCalls(actual.ElemType, policy, false) + case *expr.Union: + for _, branch := range actual.Values { + branchPointer := pointer && expr.IsObject(branch.Attribute.Type) + c.markInlineValidationCalls(branch.Attribute, policy, branchPointer) + } + } +} + // find returns the record for the same generated type definition. func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { for _, record := range c.records { @@ -643,24 +1277,36 @@ func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { // unionIdentity returns the Go type used by every named branch of // union without changing union. -func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, policy wireTypePolicy) wireUnionIdentity { - identity := wireUnionIdentity{definition: codegen.NewUnionTypeID(union)} +func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, policy wireTypePolicy, api ...string) wireUnionIdentity { + identity := wireUnionIdentity{ + definition: codegen.NewUnionTypeID(union), + releasedOrder: releasedUnionOrder(policy), + } + if len(api) > 0 { + identity.api = api[0] + } attribute := &expr.AttributeExpr{Type: union} c.collectUnionDeclarations(attribute, role, policy, &identity.declarations, make(map[expr.UserType]struct{})) return identity } +// releasedUnionOrder keeps the request union name when request and response +// copies of the same designed union need different Go declarations. +func releasedUnionOrder(policy wireTypePolicy) uint8 { + if policy.request { + return 1 + } + return 2 +} + // collectUnionDeclarations records generated branch types in branch order. func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, declarations *[]*wireTypeRecord, seen map[expr.UserType]struct{}) { if attribute.Type == expr.Empty { return } if userType, ok := attribute.Type.(expr.UserType); ok { - preferred := wireTypeDeclaredName(userType.Origin()) - if policy.view != "" { - preferred = wireTypeDeclaredName(userType) - } - record := c.find(newWireTypeIdentity(attribute, role, policy, codegen.Goify(preferred, true))) + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, role, policy, preferred)) if record == nil { panic(fmt.Sprintf("HTTP union branch type %q was not submitted before package names were assigned", preferred)) } @@ -720,7 +1366,7 @@ func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExp panic(fmt.Sprintf("HTTP union branch %q has no submitted Go type name", wireTypeDeclaredName(userType))) } record := declarations[*index] - *index = *index + 1 + (*index)++ c.bindOccurrence(attribute, record) origin := userType.Origin() if _, ok := seen[origin]; ok { @@ -762,6 +1408,22 @@ func (c *wireTypeCatalog) resolver(scope *codegen.NameScope, policy wireTypePoli return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy} } +// occurrenceResolver uses only the exact type records attached to one copied +// expression. Union declarations use it after their branch records are fixed. +func (c *wireTypeCatalog) occurrenceResolver(scope *codegen.NameScope) codegen.Attributor { + return &wireAttributeScope{ + catalog: c, + base: codegen.NewAttributeScope(scope), + exactOccurrence: true, + } +} + +// rootResolver applies a selected result view only to root. Nested fields use +// their normal type definitions without that view. +func (c *wireTypeCatalog) rootResolver(scope *codegen.NameScope, policy wireTypePolicy, root *wireTypeRecord) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy, viewRoot: root} +} + // bindOccurrence records the Go type used by one copied named value and its fields. func (c *wireTypeCatalog) bindOccurrence(attribute *expr.AttributeExpr, record *wireTypeRecord) { c.bindings[attribute] = record @@ -770,6 +1432,64 @@ func (c *wireTypeCatalog) bindOccurrence(attribute *expr.AttributeExpr, record * } } +// applyReleasedNames gives a copied composite body the public nested type +// names used to build its released constructor name. +func (c *wireTypeCatalog) applyReleasedNames(attribute *expr.AttributeExpr, policy wireTypePolicy, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP type %q was not submitted before its constructor name was built", preferred)) + } + userType.Attribute().AddMeta("struct:type:name", record.preferredName()) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyReleasedNames(userType.Attribute(), nestedPolicy, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + case *expr.Array: + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Map: + c.applyReleasedNames(actual.KeyType, nestedPolicy, seen) + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Union: + for _, named := range actual.Values { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + } +} + +// releasedCompositeName returns the old public name for an array or map after +// applying its nested public type names. +func (c *wireTypeCatalog) releasedCompositeName(body *expr.AttributeExpr, policy wireTypePolicy) string { + body = expr.DupAtt(body) + c.applyReleasedNames(body, policy, make(map[expr.UserType]struct{})) + name := codegen.NewAttributeScope(codegen.NewNameScope()).Name(body, "", policy.pointer, policy.useDefault) + return codegen.Goify(name, true) +} + +// releasedCompositeConstructorName returns the old public constructor name for +// an array or map body. +func (c *wireTypeCatalog) releasedCompositeConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + return "New" + c.releasedCompositeName(body, policy) +} + // Name returns the type name selected for this HTTP attribute copy. func (s *wireAttributeScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { if record := s.record(attribute); record != nil { @@ -794,10 +1514,11 @@ func (s *wireAttributeScope) Name(attribute *expr.AttributeExpr, pkg string, poi return codegen.GoNativeTypeName(actual) case *expr.Array, *expr.Map, *expr.Object: context := &codegen.AttributeContext{ - Pointer: pointer, - UseDefault: useDefault, - Scope: s, - UnionPointer: true, + Pointer: pointer, + UseDefault: useDefault, + Scope: s, + UnionPointer: true, + ArrayElementPointer: s.policy.arrayElementPointer, } return goTypeDefForContext(attribute, context) case expr.UserType: @@ -831,44 +1552,67 @@ func (s *wireAttributeScope) Enter(attribute *expr.AttributeExpr) codegen.Attrib if location := codegen.UserTypeLocation(attribute.Type); location != nil { pkg = location.PackageName() } - return &wireAttributeScope{catalog: s.catalog, base: s.base.Enter(attribute), pkg: pkg, policy: s.policy} + policy := s.policy + viewRoot := s.viewRoot + if policy.view != "" && (s.viewRoot == nil || !wireTypeIdentitiesEqual( + s.viewRoot.identity, + newWireTypeIdentity(attribute, s.viewRoot.identity.role, policy, s.viewRoot.identity.preferred), + )) { + policy.view = "" + viewRoot = nil + } + return &wireAttributeScope{ + catalog: s.catalog, + base: s.base.Enter(attribute), + pkg: pkg, + policy: policy, + viewRoot: viewRoot, + exactOccurrence: s.exactOccurrence, + } } -// IsSumType reports that HTTP unions use generated sum-type structs. +// IsSumType reports that HTTP unions use generated values that hold one branch. func (*wireAttributeScope) IsSumType() bool { return true } -// ValidatorName returns the validation function chosen for this copied type. -func (s *wireAttributeScope) ValidatorName(attribute *expr.AttributeExpr, view string) string { +// ValidatorCall returns the exact private call used for a named value inside +// another HTTP body value. +func (s *wireAttributeScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, path string) string { if record := s.record(attribute); record != nil { - if record.validator == nil { - panic(fmt.Sprintf("HTTP type %q has no validator declaration", record.name)) + if record.nestedValidator == nil { + panic(fmt.Sprintf("HTTP type %q has no nested validator", record.name)) } - return record.validator.Name() + return fmt.Sprintf("%s(%s, %s)", record.nestedValidator.Name(), target, path) } if userType, ok := attribute.Type.(expr.UserType); ok { panic(fmt.Sprintf("HTTP validator for %q has no package declaration", wireTypeDeclaredName(userType))) } - return s.base.ValidatorName(attribute, view) + return s.base.ValidatorCall(attribute, "", target, path) } // record returns the chosen type for attribute. A copied nested value may reuse // a type when its pointer and default-value rules are the same. func (s *wireAttributeScope) record(attribute *expr.AttributeExpr) *wireTypeRecord { - if record := s.catalog.bindings[attribute]; record != nil { - return record - } userType, ok := attribute.Type.(expr.UserType) if !ok { return nil } - preferred := wireTypeDeclaredName(userType.Origin()) - if s.policy.view != "" { - preferred = wireTypeDeclaredName(userType) + if s.exactOccurrence { + return s.catalog.bindings[attribute] } - preferred = codegen.Goify(preferred, true) - return s.catalog.find(newWireTypeIdentity(attribute, wireAttribute, s.policy, preferred)) + preferred := wireTypePreferredName(userType, s.policy) + if s.viewRoot != nil { + identity := newWireTypeIdentity(attribute, s.viewRoot.identity.role, s.policy, s.viewRoot.identity.preferred) + if wireTypeIdentitiesEqual(s.viewRoot.identity, identity) { + return s.viewRoot + } + } + identity := newWireTypeIdentity(attribute, wireAttribute, s.policy, preferred) + if record := s.catalog.bindings[attribute]; record != nil && wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + return s.catalog.find(identity) } // unionRecord returns the chosen union for union. A copied nested union may @@ -909,6 +1653,7 @@ func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, polic if resultType, ok := userType.(*expr.ResultTypeExpr); ok { identity.resultID = resultType.Identifier identity.role = 0 + identity.policy.view = "" } else if policy.view == "" { identity.sourceID = userType.Origin().ID() identity.role = 0 @@ -917,6 +1662,40 @@ func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, polic return identity } +// wireTypePreferredName returns the Go name requested for one designed type. +// A result type created for one view already has the view in its name. Other +// values use their authored type name unless selecting a view changes the +// fields in the generated transport type. +func wireTypePreferredName(userType expr.UserType, policy wireTypePolicy) string { + named := userType.Origin() + if _, projected := userType.(*expr.ResultTypeExpr); projected || policy.view != "" { + named = userType + } + return codegen.Goify(wireTypeDeclaredName(named), true) +} + +// releasedWireTypeSuffix returns the suffix that HTTP copies added to named +// values nested inside one body. The body declaration itself already keeps its +// endpoint name. +func releasedWireTypeSuffix(attribute *expr.AttributeExpr, role wireTypeRole) string { + switch role { + case wireRequestBody: + return "RequestBody" + case wireStreamPayload: + if expr.IsObject(attribute.Type) { + return "StreamingBody" + } + return "" + case wireResponseBody: + if expr.IsObject(attribute.Type) { + return "ResponseBody" + } + return "Response" + default: + return "" + } +} + // wireTypeIdentitiesEqual reports whether two records produce the same Go type. func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { if left.sourceID != right.sourceID || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { @@ -936,28 +1715,31 @@ func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { // order returns the designed values used to choose a stable suffix when several // HTTP declarations ask for the same Go name. -func (i wireTypeIdentity) order(family string) wireNameOrder { +func (i wireTypeIdentity) order(kind wireNameKind) wireNameOrder { return wireNameOrder{ - family: family, - source: i.sourceID + i.resultID, - role: uint8(i.role), - preferred: i.preferred, - shape: expr.Hash(i.attribute.Type, false, false, false), - view: i.policy.view, - request: i.policy.request, - pointer: i.policy.pointer, - defaults: i.policy.useDefault, + kind: kind, + api: i.api, + source: i.sourceID + i.resultID, + role: uint8(i.role), + preferred: i.preferred, + shape: expr.Hash(i.attribute.Type, false, false, false), + view: i.policy.view, + request: i.policy.request, + pointer: i.policy.pointer, + arrayElementPointer: i.policy.arrayElementPointer, + defaults: i.policy.useDefault, } } // order returns the designed values used to choose stable suffixes for a union, // its constants, and its functions. -func (i wireUnionIdentity) order(family, name, branch string) wireNameOrder { +func (i wireUnionIdentity) order(kind wireNameKind, name, branch string) wireNameOrder { declarations := make([]string, len(i.declarations)) for index, declaration := range i.declarations { - order := declaration.identity.order("type") + order := declaration.identity.order(wireNameType) declarations[index] = fmt.Sprintf( - "%q:%d:%q:%q:%q:%t:%t:%t", + "%q:%q:%d:%q:%q:%q:%t:%t:%t:%t", + order.api, order.source, order.role, order.preferred, @@ -965,11 +1747,14 @@ func (i wireUnionIdentity) order(family, name, branch string) wireNameOrder { order.view, order.request, order.pointer, + order.arrayElementPointer, order.defaults, ) } return wireNameOrder{ - family: family, + kind: kind, + unionUse: i.releasedOrder, + api: i.api, source: strings.Join(declarations, "\x00"), preferred: name, shape: string(i.definition), @@ -982,15 +1767,20 @@ func (i wireUnionIdentity) order(family, name, branch string) wireNameOrder { func (o wireNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { right := other.(wireNameOrder) for _, compared := range []int{ - cmp.Compare(o.family, right.family), + cmp.Compare(o.kind, right.kind), + cmp.Compare(o.unionUse, right.unionUse), + cmp.Compare(o.api, right.api), cmp.Compare(o.source, right.source), + cmp.Compare(o.target, right.target), cmp.Compare(o.role, right.role), cmp.Compare(o.preferred, right.preferred), cmp.Compare(o.shape, right.shape), cmp.Compare(o.view, right.view), cmp.Compare(boolOrder(o.request), boolOrder(right.request)), cmp.Compare(boolOrder(o.pointer), boolOrder(right.pointer)), + cmp.Compare(boolOrder(o.arrayElementPointer), boolOrder(right.arrayElementPointer)), cmp.Compare(boolOrder(o.defaults), boolOrder(right.defaults)), + cmp.Compare(boolOrder(o.required), boolOrder(right.required)), } { if compared != 0 { return compared diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go index af9a38b564..da72024358 100644 --- a/http/codegen/wire_catalog_test.go +++ b/http/codegen/wire_catalog_test.go @@ -52,6 +52,77 @@ func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { require.Len(t, catalog.records, 1) } +func TestWireTypeCatalogPreservesReleasedNestedNames(t *testing.T) { + cases := []struct { + name string + role wireTypeRole + body *expr.AttributeExpr + want string + }{ + { + name: "request body", + role: wireRequestBody, + body: wireCatalogContainer(wireCatalogType("Child", "request-child", "value", true)), + want: "ChildRequestBody", + }, + { + name: "streaming body", + role: wireStreamPayload, + body: wireCatalogContainer(wireCatalogType("Child", "stream-child", "value", true)), + want: "ChildStreamingBody", + }, + { + name: "object response body", + role: wireResponseBody, + body: wireCatalogContainer(wireCatalogType("Child", "response-child", "value", true)), + want: "ChildResponseBody", + }, + { + name: "collection response body", + role: wireResponseBody, + body: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{ + Type: wireCatalogType("Child", "response-element", "value", true), + }}}, + want: "ChildResponse", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + catalog, generation := testWireTypeCatalog(t) + catalog.collect(test.body, test.role, wireTypePolicy{}, "") + linkTestWireTypeCatalog(t, generation, catalog) + + child := firstWireUserType(test.body) + record := catalog.lookupUser(child, wireAttribute, wireTypePolicy{}) + require.Equal(t, test.want, record.name) + }) + } +} + +func TestWireTypeCatalogKeepsCurrentNameForSharedReleasedDeclarations(t *testing.T) { + child := wireCatalogType("Shared", "shared", "value", true) + request := wireCatalogContainer(child) + stream := wireCatalogContainer(child) + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(request, wireRequestBody, wireTypePolicy{}, "") + catalog.collect(stream, wireStreamPayload, wireTypePolicy{}, "") + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(request), wireAttribute, wireTypePolicy{}) + require.Equal(t, "Shared", record.name) +} + +func TestWireTypeCatalogSuffixesReleasedNameAfterPackageCollision(t *testing.T) { + body := wireCatalogContainer(wireCatalogType("Child", "child", "value", true)) + catalog, generation := testWireTypeCatalog(t, "ChildRequestBody") + catalog.collect(body, wireRequestBody, wireTypePolicy{}, "") + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(body), wireAttribute, wireTypePolicy{}) + require.Equal(t, "ChildRequestBody2", record.name) +} + func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *testing.T) { typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} withoutValidator := wireTypePolicy{pointer: true} @@ -60,13 +131,104 @@ func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *te first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator, "") second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator, "") + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(typeAttribute)}, + }}, withValidator) require.Same(t, first, second) linkTestWireTypeCatalog(t, generation, catalog) catalog.bind(first, &TypeData{Def: "struct { Value string }"}) - catalog.bind(second, &TypeData{Def: "struct { Value string }", ValidateDef: "validate shared"}) + catalog.bind(second, &TypeData{ + Def: "struct { Value string }", + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) require.Equal(t, "Shared", first.name) - require.Equal(t, "validate shared", first.data.ValidateDef) + require.Equal(t, "validate shared from body", first.data.ValidateDef) + require.Equal(t, "validate shared from parent path", first.data.NestedValidateDef) + require.Equal(t, "ValidateShared", first.data.ValidatorName) + require.Equal(t, "validateShared", first.data.NestedValidatorName) +} + +func TestWireTypeCatalogErrorDescriptionUsesAllPlannedErrors(t *testing.T) { + cases := []struct { + name string + uses []wireErrorUse + want string + }{ + { + name: "same endpoint", + uses: []wireErrorUse{ + {service: "Calc", method: "Add", name: "underflow"}, + {service: "Calc", method: "Add", name: "overflow"}, + }, + want: "Shared is the type of the \"Calc\" service \"Add\" endpoint HTTP response body for the \"overflow\" and \"underflow\" errors.", + }, + { + name: "several endpoints", + uses: []wireErrorUse{ + {service: "Beta", method: "Write", name: "conflict"}, + {service: "Alpha", method: "Read", name: "missing"}, + }, + want: "Shared is the HTTP response body type for these service errors:\n" + + "- \"Alpha\" service \"Read\" endpoint: \"missing\" error\n" + + "- \"Beta\" service \"Write\" endpoint: \"conflict\" error", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + record := catalog.collect(attribute, wireResponseBody, policy, "") + for _, use := range test.uses { + record.addErrorUse(use) + } + record.addErrorUse(test.uses[0]) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + Description: "Shared is an HTTP response body.", + Def: "struct { Value string }", + }) + + require.Equal(t, test.want, record.data.Description) + }) + } +} + +func TestWireTypeCatalogPlansNestedValidatorNameWithPackageNames(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "validateShared") + record := catalog.collect(attribute, wireAttribute, policy, "") + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(attribute)}, + }}, policy) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) + + require.Equal(t, "validateShared2", record.data.NestedValidatorName) +} + +func TestWireTypeCatalogDoesNotRewriteValidationCalls(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "ValidateShared") + record := catalog.collect(attribute, wireAttribute, policy, "") + linkTestWireTypeCatalog(t, generation, catalog) + + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + ValidateRef: "err = ValidateSharedCopy(v)", + }) + + require.Equal(t, "ValidateShared2", record.data.ValidatorName) + require.Equal(t, "err = ValidateSharedCopy(v)", record.data.ValidateRef) } func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { @@ -102,6 +264,25 @@ func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T require.Equal(t, "Shared2", first.name) } +func TestWireTypeCatalogBindingUsesCurrentLayoutPolicy(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + valuePolicy := wireTypePolicy{} + pointerPolicy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + catalog.collect(attribute, wireAttribute, valuePolicy, "") + catalog.collect(attribute, wireAttribute, pointerPolicy, "") + linkTestWireTypeCatalog(t, generation, catalog) + + valueRecord := catalog.lookupUser(attribute, wireAttribute, valuePolicy) + pointerRecord := catalog.lookupUser(attribute, wireAttribute, pointerPolicy) + require.NotSame(t, valueRecord, pointerRecord) + + valueScope := catalog.resolver(catalog.scope, valuePolicy) + pointerScope := catalog.resolver(catalog.scope, pointerPolicy) + require.Equal(t, valueRecord.name, valueScope.Name(attribute, "", false, false)) + require.Equal(t, pointerRecord.name, pointerScope.Name(attribute, "", true, false)) +} + func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { originalNil := expr.Empty.Attribute().Meta == nil original := expr.Empty.Attribute().Meta.Dup() @@ -176,3 +357,23 @@ func wireCatalogType(name, uid, field string, required bool) *expr.UserTypeExpr } return &expr.UserTypeExpr{AttributeExpr: &expr.AttributeExpr{Type: object}, TypeName: name, UID: uid} } + +// wireCatalogContainer places a named type inside an object body. +func wireCatalogContainer(child expr.UserType) *expr.AttributeExpr { + return &expr.AttributeExpr{Type: &expr.Object{{ + Name: "child", + Attribute: &expr.AttributeExpr{Type: child}, + }}} +} + +// firstWireUserType returns the first named value inside an object or array body. +func firstWireUserType(body *expr.AttributeExpr) *expr.AttributeExpr { + switch actual := body.Type.(type) { + case *expr.Object: + return (*actual)[0].Attribute + case *expr.Array: + return actual.ElemType + default: + panic("test body does not contain a named type") + } +} diff --git a/jsonrpc/ARCHITECTURE.md b/jsonrpc/ARCHITECTURE.md index f2930a7ae2..7c20f1eb8d 100644 --- a/jsonrpc/ARCHITECTURE.md +++ b/jsonrpc/ARCHITECTURE.md @@ -1,201 +1,103 @@ # Goa JSON-RPC Architecture -This document explains the architecture of Goa's JSON-RPC support, covering both basic HTTP and advanced WebSocket-based streaming communication. It details the code generation process, runtime behavior, and recommended usage patterns. +Goa implements JSON-RPC 2.0 over one HTTP POST route per service. A method +either returns one result in the HTTP response or streams results as JSON-RPC +messages carried by Server-Sent Events (SSE). -## Core Principle: Composition Over Modification +JSON-RPC does not support Goa client streams or bidirectional streams. A method +with `StreamingResult` must select `ServerSentEvents`. Ordinary HTTP methods may +still use WebSockets; that transport has its own generated code and does not +change the JSON-RPC service contract. -The fundamental principle behind Goa's JSON-RPC implementation is **composition over modification**. Instead of altering shared HTTP templates to accommodate JSON-RPC, the JSON-RPC code generation layer builds upon the existing HTTP transport infrastructure. This approach ensures a clean separation of concerns, preventing the HTTP layer from becoming coupled to JSON-RPC specifics and allowing both to evolve independently. +## Generation layers -## Code Generation - -The generation of JSON-RPC enabled services follows a layered process that starts with the standard HTTP transport code. - -### HTTP Codegen Foundation - -The process begins by generating the transport-agnostic service code, which includes: - -* Service interfaces and endpoints -* Basic HTTP handlers and middleware -* Encoding and decoding utilities -* Error handling infrastructure - -### JSON-RPC Composition Layer - -The JSON-RPC `codegen` package then composes on top of the generated HTTP code by programmatically manipulating the `codegen.File` data structure before it is rendered. This involves a three-step process: - -1. **Generate Base HTTP Code**: The standard `httpcodegen.ServerEncodeDecodeFile` function is called to produce the initial set of files. -2. **Modify Sections**: The generated sections are iterated upon to introduce JSON-RPC specific behavior. This includes adding necessary imports and replacing HTTP handler signatures with their JSON-RPC counterparts. -3. **Add JSON-RPC Sections**: Finally, new sections containing JSON-RPC specific logic, such as server handler initializers, are appended. - -This process is exemplified by the following snippet: +The generated service package owns transport-neutral method and stream +interfaces. JSON-RPC uses the same exact typed per-method server stream as HTTP +SSE and gRPC: ```go -// Step 1: Generate base HTTP code -f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) - -// Step 2: Modify sections before final code generation -for _, s := range f.SectionTemplates { - // Add JSON-RPC imports - if s.Name == "source-header" { - codegen.AddImport(s, codegen.GoaImport("jsonrpc")) - } - - // Modify signatures for JSON-RPC context - if s.Name == "request-decoder" { - s.Source = strings.Replace(s.Source, - httpRequestDecoderTemplate, - jsonrpcRequestDecoderTemplate, 1) - } - - // Namespace sections to avoid conflicts - s.Name = "jsonrpc-" + s.Name +type WatchServerStream interface { + Send(Event) error + SendWithContext(context.Context, Event) error + Close() error } - -// Step 3: Add JSON-RPC specific sections -sections = append(sections, - &codegen.SectionTemplate{ - Name: "jsonrpc-server-handler-init", - Source: jsonrpcTemplates.Read(serverHandlerInitT), - Data: e - }) ``` -### Key Codegen Patterns - -Three key patterns enable this compositional approach: - -1. **Template Namespacing**: JSON-RPC sections are prefixed with `jsonrpc-` to prevent name collisions with HTTP sections. -2. **In-Memory Modification**: Instead of altering the source templates on disk, modifications are made to the `Source` field of the `codegen.SectionTemplate` struct in memory. -3. **Conditional Template Selection**: The code generation logic dynamically selects the appropriate templates based on the endpoint configuration, for example, adding WebSocket-specific templates only when a WebSocket transport is defined for the service. - -### Template Responsibilities - -This layered approach results in a clean separation of responsibilities between HTTP and JSON-RPC templates: - -* **HTTP Templates (Shared)**: These are responsible for transport-agnostic service logic. They must not contain any JSON-RPC or WebSocket specific logic. -* **JSON-RPC Templates (Specialized)**: These handle JSON-RPC protocol specifics and WebSocket streaming. They can specialize HTTP behavior but should do so through composition, not modification of the HTTP templates. - -## Runtime Architecture and Usage - -The generated code provides two primary mechanisms for JSON-RPC communication: a simple HTTP transport for traditional request-response interactions, and a WebSocket transport for real-time, bidirectional streaming. - -### Standard JSON-RPC over HTTP - -For services that do not require streaming, JSON-RPC messages are exchanged over standard HTTP. The generated server code includes an HTTP handler that decodes the JSON-RPC request from the HTTP body, invokes the corresponding service method, and writes the JSON-RPC response back to the HTTP response writer. - -The handler signatures clearly illustrate the differences between the transport layers: - -* **Regular HTTP**: `func(context.Context, *http.Request, http.ResponseWriter)` -* **JSON-RPC HTTP**: `func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter)` +The HTTP generation plan owns JSON request and response body types, +conversions, validation, and file imports. The JSON-RPC generator copies the +finished HTTP plan and adds JSON-RPC request dispatch and message framing. It +does not change the service interface or inspect generated types at runtime. -### JSON-RPC over WebSocket +Generation follows this order: -Goa provides a powerful abstraction for building streaming services with WebSockets. This allows for full-duplex communication channels that can support a variety of interaction patterns. +1. The design is evaluated and rejects stream shapes JSON-RPC cannot carry. +2. The service plan assigns exact method and stream types. +3. The HTTP JSON-RPC plan assigns request and response body types and + conversions. +4. The JSON-RPC plan writes only the unary or SSE code selected by the design. -#### Architectural Principles +## Unary requests -The WebSocket architecture is guided by three principles: +The server reads one JSON-RPC request, decodes its `params` into the designed +payload, calls the service endpoint once, and writes one JSON-RPC response when +the request contains an `id`. A request without an `id` is a notification and +receives no response. -1. **Single WebSocket Connection**: A single WebSocket connection is used to handle all JSON-RPC communication for a given service, including multiplexing different method calls and streaming patterns. -2. **User Code Owns Streaming Logic**: The core streaming logic is implemented by the developer in the `HandleStream` method. Goa provides the infrastructure and the `Stream` interface, but the implementation of the streaming strategy is left to the user. -3. **Clean Separation of Concerns**: The architecture separates the business logic (in service methods), the transport layer (JSON-RPC protocol and WebSocket management), and the streaming logic (in `HandleStream`). +The shared service route dispatches requests by their JSON-RPC `method`. Batch +requests use the same per-method handlers and collect only responses for calls +that contain an `id`. -#### Core Components +## Server-sent-event streams -The WebSocket support is built around three core components: +The client sends one JSON-RPC request over HTTP. Each service call to `Send` +writes an SSE event named `notification` whose data is a complete JSON-RPC +notification: -* **`HandleStream` Method**: This method is the entry point for all WebSocket communication. It is where the developer implements the application-specific streaming logic. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - // User implements their streaming strategy here. - // Can listen to channels, timers, events, etc. - // Can call stream.Recv() to process incoming JSON-RPC requests. - // Can call stream.SendMethodName() to send responses or notifications. - } - ``` - -* **`Stream` Interface**: This generated interface provides the methods for interacting with the WebSocket connection, including receiving requests (`Recv`), sending responses (`SendMethodName`), sending errors (`SendError`), and closing the connection (`Close`). - -* **Service Methods**: These are the regular service methods with standard Go signatures. They are called automatically when `Recv()` processes a matching JSON-RPC request and can also be called directly from `HandleStream` for server-initiated communication. - -The handler signature for WebSocket streaming endpoints reflects the asynchronous nature of the communication: - -```go -func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) +```json +{"jsonrpc":"2.0","method":"watch","params":{"value":"ready"}} ``` -The handler returns a result and an error because responses are sent asynchronously via the `Stream` interface rather than being written directly to an `http.ResponseWriter`. - -#### Streaming Patterns - -The flexibility of the `HandleStream` method allows for a variety of streaming patterns: - -* **Request-Response**: The traditional JSON-RPC pattern can be implemented by simply calling `stream.Recv()` in a loop. When `Recv()` is called, it reads a JSON-RPC request from the WebSocket, dispatches it to the appropriate service method, and automatically sends the response back. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - defer stream.Close() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - if err := stream.Recv(ctx); err != nil { - return err - } - } - } - } - ``` +The service method return completes the stream. For a request with an `id`, the +transport writes exactly one terminal event: -* **Server Streaming**: To push data from the server to the client, the `HandleStream` method can initiate a goroutine that sends data at regular intervals or in response to events. +- a `response` with `result: null` when the method succeeds; or +- an `error` containing the mapped JSON-RPC error when the method returns an + error. -* **Client Streaming**: To receive a stream of data from a client, the `HandleStream` method can repeatedly call `stream.Recv()` and accumulate the results. +JSON-RPC rejects a method that defines different `Result` and +`StreamingResult` types because its client stream has no separate operation +that could return the final `Result`. Use one method for the stream and another +method for the final resource. gRPC has the same restriction. Ordinary HTTP +keeps mixed-result support. -* **Bidirectional Streaming**: For interactive communication, `HandleStream` can combine both server and client streaming patterns, for example by launching a goroutine to handle outgoing messages while the main loop processes incoming messages. +A request without an `id` receives streamed notifications but no terminal +response. Request IDs and JSON-RPC messages remain transport details and never +appear in the generated service stream. -#### Advanced Patterns +The generated client returns each notification value from `Recv`. A successful +terminal response makes the next `Recv` return `io.EOF`. A terminal JSON-RPC +error is returned as an error. -The `HandleStream` method can also be used to implement more advanced patterns, such as: +## Result conversion and views -* **Mixed Request-Response and Streaming**: A service can handle both traditional request-response interactions and asynchronous, server-initiated notifications within the same WebSocket connection. -* **Conditional Streaming**: The streaming strategy can be determined dynamically based on the properties of the connection or the initial messages exchanged. +All JSON names, required fields, transport pointers, result conversions, and +view branches are decided while generating code. A variable-view result uses +this JSON-RPC value: -#### Method Dispatch and Results - -When `stream.Recv()` is called, it automatically handles the parsing of the incoming JSON-RPC request, validation, routing to the appropriate service method, and marshalling of the response. Service methods can also be invoked manually from within `HandleStream` for server-initiated communication. - -#### Error Handling - -The architecture provides mechanisms for handling various types of errors: - -* **Connection Errors**: Errors at the WebSocket connection level will cause `HandleStream` to terminate. -* **JSON-RPC Protocol Errors**: Invalid requests will result in the automatic sending of a JSON-RPC error response. -* **Streaming Errors**: Errors that occur while sending or receiving data can be handled within the `HandleStream` implementation. - -#### Testing Strategies - -The separation of concerns in the architecture simplifies testing: - -* **Integration Tests**: The `HandleStream` implementation can be overridden in tests to simulate specific streaming behaviors. -* **Unit Tests**: Service methods can be tested independently as standard Go functions. - -## Maintenance Guidelines - -To maintain the clean separation of concerns and the long-term health of the codebase, it is important to adhere to the following guidelines: +```json +{"view":"summary","body":{"value":"ready"}} +``` -### DO: +The same representation is used for unary results and streamed notification +parameters. A fixed-view or non-viewed result uses only its designed body. -* ✅ Modify JSON-RPC templates for JSON-RPC specific behavior. -* ✅ Use `codegen.File` section manipulation for signature changes. -* ✅ Add JSON-RPC specific sections for specialized functionality. -* ✅ Compose on top of HTTP-generated code. +## Errors -### DON'T: +Protocol parsing, request validation, method dispatch, designed service errors, +and unexpected service errors are mapped by the JSON-RPC transport. Service +implementations return ordinary errors; they do not write protocol error +messages themselves. -* ❌ Modify HTTP templates with JSON-RPC specific logic. -* ❌ Add WebSocket conditionals to shared HTTP templates. -* ❌ Break the transport independence of the HTTP layer. -* ❌ Couple the HTTP codegen to JSON-RPC requirements. +If the request has an `id`, an SSE decode or service error becomes one terminal +JSON-RPC error event. If the request has no `id`, the server writes no response +and passes the error to the configured server error handler. diff --git a/jsonrpc/README.md b/jsonrpc/README.md index 9489912e6b..e86ae5d127 100644 --- a/jsonrpc/README.md +++ b/jsonrpc/README.md @@ -1,1006 +1,216 @@ # JSON-RPC 2.0 in Goa -Goa provides first-class, type-safe support for JSON-RPC 2.0, enabling you to build robust RPC services with the same powerful DSL used for REST and gRPC. This implementation handles all protocol complexities while preserving Goa's design-first philosophy. - -## Table of Contents - -- [Quick Start](#quick-start) -- [Core Concepts](#core-concepts) - - [Protocol Fundamentals](#protocol-fundamentals) - - [Single Endpoint Architecture](#single-endpoint-architecture) - - [Request vs Notification](#request-vs-notification) -- [Defining Services](#defining-services) - - [Service Configuration](#service-configuration) - - [Method Configuration](#method-configuration) - - [ID Field Mapping](#id-field-mapping) -- [Transport Options](#transport-options) - - [HTTP: Request-Response](#http-request-response) - - [Server-Sent Events: Server Streaming](#server-sent-events-server-streaming) - - [WebSocket: Bidirectional Streaming](#websocket-bidirectional-streaming) - - [Mixed Transports: Content Negotiation](#mixed-transports-content-negotiation) -- [Advanced Features](#advanced-features) - - [Batch Processing](#batch-processing) - - [Error Handling](#error-handling) - - [Streaming Patterns](#streaming-patterns) - - [Mixed Results](#mixed-results) -- [Best Practices](#best-practices) - -## Quick Start - -Define a simple JSON-RPC calculator service: +Goa generates typed JSON-RPC 2.0 clients and servers from the same service +designs used for HTTP and gRPC. Generated code owns request decoding, +validation, method dispatch, response encoding, errors, notifications, batch +requests, and server-sent-event streams. -```go -// design/design.go -package design - -import . "goa.design/goa/v3/dsl" - -var _ = API("calculator", func() { - Title("Calculator Service") - Description("A simple calculator exposed via JSON-RPC") -}) - -var _ = Service("calc", func() { - Description("The calc service performs basic arithmetic") - - // Enable JSON-RPC for this service at /rpc endpoint - JSONRPC(func() { - POST("/rpc") - }) - - // Define an add method - Method("add", func() { - Description("Add two numbers") - Payload(func() { - Attribute("a", Float64, "First operand") - Attribute("b", Float64, "Second operand") - Required("a", "b") - }) - Result(Float64) - - // Expose this method via JSON-RPC - JSONRPC(func() {}) - }) - - // Define a divide method with error handling - Method("divide", func() { - Description("Divide two numbers") - Payload(func() { - Field(1, "dividend", Float64, "The dividend") - Field(2, "divisor", Float64, "The divisor") - Required("dividend", "divisor") - }) - Result(Float64) - Error("division_by_zero") - - JSONRPC(func() { - Response("division_by_zero", func() { - Code(-32001) // Custom error code - }) - }) - }) -}) -``` +## Unary methods -Generate the code: - -```bash -goa gen calculator/design -``` - -Implement the service: +Enable JSON-RPC on a service and expose each method that should be callable: ```go -// calc.go -package calcapi - -import ( - "context" - calc "calculator/gen/calc" -) - -type calcService struct{} - -func NewCalc() calc.Service { - return &calcService{} -} - -func (s *calcService) Add(ctx context.Context, p *calc.AddPayload) (float64, error) { - return p.A + p.B, nil -} - -func (s *calcService) Divide(ctx context.Context, p *calc.DividePayload) (float64, error) { - if p.Divisor == 0 { - return 0, calc.MakeDivisionByZero("cannot divide by zero") - } - return p.Dividend / p.Divisor, nil -} -``` - -## Core Concepts - -### Protocol Fundamentals - -JSON-RPC 2.0 is a stateless, lightweight remote procedure call protocol that -uses JSON for encoding. Key characteristics: - -1. **Transport Agnostic**: While commonly used over HTTP, the protocol itself doesn't specify transport -2. **Simple Message Format**: All communication uses a consistent JSON structure -3. **Bidirectional**: Supports both client-to-server and server-to-client communication -4. **Batch Support**: Multiple calls can be sent in a single request - -Message structure: -```json -// Request -{ - "jsonrpc": "2.0", - "method": "add", - "params": {"a": 5, "b": 3}, - "id": 1 -} +var _ = Service("calc", func() { + JSONRPC(func() { + POST("/rpc") + }) -// Response -{ - "jsonrpc": "2.0", - "result": 8, - "id": 1 -} + Method("add", func() { + Payload(func() { + Attribute("a", Int) + Attribute("b", Int) + Required("a", "b") + }) + Result(Int) + JSONRPC(func() {}) + }) +}) ``` -### Single Endpoint Architecture - -Unlike REST where each resource has its own URL, JSON-RPC services multiplex all -methods through a single endpoint: - -- **REST**: `/users` (GET), `/users/{id}` (GET/PUT/DELETE), `/products` (GET/POST) -- **JSON-RPC**: `/rpc` (all methods) - -This design provides several benefits: - -1. **Simplified Routing**: No complex URL patterns to manage -2. **Protocol Consistency**: All methods follow the same calling convention -3. **Connection Efficiency**: WebSocket/SSE connections can handle multiple methods -4. **Easy Versioning**: Version the entire API at once - -The `method` field in the JSON-RPC payload determines which service method to invoke: +Every JSON-RPC method in the service shares the service route. The `method` +property inside the JSON-RPC request selects the Goa method: ```json -{"jsonrpc": "2.0", "method": "add", "params": {"a": 5, "b": 3}, "id": 1} -{"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 2} +{"jsonrpc":"2.0","id":"sum-1","method":"add","params":{"a":2,"b":3}} ``` -### Request vs Notification - -JSON-RPC distinguishes between two types of messages based on the presence of an ID: - -**Requests** (with ID) expect a response: -```json -{"jsonrpc": "2.0", "method": "process", "params": {"data": "hello"}, "id": "req-123"} -// Server MUST send a response with matching ID -``` +The generated server validates `params`, calls the service method once, and +returns the designed result: -**Notifications** (without ID) are fire-and-forget: ```json -{"jsonrpc": "2.0", "method": "log", "params": {"message": "user logged in"}} -// Server MUST NOT send a response +{"jsonrpc":"2.0","id":"sum-1","result":5} ``` -This behavior is determined at **runtime** by the client, not design time. The -same method can be called as either a request or notification. +Calling `JSONRPC` inside a method automatically enables JSON-RPC for its +service. A service-level `JSONRPC` block is still useful for declaring the +shared route and defaults. -## Defining Services +## Requests, notifications, and IDs -### Service Configuration +A JSON-RPC request contains an `id` and receives one response. A notification +omits `id` and receives no response, including when decoding or service work +fails. -Enable JSON-RPC at the service level to define the shared endpoint: +Goa can map the protocol ID to a designed payload field: ```go -Service("myservice", func() { - Description("A service exposed via JSON-RPC") - - // Define the JSON-RPC endpoint - JSONRPC(func() { - POST("/jsonrpc") // For HTTP and SSE - // OR - GET("/ws") // For WebSocket - }) - - // Define error mappings for all methods - Error("unauthorized", func() { - Description("Unauthorized access") - }) - - JSONRPC(func() { - Response("unauthorized", func() { - Code(-32000) // Map to JSON-RPC error code - }) - }) +Payload(func() { + ID("request_id", String) + Attribute("value", String) + Required("value") }) ``` -### Method Configuration - -Each method needs its own `JSONRPC()` block to be exposed: +If `request_id` is required, callers must send a request ID. If it is optional, +the generated client omits the JSON-RPC `id` when the field is empty and sends +a notification. The generated server sets the field from the incoming ID +before calling the service. -```go -Method("process", func() { - Description("Process data") - - Payload(func() { - Attribute("data", String, "Data to process") - Attribute("priority", Int, "Processing priority") - Required("data") - }) - - Result(func() { - Attribute("output", String, "Processed output") - Attribute("duration", Int, "Processing time in ms") - Required("output", "duration") - }) - - // Enable JSON-RPC for this method - JSONRPC(func() { - // Method-specific error mappings (optional) - Response("invalid_data", func() { - Code(-32002) - }) - }) -}) -``` +Unary result types may also declare an `ID` field. When the service returns a +non-empty result ID, the generated server uses it as the response ID. Otherwise +it uses the request ID. -### ID Field Mapping +## Server streaming with SSE -Control how JSON-RPC message IDs map to your payload and result types: +JSON-RPC supports server-to-client streams through Server-Sent Events (SSE). +Define a `StreamingResult` and select `ServerSentEvents` in the method-level +JSON-RPC block: ```go -Method("track", func() { - Payload(func() { - ID("request_id", String, "Tracking ID") // Maps to JSON-RPC request ID - Attribute("action", String) - Required("request_id", "action") - }) - - Result(func() { - ID("request_id", String, "Tracking ID") // Optional; if empty the - // response uses the request id - Attribute("status", String) - Required("request_id", "status") - }) - - JSONRPC(func() {}) +var Event = Type("Event", func() { + Attribute("message", String) + Required("message") }) -``` +var _ = Service("updates", func() { + JSONRPC(func() { + POST("/rpc") + }) -The `ID()` function marks which field receives the JSON-RPC message ID. Rules: - -1. ID fields must be String type -2. Result can only have an ID if Payload has one -3. For non-streaming methods, the response `id` defaults to the request `id`. - If the result ID is set, that value is used instead. -4. Missing ID at runtime means the message is a notification - -### ID Semantics - -How IDs behave across transports and shapes: - -- Design-time type - - `ID()` marks the field that carries the JSON-RPC ID; it must be `String` in - the design. - -- Runtime type - - JSON-RPC allows string or number IDs. Goa accepts either on the wire and - normalizes to string when assigning to your `ID()` fields. - -- HTTP (request/response) - - Client - - If the payload has an ID field and it is non-empty, the client sends it - as `id` (request). If empty (or nil pointer), the client omits `id` - (notification). - - If the payload has no ID field, the client generates a string `id` and - sends a request (never a notification). - - Server - - The response envelope `id` equals the result ID if set; otherwise it - equals the request `id`. The server does not inject the request `id` into - your result struct. - -- SSE (server streaming) - - `Send(ctx, event)`: emits a JSON-RPC notification (no `id`). - - `SendAndClose(ctx, result)`: sends a JSON-RPC response. The `id` equals the - result ID if set; otherwise the original request `id`. To avoid duplicate - fields, the framework clears the result ID field when it is used for the - envelope. - -- WebSocket (streaming) - - Server replies use the original request `id` automatically. Use - `SendNotification` for server-initiated messages (no `id`). - - Client generates a string `id` per request in bidirectional or recv-only - patterns. When receiving, if your result has an ID field and it is empty, - the client populates it from the envelope `id` for convenience. - -- When to use `ID()` in the DSL - - Non-streaming: put `ID()` in the payload to receive request IDs in your - handler; add it to the result only if you need to surface the ID in your - result type. - - Streaming (WebSocket bidirectional): include `ID()` in both streaming - payload and result to correlate messages at the type level. - - Notifications: omit `ID()` (no `id` is sent or expected). - -## Transport Options - -### HTTP: Request-Response - -Standard synchronous RPC over HTTP. Best for: -- Simple request-response patterns -- Stateless operations -- RESTful service migration - -```go -Service("api", func() { - JSONRPC(func() { - POST("/rpc") - }) - - Method("query", func() { - Payload(func() { - Attribute("sql", String) - Required("sql") - }) - Result(ArrayOf(map[string]any)) - JSONRPC(func() {}) - }) + Method("watch", func() { + Payload(func() { + Attribute("topic", String) + Required("topic") + }) + StreamingResult(Event) + JSONRPC(func() { + ServerSentEvents() + }) + }) }) ``` -**Client usage:** -```go -client := api.NewClient("http", "localhost:8080", http.DefaultClient, - goahttp.RequestEncoder, goahttp.ResponseDecoder, false) - -result, err := client.Query(ctx, &api.QueryPayload{SQL: "SELECT * FROM users"}) -``` - -**Wire format:** -```http -POST /rpc HTTP/1.1 -Content-Type: application/json - -{"jsonrpc":"2.0","method":"query","params":{"sql":"SELECT * FROM users"},"id":1} -``` - -**How it works internally:** - -- The generated server inspects the first byte of the body to route batch - (`[` starts a JSON array) vs single requests, then decodes a - `jsonrpc.RawRequest` and validates `jsonrpc:"2.0"`, `method`, and - `params`. -- Dispatch is by the `method` field to the corresponding generated handler - for your service method. The handler decodes the typed payload, invokes - your implementation, and encodes a typed JSON-RPC response via - `MakeSuccessResponse(id, result)`. -- If the incoming message has no `id` (a notification), the server does not - send a response, per the spec. -- Batch requests are decoded to `[]jsonrpc.RawRequest` and each entry is - processed independently; responses are streamed into a JSON array. - -### Server-Sent Events: Server Streaming - -Unidirectional streaming from server to client. Perfect for: -- Progress updates -- Live notifications -- Real-time feeds -- Long-running operations +The generated service method receives the same exact typed stream interface as +other Goa transports: ```go -Service("monitor", func() { - JSONRPC(func() { - POST("/events") // SSE uses POST for initial payload - }) - - Method("watch", func() { - Description("Watch system metrics") - - Payload(func() { - Attribute("metrics", ArrayOf(String), "Metrics to watch") - Required("metrics") - }) - - StreamingResult(func() { - Attribute("metric", String) - Attribute("value", Float64) - Attribute("timestamp", String, func() { - Format(FormatDateTime) - }) - Required("metric", "value", "timestamp") - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("metric") // SSE event type field - }) - }) - }) -}) -``` - -**Server implementation:** - -```go -func (s *monitorSvc) Watch(ctx context.Context, p *monitor.WatchPayload, - stream monitor.WatchServerStream) error { - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - for _, metric := range p.Metrics { - err := stream.Send(ctx, &monitor.WatchResult{ - Metric: metric, - Value: getMetricValue(metric), - Timestamp: time.Now().Format(time.RFC3339), - }) - if err != nil { - return err - } - } - } - } +func (s *updatesService) Watch(ctx context.Context, p *updates.WatchPayload, stream updates.WatchServerStream) error { + if err := stream.Send(&updates.Event{Message: "ready"}); err != nil { + return err + } + return nil } ``` -**Client usage:** +The stream provides: -```go -httpClient := monitorjsonrpc.NewClient(/* ... */) -stream, err := httpClient.Watch(ctx, &monitor.WatchPayload{ - Metrics: []string{"cpu", "memory"}, -}) +- `Send(T) error` +- `SendWithContext(context.Context, T) error` +- `Close() error` +- `SetView(string)` when the streaming result has selectable views -for { - result, err := stream.Recv() - if err == io.EOF { - break - } - log.Printf("%s: %f", result.Metric, result.Value) -} -``` - -**How it works internally:** - -- SSE uses a regular HTTP POST to deliver the initial JSON-RPC request. The - generated handler decodes a `jsonrpc.RawRequest`, validates it, and - dispatches to the method-specific SSE handler. -- The SSE response is a long-lived HTTP response with - `Content-Type: text/event-stream`. The generated stream type writes events - using standard SSE framing (`id:`, `event:`, `data:`, blank line). -- The server stream interface exposes: - - `Send(ctx, event)`: writes a JSON-RPC notification as an SSE event - (no response expected). Use this for progress or updates. - - `SendAndClose(ctx, result)`: sends the final JSON-RPC response (with `id`) - and closes the stream. The response `id` is taken from the original - request `id`, or from a result `ID()` field if defined in the design. - - `SendError(ctx, id, err)`: writes a JSON-RPC error response. -- Notifications vs responses: - - Notifications omit `id` per JSON-RPC and are represented as SSE events - with the `data:` being the result body. - - Final responses include a JSON-RPC envelope; the SSE `id:` field mirrors - the JSON-RPC response `id` when an ID is present. -- Example on-the-wire SSE frame (simplified): - - ```text - event: metric - id: 7 - data: {"jsonrpc":"2.0","result":{"metric":"cpu","value":0.9},"id":"7"} - - ``` - -### WebSocket: Bidirectional Streaming - -Full-duplex, persistent connections for real-time communication. Ideal for: -- Chat applications -- Collaborative editing -- Gaming -- Live bidirectional data exchange - -```go -Service("chat", func() { - JSONRPC(func() { - GET("/ws") // WebSocket upgrade - }) - - // Client-to-server notifications - Method("send", func() { - StreamingPayload(func() { - Attribute("message", String) - Required("message") - }) - JSONRPC(func() {}) - }) - - // Server-to-client notifications - Method("broadcast", func() { - StreamingResult(func() { - Attribute("from", String) - Attribute("message", String) - Required("from", "message") - }) - JSONRPC(func() {}) - }) - - // Bidirectional request-response - Method("echo", func() { - StreamingPayload(func() { - ID("msg_id", String) - Attribute("text", String) - Required("msg_id", "text") - }) - StreamingResult(func() { - ID("msg_id", String) - Attribute("echo", String) - Required("msg_id", "echo") - }) - JSONRPC(func() {}) - }) -}) -``` +Each `Send` writes one SSE `notification` event containing a complete JSON-RPC +notification. When the method returns, the transport writes one terminal event +for a request with an ID: -**Server implementation:** - -```go -type chatSvc struct { - connections map[string]chat.BroadcastServerStream - mu sync.RWMutex -} +- success writes `result: null`; +- a returned error writes a JSON-RPC error. -func (s *chatSvc) HandleStream(ctx context.Context, stream chat.Stream) error { - // Register connection - connID := generateConnID() - s.mu.Lock() - s.connections[connID] = stream.(chat.BroadcastServerStream) - s.mu.Unlock() - - defer func() { - s.mu.Lock() - delete(s.connections, connID) - s.mu.Unlock() - stream.Close() - }() - - // Handle incoming messages - for { - _, err := stream.Recv(ctx) - if err != nil { - return err - } - // Messages are automatically dispatched to method handlers - } -} +A request without an ID receives streamed notifications but no terminal +response. The generated client returns notification values from `Recv`; after +a successful terminal response it returns `io.EOF`, and after an error response +it returns that error. -func (s *chatSvc) Send(ctx context.Context, p *chat.SendPayload) error { - // Broadcast to all connections - s.mu.RLock() - defer s.mu.RUnlock() - - for _, conn := range s.connections { - conn.SendNotification(ctx, &chat.BroadcastResult{ - From: "user", - Message: p.Message, - }) - } - return nil -} +JSON-RPC does not accept `StreamingPayload` or bidirectional streaming. Use +gRPC or an ordinary HTTP WebSocket method when the client must send a stream of +values. -func (s *chatSvc) Echo(ctx context.Context, p *chat.EchoPayload, - stream chat.EchoServerStream) error { - - return stream.SendResponse(ctx, &chat.EchoResult{ - MsgID: p.MsgID, - Echo: "Echo: " + p.Text, - }) -} -``` +### Last-Event-ID -**How it works internally:** - -- Connection lifecycle: - - The generated server upgrades the HTTP request to a WebSocket and - constructs a `Stream` implementation, then calls your - `HandleStream(ctx, stream)`. - - Your `HandleStream` should defer `stream.Close()` and typically loop on - `stream.Recv(ctx)`, which reads a JSON-RPC message and dispatches it to - the appropriate generated handler based on its `method`. -- Dispatch and method invocation: - - For non-streaming methods, `Recv` decodes the payload, invokes your - method, and sends the typed JSON-RPC success response via the stream. - - For streaming methods, `Recv` creates a method-specific stream wrapper - that implements your generated `XServerStream` interface and calls your - method implementation with it. -- Sending from your methods: - - In server or bidirectional streaming, your method receives a stream - wrapper providing: - - `SendNotification(ctx, result)`: sends a JSON-RPC notification (no id). - - `SendResponse(ctx, result)`: sends a JSON-RPC success response using the - original request `id`. You do not need to pass the id; the wrapper holds - it for you. - - `SendError(ctx, err)`: sends a JSON-RPC error response correlated to the - original request `id` when present. -- Notifications and responses: - - Messages without `id` are notifications. Use `SendNotification` for - server-initiated messages that should not expect a response. - - When replying to a client request that had an `id`, use `SendResponse` to - correlate via that `id` automatically. -- Error handling: - - Invalid messages (parse errors, missing method) trigger JSON-RPC error - responses when an `id` is present; otherwise they are ignored to keep the - connection alive. - - Unexpected WebSocket close codes abort the loop and close the connection. - -### Mixed Transports: Content Negotiation - -Combine HTTP and SSE in a single service using automatic content negotiation: +`SSERequestID` maps the incoming HTTP `Last-Event-ID` header to a string field +in the initial method payload: ```go -Service("hybrid", func() { - JSONRPC(func() { - POST("/api") - }) - - // Standard HTTP method - Method("status", func() { - Result(func() { - Attribute("healthy", Boolean) - Required("healthy") - }) - JSONRPC(func() {}) - }) - - // SSE streaming method - Method("monitor", func() { - StreamingResult(func() { - Attribute("event", String) - Attribute("data", Any) - }) - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("update") - }) - }) - }) - - // Mixed results with content negotiation - Method("flexible", func() { - Payload(func() { - Attribute("resource", String) - Required("resource") - }) - - // Return simple result for HTTP - Result(func() { - Attribute("data", String) - Required("data") - }) - - // Return stream for SSE - StreamingResult(func() { - Attribute("chunk", String) - Attribute("progress", Int) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("progress") - }) - }) - }) +Payload(func() { + Attribute("last_event_id", String) }) -``` - -The server automatically routes based on the `Accept` header: -- `Accept: application/json` → HTTP handler → `Result` -- `Accept: text/event-stream` → SSE handler → `StreamingResult` - -Under the hood, the generated handler checks `Accept` at runtime and invokes -the SSE stream only when `text/event-stream` is requested and the method has -`StreamingResult` (including mixed-result shapes). Otherwise, the standard -HTTP request-response path is used. -## Advanced Features - -### Batch Processing - -JSON-RPC supports sending multiple requests in a single HTTP call: - -```json -[ - {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}, - {"jsonrpc": "2.0", "method": "multiply", "params": {"a": 3, "b": 4}, "id": 2}, - {"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 3} -] -``` - -The server processes each request independently and returns an array of responses: - -```json -[ - {"jsonrpc": "2.0", "result": 3, "id": 1}, - {"jsonrpc": "2.0", "result": 12, "id": 2}, - {"jsonrpc": "2.0", "result": 5, "id": 3} -] -``` - -Batch processing is automatic - no special configuration needed. - -### Error Handling - -Goa provides comprehensive error handling with standard JSON-RPC error codes: - -```go -Service("api", func() { - // Define service-level errors - Error("unauthorized", func() { - Description("User is not authorized") - }) - Error("rate_limited", func() { - Description("Too many requests") - }) - - JSONRPC(func() { - // Map errors to JSON-RPC codes - Response("unauthorized", func() { - Code(-32001) // Custom application code - }) - Response("rate_limited", func() { - Code(-32002) - }) - }) - - Method("secure", func() { - // ... method definition ... - Error("unauthorized") // Method can return this error - Error("invalid_token") // Method-specific error - - JSONRPC(func() { - Response("invalid_token", func() { - Code(-32003) - }) - }) - }) +JSONRPC(func() { + ServerSentEvents(func() { + SSERequestID("last_event_id") + }) }) ``` -Standard error codes: -- `-32700`: Parse error -- `-32600`: Invalid request -- `-32601`: Method not found -- `-32602`: Invalid params -- `-32603`: Internal error -- `-32000` to `-32099`: Reserved for implementation +The payload field stays optional unless the design marks it required. -### Streaming Patterns +JSON-RPC rejects a method that defines different `Result` and +`StreamingResult` types because the generated client cannot receive both from +one call. Define one method for the stream and another method for the final +resource. The two methods may share the same service and JSON-RPC path. -#### Client Streaming (WebSocket only) -```go -Method("upload", func() { - StreamingPayload(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - Result(func() { - Attribute("size", Int64) - Attribute("checksum", String) - }) - JSONRPC(func() {}) -}) -``` +## Errors -#### Server Streaming (SSE or WebSocket) -```go -Method("download", func() { - Payload(func() { - Attribute("file", String) - Required("file") - }) - StreamingResult(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - JSONRPC(func() { - ServerSentEvents(func() {}) // Or use WebSocket - }) -}) -``` +Map designed errors to JSON-RPC codes in the method design: -#### Bidirectional Streaming (WebSocket only) ```go -Method("transform", func() { - StreamingPayload(func() { - ID("seq", String) - Attribute("input", String) - Required("seq", "input") - }) - StreamingResult(func() { - ID("seq", String) - Attribute("output", String) - Required("seq", "output") - }) - JSONRPC(func() {}) +Method("divide", func() { + Error("division_by_zero") + JSONRPC(func() { + Response("division_by_zero", func() { + Code(-32001) + }) + }) }) ``` -### Mixed Results - -Support different response types based on content negotiation: - -```go -Method("report", func() { - Payload(func() { - Attribute("query", String) - Required("query") - }) - - // Simple result for synchronous HTTP - Result(func() { - Attribute("summary", String) - Attribute("count", Int) - Required("summary", "count") - }) - - // Streaming result for SSE - StreamingResult(func() { - Attribute("row", Map(String, Any)) - Attribute("progress", Float64) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("row") - }) - }) -}) -``` - -Implementation: - -```go -// Called for Accept: application/json -func (s *svc) Report(ctx context.Context, p *ReportPayload) (*ReportResult, error) { - summary, count := generateReport(p.Query) - return &ReportResult{Summary: summary, Count: count}, nil -} - -// Called for Accept: text/event-stream -func (s *svc) ReportStream(ctx context.Context, p *ReportPayload, - stream ReportServerStream) error { - - rows := queryRows(p.Query) - for i, row := range rows { - err := stream.Send(ctx, &ReportStreamingResult{ - Row: row, - Progress: float64(i) / float64(len(rows)), - }) - if err != nil { - return err - } - } - return nil -} -``` - -## Best Practices - -### 1. Service Design - -**DO:** -- Group related methods in the same service -- Use consistent naming conventions -- Define clear error codes and messages -- Document expected behavior - -**DON'T:** -- Mix WebSocket with HTTP endpoints in the same service -- Use deeply nested payload structures -- Rely on transport-specific features - -### 2. Error Handling - -**DO:** -- Map application errors to appropriate JSON-RPC codes -- Provide meaningful error messages -- Use standard codes when applicable -- Include error data when helpful +Goa also uses the standard JSON-RPC codes: -**DON'T:** -- Use reserved error code ranges -- Return stack traces in production -- Ignore validation errors +- `-32700` for malformed JSON; +- `-32600` for an invalid request; +- `-32601` for an unknown method; +- `-32602` for invalid parameters; and +- `-32603` for an unexpected service error. -### 3. Streaming +Service implementations return ordinary designed or unexpected errors. The +generated transport writes the JSON-RPC error and preserves the request ID. -**DO:** -- Use SSE for server-push scenarios -- Use WebSocket for bidirectional needs -- Implement proper cleanup in stream handlers -- Handle connection failures gracefully +## Batch requests -**DON'T:** -- Keep streams open indefinitely -- Send large payloads in single messages -- Ignore backpressure +Unary JSON-RPC methods accept a JSON array of requests and notifications. The +generated server dispatches each item and returns an array containing responses +only for items with an ID. An all-notification batch receives no JSON-RPC +response body. -### 4. Performance +SSE streams are opened by one request and are not batch operations. -**DO:** -- Use batch requests for multiple operations -- Implement connection pooling for clients -- Cache frequently accessed data -- Monitor message sizes +## Using JSON-RPC with other transports -**DON'T:** -- Create new connections per request -- Send unnecessary notifications -- Block stream handlers +A Goa method may also have ordinary `HTTP` or `GRPC` transport mappings. Each +generated transport implements the same service method contract. JSON-RPC +methods share their JSON-RPC route; ordinary HTTP routes and gRPC procedures +remain independent. -### Supporting Multiple Transports +## Generation -Expose the same service over multiple protocols: +Run Goa against the design package import path: -```go -Service("universal", func() { - // JSON-RPC configuration - JSONRPC(func() { - POST("/rpc") - }) - - Method("process", func() { - Payload(func() { - Attribute("data", String) - Required("data") - }) - Result(func() { - Attribute("output", String) - Required("output") - }) - - // Available via JSON-RPC - JSONRPC(func() {}) - - // Also available via HTTP REST - HTTP(func() { - POST("/process") - }) - - // And via gRPC - GRPC(func() {}) - }) -}) +```bash +goa gen example.com/project/design ``` -## Additional Resources - -- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) -- [Goa Documentation](https://goa.design) -- [Example Services](https://github.com/goadesign/examples) -- [Integration Tests](../jsonrpc/integration_tests) - -## Summary - -Goa's JSON-RPC implementation provides: - -- **Type Safety**: Full compile-time type checking -- **Code Generation**: Automatic client/server code from DSL -- **Protocol Compliance**: Complete JSON-RPC 2.0 support -- **Transport Flexibility**: HTTP, SSE, and WebSocket options -- **Streaming Support**: Unidirectional and bidirectional patterns -- **Error Handling**: Comprehensive error mapping and codes -- **Content Negotiation**: Mixed results based on Accept headers -- **Batch Processing**: Automatic batch request handling +Do not edit generated files. Change the design or the owning generator and run +generation again. -The implementation seamlessly integrates with Goa's existing features while -maintaining clean separation of concerns and enabling powerful real-time -communication patterns. \ No newline at end of file +See [ARCHITECTURE.md](ARCHITECTURE.md) for generator ownership and the exact SSE +message lifecycle. diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index d47948cffa..7dac6a7883 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -15,20 +15,8 @@ type ( // one client package. clientTemplateData struct { httpcodegen.JSONRPCServiceSnapshot - // BufferPool is the byte buffer variable used by clients without WebSockets. + // BufferPool is the byte buffer variable used while encoding requests. BufferPool *codegen.NameDeclaration - // WebSocketConnection is the shared WebSocket connection type. - WebSocketConnection *codegen.NameDeclaration - // WebSocketRequestOwner is the type that marks one method stream closed. - WebSocketRequestOwner *codegen.NameDeclaration - // WebSocketPendingRequest is the type that stores one waiting request. - WebSocketPendingRequest *codegen.NameDeclaration - // WebSocketMessage is the type that reads one incoming WebSocket message. - WebSocketMessage *codegen.NameDeclaration - // WebSocketClosedError is the error returned after a method stream closes. - WebSocketClosedError *codegen.NameDeclaration - // NewWebSocketConnection is the shared WebSocket connection constructor. - NewWebSocketConnection *codegen.NameDeclaration } ) @@ -37,11 +25,9 @@ type ( func clientFiles(services []*servicePlan) []*codegen.File { files := make([]*codegen.File, 0, len(services)*3) for _, planned := range services { - files = append(files, addFileImports(clientFile(planned), planned.data)) - if f := websocketClientFile(planned); f != nil { - files = append(files, addFileImports(f, planned.data)) - } - if f := sseClientFile(planned); f != nil { + renderPlan := servicePlanForOutput(planned, true) + files = append(files, addFileImports(clientFile(renderPlan), planned.data)) + if f := sseClientFile(renderPlan); f != nil { files = append(files, addFileImports(f, planned.data)) } } @@ -50,31 +36,38 @@ func clientFiles(services []*servicePlan) []*codegen.File { if f == nil { continue } - var swapped int + sections := make([]*codegen.SectionTemplate, 0, len(f.SectionTemplates)) + var decoders int for _, s := range f.SectionTemplates { switch s.Name { case "source-header": codegen.AddImport(s, &codegen.ImportSpec{Path: "bufio"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) + codegen.AddImport(s, &codegen.ImportSpec{Path: "errors"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "sync"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "sync/atomic"}) codegen.AddImport(s, codegen.GoaImport("jsonrpc")) case "response-decoder": + endpoint := s.Data.(*httpcodegen.JSONRPCEndpointSnapshot) + if endpoint.SSE != nil { + continue + } s.Source = jsonrpcTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP) s.FuncMap["buildResponseData"] = buildJSONRPCResponseData for name, function := range viewedResultFuncs(planned) { s.FuncMap[name] = function } - swapped++ + decoders++ } s.Name = "jsonrpc-" + s.Name + sections = append(sections, s) } + f.SectionTemplates = sections viewed := clientViewedResultSections(planned) if len(viewed) > 0 { header := f.SectionTemplates[0] codegen.AddImport(header, &codegen.ImportSpec{Path: "encoding/json"}) codegen.AddImport(header, codegen.GoaImport("")) - codegen.AddImport(header, planned.data.ViewImport()) + codegen.AddImport(header, planned.data.ClientViewImport()) f.SectionTemplates = append(f.SectionTemplates, &codegen.SectionTemplate{ Name: "jsonrpc-viewed-result-body-decoder", Source: jsonrpcTemplates.Read(viewedResultBodyDecodeT), @@ -82,10 +75,15 @@ func clientFiles(services []*servicePlan) []*codegen.File { }) f.SectionTemplates = append(f.SectionTemplates, viewed...) } - // The HTTP client file emits exactly one response decoder per - // endpoint. Guard against the two generators drifting apart. - if n := len(planned.data.Endpoints); swapped != n { - panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, planned.name, n)) + // Each method that returns one response needs exactly one decoder. + var expected int + for _, endpoint := range planned.data.Endpoints { + if endpoint.SSE == nil { + expected++ + } + } + if decoders != expected { + panic(fmt.Sprintf("jsonrpc: wrote %d response decoders for service %q, expected %d", decoders, planned.name, expected)) } files = append(files, addFileImports(f, planned.data)) } @@ -106,14 +104,8 @@ func buildJSONRPCResponseData(data httpcodegen.JSONRPCResponseData, serviceName func clientFile(planned *servicePlan) *codegen.File { data := planned.data renderData := &clientTemplateData{ - JSONRPCServiceSnapshot: data, - BufferPool: planned.clientNames.bufferPool, - WebSocketConnection: planned.clientNames.websocketConnection, - WebSocketRequestOwner: planned.clientNames.websocketRequestOwner, - WebSocketPendingRequest: planned.clientNames.websocketPendingRequest, - WebSocketMessage: planned.clientNames.websocketMessage, - WebSocketClosedError: planned.clientNames.websocketClosedError, - NewWebSocketConnection: planned.clientNames.newWebsocketConnection, + JSONRPCServiceSnapshot: data, + BufferPool: planned.clientNames.bufferPool, } svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") @@ -130,13 +122,10 @@ func clientFile(planned *servicePlan) *codegen.File { {Path: "strconv"}, {Path: "strings"}, {Path: "sync"}, - {Path: "sync/atomic"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), + data.ClientServiceImport(), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "client", imports), @@ -146,7 +135,6 @@ func clientFile(planned *servicePlan) *codegen.File { Source: jsonrpcTemplates.Read(clientStructT), Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": hasJSONRPCWebSocket, "hasSSE": hasJSONRPCSSE, "isSSEEndpoint": isJSONRPCSSEEndpoint, }, @@ -157,7 +145,6 @@ func clientFile(planned *servicePlan) *codegen.File { Source: jsonrpcTemplates.Read(clientInitT), Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": hasJSONRPCWebSocket, "hasSSE": hasJSONRPCSSE, "isSSEEndpoint": isJSONRPCSSEEndpoint, }, @@ -170,46 +157,17 @@ func clientFile(planned *servicePlan) *codegen.File { Source: jsonrpcTemplates.Read(clientEndpointInitT), Data: &e.JSONRPCEndpointSnapshot, FuncMap: map[string]any{ - "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, - "isSSEEndpoint": isJSONRPCSSEEndpoint, - "viewedDecodeName": funcs["viewedDecodeName"], - "websocketRequestOwnerName": planned.websocketRequestOwnerName, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "viewedDecodeName": funcs["viewedDecodeName"], }, }) } - if hasJSONRPCWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-client-websocket-conn", - Source: jsonrpcTemplates.Read(websocketClientConnT), - Data: renderData, - }) - } - return &codegen.File{Path: path, SectionTemplates: sections} } -// websocketRequestOwnerName returns the type used to mark one method stream -// closed. -func (s *servicePlan) websocketRequestOwnerName() string { - return s.clientNames.websocketRequestOwner.Name() -} - -// hasJSONRPCWebSocket reports whether service has a method that uses WebSocket. -// Generated clients include shared connection fields only when one is needed. -func hasJSONRPCWebSocket(data any) bool { - service := jsonRPCClientService(data) - for index := range service.Endpoints { - if isJSONRPCWebSocketEndpoint(service.Endpoints[index]) { - return true - } - } - return false -} - // hasJSONRPCSSE reports whether service has a method that sends server-sent // events. Generated clients include stream fields only when one is needed. - func hasJSONRPCSSE(data any) bool { service := jsonRPCClientService(data) for _, endpoint := range service.Endpoints { diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index 7d82b2ebf5..46ebcc200d 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -3,12 +3,15 @@ package codegen_test import ( + "context" "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -25,7 +28,7 @@ import ( // TestJSONRPCKitchenSink pins every file the transport and example generators // produce for a design covering the full JSON-RPC surface (plain methods, -// required/optional IDs, custom errors, WebSocket, SSE, mixed HTTP+JSON-RPC +// required and optional IDs, custom errors, SSE, and mixed HTTP and JSON-RPC // transports). Each rendered file is compared against a golden copy under // testdata/golden/kitchen_sink and the set of generated paths is compared // against a manifest so files that appear or disappear fail the test. @@ -45,14 +48,21 @@ func TestJSONRPCKitchenSink(t *testing.T) { Root: root, Service: servicePlan, HTTP: jsonHTTPPlans[0], ApplicationHTTP: httpPlans[0], }) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + httpExamples, err := httpcodegen.NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + jsonExamples, err := jsonrpccodegen.NewExamplePlan(jsonPlans[0], examplePlan) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, httpPlans[0].Link()) require.NoError(t, jsonHTTPPlans[0].Link()) require.NoError(t, jsonPlans[0].Link()) tfiles := kitchenSinkTransportFiles(httpPlans[0], jsonPlans[0]) - efiles := kitchenSinkExampleFiles(root, servicePlan, httpPlans[0], jsonPlans[0]) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + efiles := kitchenSinkExampleFiles(rootData, servicePlan, httpExamples, jsonExamples) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -84,6 +94,11 @@ func TestJSONRPCKitchenSink(t *testing.T) { require.NoError(t, err) testutil.CompareOrUpdateGolden(t, string(content), filepath.Join(goldenDir, rel+".golden")) } + feedCodec, err := os.ReadFile(filepath.Join(tmp, "gen", "jsonrpc", "feed", "client", "encode_decode.go")) + require.NoError(t, err) + require.NotContains(t, string(feedCodec), "DecodeWatchResponse") + require.Contains(t, string(feedCodec), "DecodeSnapshotResponse") + compileKitchenSink(t, servicePlan, tfiles) } // kitchenSinkTransportFiles assembles every transport file through the public @@ -106,19 +121,53 @@ func kitchenSinkTransportFiles(httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccode // kitchenSinkExampleFiles assembles example service and transport files // through their public subsystem APIs. -func kitchenSinkExampleFiles(root *expr.RootExpr, plan *service.Plan, httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { +func kitchenSinkExampleFiles(root *example.Root, plan *service.Plan, httpPlan *httpcodegen.ExamplePlan, jsonPlan *jsonrpccodegen.ExamplePlan) []*goacodegen.File { services := plan.Services() files := service.ExampleServiceFiles(plan) files = append(files, service.ExampleInterceptorsFiles(plan)...) files = append(files, example.ServerFiles(root, services)...) files = append(files, example.CLIFiles(root)...) - if len(root.API.HTTP.Services) > 0 { - files = append(files, httpPlan.ExampleCLIFiles()...) - } - if len(root.API.JSONRPC.Services) > 0 { - files = append(files, jsonPlan.ExampleServerFiles()...) - files = append(files, jsonPlan.ExampleCLIFiles()...) - } + files = append(files, httpPlan.CLIFiles()...) + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.CLIFiles()...) return files } + +// compileKitchenSink renders the service and transport packages together so +// every generated conversion must use a concrete value accepted by the +// service contract it returns. +func compileKitchenSink(t *testing.T, servicePlan *service.Plan, transportFiles []*goacodegen.File) { + t.Helper() + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + dir := t.TempDir() + for _, file := range append(serviceFiles, transportFiles...) { + _, err := file.Render(dir) + require.NoError(t, err) + } + + goaDir := goaModuleDirectory(t) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDir) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// goaModuleDirectory returns the local Goa checkout used to build this test. +func goaModuleDirectory(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + dir := strings.TrimSpace(string(output)) + require.NotEmpty(t, dir) + return dir +} diff --git a/jsonrpc/codegen/package_import_alias_test.go b/jsonrpc/codegen/package_import_alias_test.go new file mode 100644 index 0000000000..77e9ec1f1f --- /dev/null +++ b/jsonrpc/codegen/package_import_alias_test.go @@ -0,0 +1,52 @@ +// This file verifies that transport packages choose import names in their own +// Go package instead of sharing names across the entire generation. +package codegen + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// TestTransportCLIPackagesChooseClientAliasesIndependently proves that the +// HTTP and JSON-RPC command packages may both use the natural client alias. +func TestTransportCLIPackagesChooseClientAliasesIndependently(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.HTTP(func() { dsl.POST("/add") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: jsonHTTPPlans[0], + ApplicationHTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + serverName := goacodegen.SnakeCase(goacodegen.Goify(root.API.Servers[0].Name, true)) + httpCLI := generation.Package(path.Join(generation.GenPkg(), "http", "cli", serverName)) + jsonrpcCLI := generation.Package(path.Join(generation.GenPkg(), "jsonrpc", "cli", serverName)) + require.Equal(t, "calcc", httpCLI.ImportName(path.Join(generation.GenPkg(), "http", "calc", "client"))) + require.Equal(t, "calcc", jsonrpcCLI.ImportName(path.Join(generation.GenPkg(), "jsonrpc", "calc", "client"))) +} diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go index c986a6c6e1..b19a4c613f 100644 --- a/jsonrpc/codegen/plan.go +++ b/jsonrpc/codegen/plan.go @@ -17,7 +17,7 @@ import ( ) type ( - // PlanInput supplies one design and the prepared values for its generated + // PlanInput supplies one design and the copied values used to generate its // service, JSON requests, and JSON responses. PlanInput struct { // Root is the design that declares the JSON-RPC services. @@ -40,74 +40,53 @@ type ( http *httpcodegen.Plan applicationHTTP *httpcodegen.Plan services []*servicePlan + servicesByExpr map[*expr.HTTPServiceExpr]*servicePlan server []*codegen.File client []*codegen.File - example []*codegen.File - exampleCLI []*codegen.File linked bool } - // servicePlan stores one service's generated package path, function names, and - // HTTP request and response data. It also records whether methods return one - // response, server-sent events, or WebSocket messages. + // ExamplePlan builds runnable JSON-RPC programs from server data and + // generated services that came from the same design. + ExamplePlan struct { + transport *Plan + http *httpcodegen.ExamplePlan + } + + // servicePlan stores one service's generated package path, function names, + // and HTTP request and response data. servicePlan struct { - data httpcodegen.JSONRPCServiceSnapshot - name string - pathName string - endpoints []*endpointPlan - helpers map[string]*viewedHelperDeclarations - endpointNames map[string]*jsonRPCEndpointNames - clientNames jsonRPCClientNames - serverNames jsonRPCServerNames - bodyDecoder *codegen.NameDeclaration - hasHTTP bool - hasSSE bool - hasWebSocket bool + data httpcodegen.JSONRPCServiceSnapshot + api string + name string + pathName string + endpoints []*endpointPlan + helpers map[string]*viewedHelperDeclarations + clientNames jsonRPCClientNames + serverNames jsonRPCServerNames + bodyDecoder *codegen.NameDeclaration + hasHTTP bool + hasSSE bool } // endpointPlan contains the HTTP request, HTTP response, and JSON-RPC result // values for one service method. endpointPlan struct { httpcodegen.JSONRPCEndpointSnapshot - viewed *viewedRepresentation - websocketPending *codegen.NameDeclaration - websocketResult *codegen.NameDeclaration - websocketWrapper *codegen.NameDeclaration + viewed *viewedRepresentation } // jsonRPCClientNames stores the Go names written once for one client. jsonRPCClientNames struct { - bufferPool *codegen.NameDeclaration - websocketConnection *codegen.NameDeclaration - websocketRequestOwner *codegen.NameDeclaration - websocketPendingRequest *codegen.NameDeclaration - websocketMessage *codegen.NameDeclaration - websocketClosedError *codegen.NameDeclaration - newWebsocketConnection *codegen.NameDeclaration - streamErrorType *codegen.NameDeclaration - streamErrorConnection *codegen.NameDeclaration - streamErrorProtocol *codegen.NameDeclaration - streamErrorParsing *codegen.NameDeclaration - streamErrorOrphaned *codegen.NameDeclaration - streamErrorTimeout *codegen.NameDeclaration - streamErrorHandler *codegen.NameDeclaration + bufferPool *codegen.NameDeclaration } // jsonRPCServerNames stores the Go names written once for one server. jsonRPCServerNames struct { - batchWriter *codegen.NameDeclaration - encodeError *codegen.NameDeclaration - sseStream *codegen.NameDeclaration - sseBuffer *codegen.NameDeclaration - websocketStream *codegen.NameDeclaration - } - - // jsonRPCEndpointNames stores the extra Go names written for one WebSocket - // method. - jsonRPCEndpointNames struct { - websocketPending *codegen.NameDeclaration - websocketResult *codegen.NameDeclaration - websocketWrapper *codegen.NameDeclaration + batchWriter *codegen.NameDeclaration + encodeError *codegen.NameDeclaration + sseStream *codegen.NameDeclaration + sseBuffer *codegen.NameDeclaration } // viewedRepresentation lists the JSON body type and constructor used for @@ -148,6 +127,7 @@ type ( // jsonRPCNameOrder gives the same Go names the same order on every run. jsonRPCNameOrder struct { + api string service string method string role uint8 @@ -165,23 +145,6 @@ const ( jsonRPCEncodeErrorRole jsonRPCSSEStreamRole jsonRPCSSEBufferRole - jsonRPCWebSocketConnectionRole - jsonRPCWebSocketRequestOwnerRole - jsonRPCWebSocketPendingRequestRole - jsonRPCWebSocketMessageRole - jsonRPCWebSocketClosedErrorRole - jsonRPCNewWebSocketConnectionRole - jsonRPCStreamErrorTypeRole - jsonRPCStreamErrorConnectionRole - jsonRPCStreamErrorProtocolRole - jsonRPCStreamErrorParsingRole - jsonRPCStreamErrorOrphanedRole - jsonRPCStreamErrorTimeoutRole - jsonRPCStreamErrorHandlerRole - jsonRPCWebSocketServerStreamRole - jsonRPCWebSocketMethodPendingRole - jsonRPCWebSocketMethodResultRole - jsonRPCWebSocketServerWrapperRole ) // NewPlans checks that inputs contain every design with JSON-RPC services once, @@ -197,12 +160,6 @@ func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, err if err := validatePlanInputs(generation, inputs); err != nil { return nil, err } - if err := example.Plan(generation); err != nil { - return nil, err - } - if err := planImports(generation, inputs); err != nil { - return nil, err - } plans := make([]*Plan, len(inputs)) for index, input := range inputs { plan := &Plan{ @@ -211,27 +168,56 @@ func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, err service: input.Service, http: input.HTTP, applicationHTTP: input.ApplicationHTTP, + servicesByExpr: make(map[*expr.HTTPServiceExpr]*servicePlan), } for _, transport := range input.Root.API.JSONRPC.Services { - planned, err := collectServicePlan(generation, transport) + planned, err := collectServicePlan(generation, input, transport) if err != nil { return nil, err } plan.services = append(plan.services, planned) + plan.servicesByExpr[transport] = planned } sort.Slice(plan.services, func(i, j int) bool { return plan.services[i].name < plan.services[j].name }) plans[index] = plan } + if err := planImports(generation, inputs); err != nil { + return nil, err + } return plans, nil } +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + if _, ok := examples.Root(transport.service); !ok { + return nil, fmt.Errorf("JSON-RPC examples require server data created from the same service design") + } + httpPlan, err := httpcodegen.NewExamplePlan(transport.http, examples) + if err != nil { + return nil, err + } + return &ExamplePlan{transport: transport, http: httpPlan}, nil +} + // Root returns the design used to create p. func (p *Plan) Root() *expr.RootExpr { return p.root } +// Service returns the finalized JSON-RPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (httpcodegen.JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.servicesByExpr[service] + if !ok { + return httpcodegen.JSONRPCServiceSnapshot{}, false + } + return p.http.JSONRPCService(planned.name) +} + // Link reads the completed service and HTTP plans and builds every JSON-RPC // file. The caller must first ask Goa to choose unique Go names and then link // both input plans so all JSON body types and constructors are available. @@ -251,32 +237,21 @@ func (p *Plan) Link() error { planned.pathName = data.Service.PathName for _, endpoint := range data.Endpoints { helper := planned.helpers[endpoint.Method.Name] - names := planned.endpointNames[endpoint.Method.Name] viewed, hasViewedResult := p.http.ViewedResult(planned.name, endpoint.Method.Name) plannedEndpoint := &endpointPlan{ JSONRPCEndpointSnapshot: endpoint, viewed: planViewedRepresentation(&endpoint, viewed, hasViewedResult, helper), } - if names != nil { - plannedEndpoint.websocketPending = names.websocketPending - plannedEndpoint.websocketResult = names.websocketResult - plannedEndpoint.websocketWrapper = names.websocketWrapper - } planned.endpoints = append(planned.endpoints, plannedEndpoint) - switch { - case endpoint.SSE != nil: + if endpoint.SSE != nil { planned.hasSSE = true - case isJSONRPCWebSocketEndpoint(endpoint): - planned.hasWebSocket = true - default: + } else { planned.hasHTTP = true } } } p.server = serverFiles(p.services) p.client = clientFiles(p.services) - p.example = p.http.CombinedExampleServerFiles(p.applicationHTTP) - p.exampleCLI = p.http.ExampleCLIFiles() p.linked = true return nil } @@ -317,17 +292,17 @@ func (p *Plan) ClientCLIFiles() []*codegen.File { return p.http.ClientCLIFiles() } -// ExampleServerFiles returns the runnable servers built by Link. Each file -// mounts both ordinary HTTP and JSON-RPC services declared on that server. -func (p *Plan) ExampleServerFiles() []*codegen.File { - p.requireLinked() - return p.example +// ServerFiles builds runnable servers that mount the saved ordinary HTTP and +// JSON-RPC services for each copied server. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CombinedServerFiles(p.transport.applicationHTTP) } -// ExampleCLIFiles returns runnable command-line clients for p's JSON-RPC services. -func (p *Plan) ExampleCLIFiles() []*codegen.File { - p.requireLinked() - return p.exampleCLI +// CLIFiles builds runnable JSON-RPC clients for each copied server. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CLIFiles() } // planViewedRepresentation copies each allowed result view and its JSON body @@ -366,6 +341,34 @@ func planViewedRepresentation(endpoint *httpcodegen.JSONRPCEndpointSnapshot, vie return representation } +// servicePlanForOutput copies the service package qualifiers written by one +// JSON-RPC client or server package. The two packages reserve imports +// independently, so a standard-library name used only by the server may suffix +// the generated service import only on that side. +func servicePlanForOutput(planned *servicePlan, client bool) *servicePlan { + copy := *planned + copy.data = planned.data + serviceImport := planned.data.ServerServiceImport() + if client { + serviceImport = planned.data.ClientServiceImport() + } + copy.data.Service.PkgName = serviceImport.Name + copy.data.Endpoints = make([]httpcodegen.JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + copy.endpoints = make([]*endpointPlan, len(planned.endpoints)) + for index, endpoint := range planned.endpoints { + endpointCopy := *endpoint + endpointCopy.ServicePkgName = serviceImport.Name + if endpoint.viewed != nil { + viewedCopy := *endpoint.viewed + viewedCopy.servicePkg = serviceImport.Name + endpointCopy.viewed = &viewedCopy + } + copy.endpoints[index] = &endpointCopy + copy.data.Endpoints[index] = endpointCopy.JSONRPCEndpointSnapshot + } + return © +} + // requireLinked stops callers from reading files before Link has built them. func (p *Plan) requireLinked() { if !p.linked { @@ -375,48 +378,38 @@ func (p *Plan) requireLinked() { // planImports records every import name written directly into JSON-RPC files. func planImports(generation *codegen.Generation, inputs []PlanInput) error { - imports := []*codegen.ImportSpec{ + clientImports := []*codegen.ImportSpec{ codegen.SimpleImport("bufio"), - codegen.SimpleImport("bytes"), - codegen.SimpleImport("context"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("errors"), - codegen.SimpleImport("fmt"), - codegen.SimpleImport("io"), - codegen.SimpleImport("mime/multipart"), - codegen.SimpleImport("net/http"), - codegen.SimpleImport("path"), - codegen.SimpleImport("strconv"), - codegen.SimpleImport("strings"), codegen.SimpleImport("sync"), - codegen.SimpleImport("sync/atomic"), - codegen.SimpleImport("time"), - codegen.SimpleImport("github.com/gorilla/websocket"), - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), codegen.GoaImport("jsonrpc"), } - for _, spec := range imports { - if err := generation.RequireImport(spec); err != nil { - return err - } + serverImports := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("mime"), + codegen.GoaImport(""), + codegen.GoaImport("jsonrpc"), } for _, input := range inputs { - design := input.Root - for _, service := range design.API.JSONRPC.Services { - pathName := codegen.SnakeCase(codegen.Goify(service.Name(), false)) - packageName := strings.ToLower(codegen.Goify(service.Name(), false)) - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"c", path.Join(generation.GenPkg(), "jsonrpc", pathName, "client"))); err != nil { - return err - } - if err := generation.ReserveGeneratedImport(codegen.NewImport(packageName+"jssvr", path.Join(generation.GenPkg(), "jsonrpc", pathName, "server"))); err != nil { + for _, transport := range input.Root.API.JSONRPC.Services { + serviceImport, _, err := input.Service.ServicePackageImports(transport.ServiceExpr) + if err != nil { return err } - } - if len(design.API.JSONRPC.Services) > 0 { - for _, server := range design.API.Servers { - serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) - if err := generation.ReserveGeneratedImport(codegen.NewImport("cli", path.Join(generation.GenPkg(), "jsonrpc", "cli", serverName))); err != nil { + pathName := path.Base(serviceImport.Path) + for index, outputPackage := range []*codegen.GeneratedPackage{ + generation.Package(path.Join(generation.GenPkg(), "jsonrpc", pathName, "client")), + generation.Package(path.Join(generation.GenPkg(), "jsonrpc", pathName, "server")), + } { + imports := clientImports + if index == 1 { + imports = serverImports + } + for _, spec := range imports { + if err := outputPackage.RequireImport(spec); err != nil { + return err + } + } + if err := outputPackage.ReserveGeneratedImport(serviceImport); err != nil { return err } } @@ -489,13 +482,6 @@ func isJSONRPCSSEEndpoint(data any) bool { return jsonRPCEndpoint(data).SSE != nil } -// isJSONRPCWebSocketEndpoint reports whether the supplied method sends or -// receives JSON-RPC messages through a WebSocket. -func isJSONRPCWebSocketEndpoint(data any) bool { - endpoint := jsonRPCEndpoint(data) - return endpoint.ClientWebSocket != nil || endpoint.ServerWebSocket != nil -} - // jsonRPCEndpoint returns the method values used to write a generated file. func jsonRPCEndpoint(data any) *httpcodegen.JSONRPCEndpointSnapshot { switch endpoint := data.(type) { @@ -514,8 +500,12 @@ func jsonRPCEndpoint(data any) *httpcodegen.JSONRPCEndpointSnapshot { // path, then requests client decoder and server encoder names for every method // that returns a result view. Link later adds the HTTP endpoint data used to // build that service's files. -func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServiceExpr) (*servicePlan, error) { - pathName := codegen.SnakeCase(codegen.Goify(transport.Name(), false)) +func collectServicePlan(generation *codegen.Generation, input PlanInput, transport *expr.HTTPServiceExpr) (*servicePlan, error) { + serviceImport, _, err := input.Service.ServicePackageImports(transport.ServiceExpr) + if err != nil { + return nil, err + } + pathName := path.Base(serviceImport.Path) clientPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "client") serverPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "server") client, err := generation.ClaimPackage(clientPath) @@ -527,13 +517,14 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ return nil, err } planned := &servicePlan{ - name: transport.Name(), - pathName: pathName, - helpers: make(map[string]*viewedHelperDeclarations), - endpointNames: make(map[string]*jsonRPCEndpointNames), + api: input.Root.API.Name, + name: transport.Name(), + pathName: pathName, + helpers: make(map[string]*viewedHelperDeclarations), } declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, method string, role uint8) (*codegen.NameDeclaration, error) { declaration := codegen.NewPreferredName(kind, preferred, visibility, jsonRPCNameOrder{ + api: planned.api, service: planned.name, method: method, role: role, @@ -543,26 +534,21 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ } return declaration, nil } - hasHTTP, hasSSE, hasWebSocket := false, false, false + hasHTTP, hasSSE := false, false for _, endpoint := range transport.HTTPEndpoints { - switch { - case endpoint.UsesSSE(): + if endpoint.UsesSSE() { hasSSE = true - case endpoint.UsesWebSocket(): - hasWebSocket = true - default: + } else { hasHTTP = true } } - if !hasWebSocket { - planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", codegen.UnexportedName, "", jsonRPCBufferPoolRole) - if err != nil { - return nil, err - } - planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", codegen.UnexportedName, "", jsonRPCEncodeErrorRole) - if err != nil { - return nil, err - } + planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", codegen.UnexportedName, "", jsonRPCBufferPoolRole) + if err != nil { + return nil, err + } + planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", codegen.UnexportedName, "", jsonRPCEncodeErrorRole) + if err != nil { + return nil, err } if hasHTTP { planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", codegen.UnexportedName, "", jsonRPCBatchWriterRole) @@ -580,62 +566,8 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ return nil, err } } - if hasWebSocket { - clientDeclarations := []struct { - target **codegen.NameDeclaration - kind codegen.PackageNameKind - preferred string - visibility codegen.PackageNameVisibility - role uint8 - }{ - {&planned.clientNames.websocketConnection, codegen.NameType, "websocketClientConn", codegen.UnexportedName, jsonRPCWebSocketConnectionRole}, - {&planned.clientNames.websocketRequestOwner, codegen.NameType, "websocketRequestOwner", codegen.UnexportedName, jsonRPCWebSocketRequestOwnerRole}, - {&planned.clientNames.websocketPendingRequest, codegen.NameType, "websocketPendingRequest", codegen.UnexportedName, jsonRPCWebSocketPendingRequestRole}, - {&planned.clientNames.websocketMessage, codegen.NameType, "websocketMessage", codegen.UnexportedName, jsonRPCWebSocketMessageRole}, - {&planned.clientNames.websocketClosedError, codegen.NameVariable, "errWebsocketMethodStreamClosed", codegen.UnexportedName, jsonRPCWebSocketClosedErrorRole}, - {&planned.clientNames.newWebsocketConnection, codegen.NameFunction, "newWebsocketClientConn", codegen.UnexportedName, jsonRPCNewWebSocketConnectionRole}, - {&planned.clientNames.streamErrorType, codegen.NameType, "StreamErrorType", codegen.ExportedName, jsonRPCStreamErrorTypeRole}, - {&planned.clientNames.streamErrorConnection, codegen.NameConstant, "StreamErrorConnection", codegen.ExportedName, jsonRPCStreamErrorConnectionRole}, - {&planned.clientNames.streamErrorProtocol, codegen.NameConstant, "StreamErrorProtocol", codegen.ExportedName, jsonRPCStreamErrorProtocolRole}, - {&planned.clientNames.streamErrorParsing, codegen.NameConstant, "StreamErrorParsing", codegen.ExportedName, jsonRPCStreamErrorParsingRole}, - {&planned.clientNames.streamErrorOrphaned, codegen.NameConstant, "StreamErrorOrphaned", codegen.ExportedName, jsonRPCStreamErrorOrphanedRole}, - {&planned.clientNames.streamErrorTimeout, codegen.NameConstant, "StreamErrorTimeout", codegen.ExportedName, jsonRPCStreamErrorTimeoutRole}, - {&planned.clientNames.streamErrorHandler, codegen.NameType, "StreamErrorHandler", codegen.ExportedName, jsonRPCStreamErrorHandlerRole}, - } - for _, item := range clientDeclarations { - *item.target, err = declare(client, item.kind, item.preferred, item.visibility, "", item.role) - if err != nil { - return nil, err - } - } - preferredStream := codegen.Goify(transport.Name(), false) + "Stream" - planned.serverNames.websocketStream, err = declare(server, codegen.NameType, preferredStream, codegen.UnexportedName, "", jsonRPCWebSocketServerStreamRole) - if err != nil { - return nil, err - } - } for _, endpoint := range transport.HTTPEndpoints { method := endpoint.MethodExpr - if endpoint.UsesWebSocket() { - names := &jsonRPCEndpointNames{} - if method.StreamingResult != nil { - names.websocketPending, err = declare(client, codegen.NameType, codegen.Goify(method.Name, false)+"ClientStreamPendingRequest", codegen.UnexportedName, method.Name, jsonRPCWebSocketMethodPendingRole) - if err != nil { - return nil, err - } - names.websocketResult, err = declare(client, codegen.NameType, codegen.Goify(method.Name, false)+"ClientStreamStreamResult", codegen.UnexportedName, method.Name, jsonRPCWebSocketMethodResultRole) - if err != nil { - return nil, err - } - } - if method.Stream == expr.ServerStreamKind || method.Stream == expr.BidirectionalStreamKind { - names.websocketWrapper, err = declare(server, codegen.NameType, codegen.Goify(method.Name, false)+"StreamWrapper", codegen.UnexportedName, method.Name, jsonRPCWebSocketServerWrapperRole) - if err != nil { - return nil, err - } - } - planned.endpointNames[method.Name] = names - } if _, ok := method.Result.Type.(*expr.ResultTypeExpr); !ok { continue } @@ -644,7 +576,7 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ codegen.NameFunction, "decodeJSONRPCResult", codegen.UnexportedName, - jsonRPCNameOrder{service: planned.name, role: viewedBodyDecoderRole}, + jsonRPCNameOrder{api: planned.api, service: planned.name, role: viewedBodyDecoderRole}, ) if err := client.DeclareName(planned.bodyDecoder); err != nil { return nil, err @@ -656,25 +588,25 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ codegen.NameFunction, "decode"+methodName+"ViewedResult", codegen.UnexportedName, - jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedResultDecoderRole}, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultDecoderRole}, ), encode: codegen.NewPreferredName( codegen.NameFunction, "encode"+methodName+"ViewedResult", codegen.UnexportedName, - jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedResultEncoderRole}, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultEncoderRole}, ), streamEncode: codegen.NewPreferredName( codegen.NameFunction, "encode"+methodName+"Result", codegen.UnexportedName, - jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedStreamEncoderRole}, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedStreamEncoderRole}, ), writeMetadata: codegen.NewPreferredName( codegen.NameFunction, "write"+methodName+"ViewedResponseMetadata", codegen.UnexportedName, - jsonRPCNameOrder{service: planned.name, method: method.Name, role: viewedMetadataWriterRole}, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedMetadataWriterRole}, ), } if err := client.DeclareName(helpers.decode); err != nil { @@ -697,6 +629,9 @@ func collectServicePlan(generation *codegen.Generation, transport *expr.HTTPServ // ComparePackageName orders Go declarations by service, method, and use. func (o jsonRPCNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { right := other.(jsonRPCNameOrder) + if compared := strings.Compare(o.api, right.api); compared != 0 { + return compared + } if compared := strings.Compare(o.service, right.service); compared != 0 { return compared } diff --git a/jsonrpc/codegen/plan_service_test.go b/jsonrpc/codegen/plan_service_test.go new file mode 100644 index 0000000000..3b400aabd5 --- /dev/null +++ b/jsonrpc/codegen/plan_service_test.go @@ -0,0 +1,90 @@ +// This file checks that plugins can read only the finalized JSON-RPC service +// data that belongs to the exact service expression used to build a plan. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceUsesExactExpressionAfterLink(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + plans, err := NewPlans( + generation, + PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }, + PlanInput{ + Root: roots[1], + Service: services[1], + HTTP: jsonPlans[1], + ApplicationHTTP: applicationPlans[1], + }, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].Service(roots[0].API.JSONRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + for _, httpPlan := range jsonPlans { + require.NoError(t, httpPlan.Link()) + } + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + + data, ok := plans[0].Service(roots[0].API.JSONRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "First", data.Service.Name) + require.NotEmpty(t, data.ClientStructDeclaration.Name()) + require.NotEmpty(t, data.ServerStructDeclaration.Name()) + + foreign := expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")) + data, ok = plans[0].Service(foreign.API.JSONRPC.Services[0]) + require.False(t, ok) + require.Empty(t, data) +} + +// TestPlanServiceReturnsDetachedSnapshot verifies that changing nested values +// returned to one plugin cannot change a later read or the files already +// prepared for generation. +func TestPlanServiceReturnsDetachedSnapshot(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + service := plan.root.API.JSONRPC.Services[0] + before := renderJSONRPCFiles(t, plan.ClientFiles()) + + first, ok := plan.Service(service) + require.True(t, ok) + require.NotEmpty(t, first.Endpoints) + require.NotNil(t, first.Endpoints[0].Result) + first.Endpoints[0].Result.Ref = "changed.Result" + + second, ok := plan.Service(service) + require.True(t, ok) + require.NotEqual(t, "changed.Result", second.Endpoints[0].Result.Ref) + require.Equal(t, before, renderJSONRPCFiles(t, plan.ClientFiles())) +} + +// renderJSONRPCFiles writes file sections without changing the plan. +func renderJSONRPCFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var source strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + return source.String() +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go index ed477d2981..709948ac38 100644 --- a/jsonrpc/codegen/plan_test.go +++ b/jsonrpc/codegen/plan_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" @@ -37,7 +38,52 @@ func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { require.NoError(t, servicePlan.Link()) services := servicePlan.Services() - require.Equal(t, "uuid2", services.ServiceImport("UUID").Name) + clientOutput := "generated.local/gen/jsonrpc/uuid/client" + require.Equal(t, "uuid2", services.ServiceImport(clientOutput, "UUID").Name) +} + +// TestPlanRetainsServicePackageImport verifies JSON-RPC output packages use +// the service package selected before later expression changes. The retained +// preferred name must still resolve around the client's sync import. +func TestPlanRetainsServicePackageImport(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Sync", func() { + dsl.Method("Read", func() { + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + transport := root.API.JSONRPC.Services[0] + transport.ServiceExpr.Name = "Changed" + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + client := generation.Package("generated.local/gen/jsonrpc/sync/client") + require.Equal(t, "sync2", client.ImportName("generated.local/gen/sync")) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + _, transport := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + otherRoot := expr.RunDSL(t, viewedJSONRPCPlanDSL) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "JSON-RPC examples require server data created from the same service design") } // TestPlanReservesGeneratedJSONRPCPackages verifies that the JSON-RPC client, @@ -62,15 +108,15 @@ func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { require.NoError(t, servicePlan.Link()) services := servicePlan.Services() - client := services.PackageImport("generated.local/gen/jsonrpc/foo/client") - server := services.PackageImport("generated.local/gen/jsonrpc/foo/server") - cli := services.PackageImport(path.Join( + cliOutput := path.Join( "generated.local/gen/jsonrpc/cli", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), - )) - require.NotEqual(t, services.ServiceImport("Fooc").Name, client.Name) - require.NotEqual(t, services.ServiceImport("Foojssvr").Name, server.Name) - require.Equal(t, "cli", cli.Name) + ) + client := services.PackageImport(cliOutput, "generated.local/gen/jsonrpc/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/jsonrpc/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foojssvr", server.Name) } // TestNewPlansRequiresEveryJSONRPCRoot verifies that planning cannot reserve @@ -86,7 +132,7 @@ func TestNewPlansRequiresEveryJSONRPCRoot(t *testing.T) { ApplicationHTTP: applicationPlans[0], }) require.EqualError(t, err, "JSON-RPC planning requires all 2 JSON-RPC roots, got 1") - assertViewedHelperNameAvailable(t, generation, "first") + assertViewedHelperNameAvailable(t, generation) } // TestNewPlansRejectsDuplicateRoot verifies that two inputs cannot plan the @@ -103,7 +149,7 @@ func TestNewPlansRejectsDuplicateRoot(t *testing.T) { _, err := NewPlans(generation, input, input) require.EqualError(t, err, "JSON-RPC root is planned more than once: First") - assertViewedHelperNameAvailable(t, generation, "first") + assertViewedHelperNameAvailable(t, generation) } // TestNewPlansRejectsRootWithoutJSONRPC verifies that inputs contain only @@ -118,7 +164,7 @@ func TestNewPlansRejectsRootWithoutJSONRPC(t *testing.T) { PlanInput{Root: roots[2], Service: services[2], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[2]}, ) require.EqualError(t, err, "root does not declare JSON-RPC services") - assertViewedHelperNameAvailable(t, generation, "first") + assertViewedHelperNameAvailable(t, generation) } // TestNewPlansRejectsMismatchedInputPlans verifies that every service and HTTP @@ -186,7 +232,7 @@ func TestNewPlansRejectsMismatchedInputPlans(t *testing.T) { generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) _, err := NewPlans(generation, test.change(roots, services, jsonPlans, applicationPlans)...) require.EqualError(t, err, test.error) - assertViewedHelperNameAvailable(t, generation, "first") + assertViewedHelperNameAvailable(t, generation) }) } } @@ -261,12 +307,16 @@ func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { ApplicationHTTP: applicationPlans[0], }) require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, applicationPlans[0].Link()) require.NoError(t, httpPlans[0].Link()) - httpFile := applicationPlans[0].ExampleServerFiles()[0] + httpExamples, err := httpcodegen.NewExamplePlan(applicationPlans[0], examplePlan) + require.NoError(t, err) + httpFile := httpExamples.ServerFiles()[0] httpImports := append([]*codegen.ImportSpec(nil), httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) require.NoError(t, plans[0].Link()) @@ -277,7 +327,9 @@ func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { require.Empty(t, section.Data.(map[string]any)["JSONRPCServices"]) } } - combined := plans[0].ExampleServerFiles()[0] + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + combined := examples.ServerFiles()[0] require.NotSame(t, httpFile, combined) for _, section := range combined.SectionTemplates { switch section.Name { @@ -290,7 +342,7 @@ func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { // TestPlanUsesHTTPViewedRepresentationBranches verifies each method uses the // body types and constructors that the HTTP plan prepared for its result views. func TestPlanUsesHTTPViewedRepresentationBranches(t *testing.T) { - _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) require.Len(t, plan.services, 1) endpoints := make(map[string]*endpointPlan) @@ -323,7 +375,7 @@ func TestPlanUsesHTTPViewedRepresentationBranches(t *testing.T) { // service method may return. Each branch uses the mapped field's JSON body and // result constructor supplied by the HTTP plan. func TestPlanUsesEveryViewForMappedResultField(t *testing.T) { - _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCMappedFieldPlanDSL) + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCMappedFieldPlanDSL) httpViewed, ok := shared.ViewedResult("mapped", "fetch") require.True(t, ok) representations := httpViewed.Representations @@ -350,7 +402,7 @@ func TestPlanUsesEveryViewForMappedResultField(t *testing.T) { // supplies that one view name, and JSON-RPC copies it without deriving a value // from the first response branch. func TestPlanTreatsSoleResultViewAsFixed(t *testing.T) { - _, _, shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCSoleViewPlanDSL) + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCSoleViewPlanDSL) httpViewed, ok := shared.ViewedResult("sole", "fetch") require.True(t, ok) representations := httpViewed.Representations @@ -414,9 +466,8 @@ func TestPlanUsesAssignedViewedHelperNames(t *testing.T) { } // linkedJSONRPCPlan evaluates one design, assigns all generated Go names, and -// links the service, HTTP, and JSON-RPC plans used by a test. It returns the -// completed plans and service data so each test can inspect generated facts. -func linkedJSONRPCPlan(t *testing.T, design func()) (*expr.RootExpr, *service.Plan, *httpcodegen.Plan, *Plan) { +// links the service, HTTP, and JSON-RPC plans used by a test. +func linkedJSONRPCPlan(t *testing.T, design func()) (*httpcodegen.Plan, *Plan) { t.Helper() root := expr.RunDSL(t, design) generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) @@ -431,7 +482,7 @@ func linkedJSONRPCPlan(t *testing.T, design func()) (*expr.RootExpr, *service.Pl require.NoError(t, servicePlan.Link()) require.NoError(t, httpPlans[0].Link()) require.NoError(t, plans[0].Link()) - return root, servicePlan, httpPlans[0], plans[0] + return httpPlans[0], plans[0] } // jsonRPCPlanningInputs builds two roots and their matching service, ordinary @@ -466,9 +517,9 @@ func jsonRPCPlanningInputs(t *testing.T) (*codegen.Generation, []*expr.RootExpr, // assertViewedHelperNameAvailable submits the helper name that a rejected plan // would have used and verifies no earlier JSON-RPC input consumed it. -func assertViewedHelperNameAvailable(t *testing.T, generation *codegen.Generation, serviceName string) { +func assertViewedHelperNameAvailable(t *testing.T, generation *codegen.Generation) { t.Helper() - client, err := generation.ClaimPackage(path.Join("generated.local/gen/jsonrpc", serviceName, "client")) + client, err := generation.ClaimPackage(path.Join("generated.local/gen/jsonrpc/first/client")) require.NoError(t, err) declaration := codegen.NewPreferredName( codegen.NameFunction, diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index 86c3b1e656..db9e8057ac 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -30,14 +30,12 @@ type ( func serverFiles(services []*servicePlan) []*codegen.File { files := make([]*codegen.File, 0, len(services)*3) for _, planned := range services { - files = append(files, addFileImports(serverFile(planned), planned.data)) - // A service uses either a WebSocket file or an SSE file for streaming. - if planned.hasSSE { - if f := sseServerFile(planned); f != nil { + renderPlan := servicePlanForOutput(planned, false) + files = append(files, addFileImports(serverFile(renderPlan), planned.data)) + if renderPlan.hasSSE { + if f := sseServerFile(renderPlan); f != nil { files = append(files, addFileImports(f, planned.data)) } - } else if f := websocketServerFile(planned); f != nil { - files = append(files, addFileImports(f, planned.data)) } } for _, planned := range services { @@ -72,14 +70,11 @@ func serverFile(planned *servicePlan) *codegen.File { fpath := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "server.go") title := fmt.Sprintf("%s JSON-RPC server", planned.name) funcs := map[string]any{ - "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, - "isSSEEndpoint": isJSONRPCSSEEndpoint, - "lowerInitial": lowerInitial, - "encodeErrorName": planned.encodeErrorName, - "sseStreamName": planned.sseStreamName, - "websocketServerStreamName": planned.websocketServerStreamName, - "websocketWrapperName": planned.websocketWrapperName, - "hasMixedTransports": planned.hasMixedTransports, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "lowerInitial": lowerInitial, + "encodeErrorName": planned.encodeErrorName, + "sseStreamName": planned.sseStreamName, + "hasMixedTransports": planned.hasMixedTransports, } for name, function := range viewedResultFuncs(planned) { funcs[name] = function @@ -92,6 +87,7 @@ func serverFile(planned *servicePlan) *codegen.File { &codegen.ImportSpec{Path: "errors"}, &codegen.ImportSpec{Path: "fmt"}, &codegen.ImportSpec{Path: "io"}, + &codegen.ImportSpec{Path: "mime"}, &codegen.ImportSpec{Path: "mime/multipart"}, &codegen.ImportSpec{Path: "net/http"}, &codegen.ImportSpec{Path: "path"}, @@ -99,13 +95,13 @@ func serverFile(planned *servicePlan) *codegen.File { codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), + data.ServerServiceImport(), ) - if serviceNeedsMetadataStrconv(planned) { + if serviceNeedsMetadataStrconv(planned) || planned.hasHTTP && planned.hasSSE { imports = append(imports, &codegen.ImportSpec{Path: "strconv"}) } if serviceHasViewedResult(data) { - imports = append(imports, data.ViewImport()) + imports = append(imports, data.ServerViewImport()) } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -130,8 +126,6 @@ func serverFile(planned *servicePlan) *codegen.File { sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) case planned.hasSSE: sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) - case planned.hasWebSocket: - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-websocket-server-handler", Source: jsonrpcTemplates.Read(websocketServerHandlerT), FuncMap: funcs, Data: renderData}) default: sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) } @@ -157,9 +151,7 @@ func serverFile(planned *servicePlan) *codegen.File { } sections = append(sections, serverViewedResultSections(planned)...) - if !planned.hasWebSocket { - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT), Data: renderData}) - } + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT), Data: renderData}) return &codegen.File{Path: fpath, SectionTemplates: sections} } diff --git a/jsonrpc/codegen/server_error_contract_test.go b/jsonrpc/codegen/server_error_contract_test.go index cf01d06a0c..4e468deec6 100644 --- a/jsonrpc/codegen/server_error_contract_test.go +++ b/jsonrpc/codegen/server_error_contract_test.go @@ -14,26 +14,43 @@ import ( "goa.design/goa/v3/jsonrpc/codegen/testdata" ) -// TestServerErrorResponses verifies request decoding writes JSON-RPC errors, -// service code can explicitly write a server-sent event error, service method -// failures return to the server, and unary failures still become responses. +// TestServerErrorResponses verifies the transport writes request and service +// failures without exposing JSON-RPC error methods through the service stream. func TestServerErrorResponses(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) plan := CreateJSONRPCPlan(root) feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") - require.Contains(t, feedServer, "if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}") - require.Contains(t, feedServer, "if _, err := endpoint(ctx, v); err != nil {\n\t\t\treturn err") - require.NotContains(t, feedServer, "return strm.SendError") + require.Contains(t, feedServer, "return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil)") + require.Contains(t, feedServer, `"result": nil`) + require.NotContains(t, feedServer, "jsonrpc.MakeSuccessResponse(req.ID, nil)") + require.Contains(t, feedServer, "return strm.sendSSEEvent(ctx, \"response\", response)") + require.Contains(t, feedServer, "jsonrpc.MakeSuccessResponse(id, res)") + require.Equal(t, 1, strings.Count(feedServer, `mux.Handle("POST", "/feed", h.ServeHTTP)`)) + require.NotContains(t, feedServer, "SendError") feedStream := renderPlannedFile(t, plan.ServerFiles(), "feed", "sse.go") - require.Contains(t, feedStream, "func (s *WatchServerStream) SendError(") + require.Contains(t, feedStream, "func (s *WatchServerStream) Send(event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) Close() error") + require.NotContains(t, feedStream, "SendAndClose") + require.NotContains(t, feedStream, "SendError") calcServer := renderPlannedFile(t, plan.ServerFiles(), "calc", "server.go") require.Contains(t, calcServer, "if err != nil {") require.Contains(t, calcServer, "encodeJSONRPCError(ctx, w, req,") } +// TestNamedSSEPayloadReceivesLastEventID verifies a named payload receives the +// event ID in its designed pointer field before the endpoint runs. +func TestNamedSSEPayloadReceivesLastEventID(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") + + require.Contains(t, feedServer, "params.LastEventID = &lastEventID") +} + // renderPlannedFile renders one file stored by the plan into memory without // writing generated output to the repository. func renderPlannedFile(t *testing.T, files []*codegen.File, service, name string) string { diff --git a/jsonrpc/codegen/server_protocol_runtime_test.go b/jsonrpc/codegen/server_protocol_runtime_test.go new file mode 100644 index 0000000000..909f2e80bc --- /dev/null +++ b/jsonrpc/codegen/server_protocol_runtime_test.go @@ -0,0 +1,431 @@ +// This file renders a JSON-RPC server and runs requests whose wire form decides +// whether the server returns one response, a batch, no body, or an event stream. +package codegen_test + +import "testing" + +// TestGeneratedServerFollowsJSONRPCRequestRules checks the request forms that +// determine whether the server sends one response, a batch, or an event stream. +func TestGeneratedServerFollowsJSONRPCRequestRules(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "protocol", protocolRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/protocol/server") +} + +// TestGeneratedPureSSEServerClosesRequestBodies checks that an event-only +// server closes the body supplied by the HTTP server after the stream ends. +func TestGeneratedPureSSEServerClosesRequestBodies(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", pureSSEBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +const protocolRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestRequestIDPresenceControlsResponses(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"ping"}` + "`" + `}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","result":null}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"result":null}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveProtocol(test.body, "") + if test.want == "" { + require.Empty(t, response.Body.String()) + return + } + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestRequestIDPresenceControlsErrors(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveProtocol(test.body, "") + if test.want == "" { + require.Empty(t, response.Body.String()) + return + } + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestBatchFormFollowsJSONWhitespaceAndEmptyArrayRules(t *testing.T) { + response := serveProtocol(" \n\t["+` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `+"]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":"one","result":null}]` + "`" + `, response.Body.String()) + + response = serveProtocol("[]", "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `[{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.Empty(t, response.Body.String()) +} + +func TestInvalidRequestsReturnErrors(t *testing.T) { + for _, body := range []string{ + ` + "`" + `{}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"ping"}` + "`" + `, + } { + response := serveProtocol(body, "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + } +} + +func TestBatchProcessesInvalidMembersIndependently(t *testing.T) { + response := serveProtocol("[1]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}]` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"},1,{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}} + ]` + "`" + `, response.Body.String()) +} + +func TestAcceptQualityControlsEventStreamSelection(t *testing.T) { + response := serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "text/event-stream;q=0, application/json") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "Text/Event-Stream;Q=0.5") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Empty(t, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "application/json, text/event-stream;q=0.5") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) +} + +func TestMixedServerSelectsTheRequestedMethodsResponseType(t *testing.T) { + tests := []struct { + name string + method string + accept string + wantCode int + wantEvent bool + wantPing int + wantWatch int + }{ + {name: "unary with both", method: "ping", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream with both", method: "watch", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + {name: "unary with events only", method: "ping", accept: "text/event-stream", wantCode: http.StatusNotAcceptable}, + {name: "stream with JSON only", method: "watch", accept: "application/json", wantCode: http.StatusNotAcceptable}, + {name: "unary without accept", method: "ping", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream without accept", method: "watch", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"` + "`" + ` + test.method + ` + "`" + `"}` + "`" + ` + response, calls := serveProtocolWithCalls(body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Equal(t, test.wantPing, calls.ping) + require.Equal(t, test.wantWatch, calls.watch) + if test.wantEvent { + require.Contains(t, response.Body.String(), "event: response") + } + if test.wantCode == http.StatusNotAcceptable { + require.Empty(t, response.Body.String()) + } + }) + } +} + +func TestMixedServerChoosesAFormatForRequestErrors(t *testing.T) { + tests := []struct { + name string + body string + accept string + wantCode int + wantEvent bool + wantBody bool + }{ + {name: "malformed prefers JSON", body: "{", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "malformed uses events", body: "{", accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "malformed unsupported", body: "{", accept: "application/xml", wantCode: http.StatusNotAcceptable}, + {name: "invalid prefers JSON", body: ` + "`" + `{}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown prefers JSON", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown uses events", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "unknown notification has no response", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, calls := serveProtocolWithCalls(test.body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Equal(t, test.wantBody, response.Body.Len() > 0) + if test.wantEvent { + require.Contains(t, response.Body.String(), "event: error") + } + }) + } +} + +func TestMixedServerKeepsBatchesOnJSON(t *testing.T) { + body := ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","method":"ping"}, + {"jsonrpc":"2.0","id":"two","method":"watch"}, + {"jsonrpc":"2.0","method":"watch"}, + {"jsonrpc":"2.0","id":"three","method":"ping"} + ]` + "`" + ` + response, calls := serveProtocolWithCalls(body, "application/json, text/event-stream") + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, 2, calls.ping) + require.Zero(t, calls.watch) + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":"two","error":{"code":-32601,"message":"Method is not available in a batch request"}}, + {"jsonrpc":"2.0","id":"three","result":null} + ]` + "`" + `, response.Body.String()) + + response, calls = serveProtocolWithCalls(body, "text/event-stream") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Empty(t, response.Body.String()) +} + +func TestMixedServerClosesTheOriginalBodyOnce(t *testing.T) { + tests := []struct { + name string + body string + accept string + }{ + {name: "single", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `}, + {name: "batch", body: ` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"}]` + "`" + `}, + {name: "parse error", body: "{"}, + {name: "not acceptable", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, accept: "text/event-stream"}, + {name: "stream completion", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch"}` + "`" + `, accept: "text/event-stream"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(test.body)} + serveProtocolBody(body, test.accept) + require.Equal(t, 1, body.closes) + }) + } +} + +func TestMixedServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, _, reported := serveProtocolBody(body, "application/json") + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func TestUndeclaredServiceErrorIsInternal(t *testing.T) { + err := goa.NewServiceError(errors.New("failed"), "invalid_params", false, false, false) + response, _, reported := serveProtocolBodyWithDecoder( + io.NopCloser(strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)), + "application/json, text/event-stream", + goahttp.RequestDecoder, + err, + ) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","error":{"code":-32603,"message":"failed"}}` + "`" + `, response.Body.String()) +} + +func TestMixedServerClosesTheBodySuppliedByTheHTTPServer(t *testing.T) { + original := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)} + replacement := &trackedRequestBody{Reader: strings.NewReader("")} + decoder := func(r *http.Request) goahttp.Decoder { + result := goahttp.RequestDecoder(r) + r.Body = replacement + return result + } + + serveProtocolBodyWithDecoder(original, "application/json", decoder, nil) + + require.Equal(t, 1, original.closes) + require.Zero(t, replacement.closes) +} + +func serveProtocol(body, accept string) *httptest.ResponseRecorder { + response, _ := serveProtocolWithCalls(body, accept) + return response +} + +type protocolCalls struct { + ping int + watch int +} + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func serveProtocolWithCalls(body, accept string) (*httptest.ResponseRecorder, *protocolCalls) { + response, calls, _ := serveProtocolBody(io.NopCloser(strings.NewReader(body)), accept) + return response, calls +} + +func serveProtocolBody(body io.ReadCloser, accept string) (*httptest.ResponseRecorder, *protocolCalls, []error) { + return serveProtocolBodyWithDecoder(body, accept, goahttp.RequestDecoder, nil) +} + +func serveProtocolBodyWithDecoder(body io.ReadCloser, accept string, decoder func(*http.Request) goahttp.Decoder, pingError error) (*httptest.ResponseRecorder, *protocolCalls, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + calls := &protocolCalls{} + server := &Server{ + Ping: NewPingHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.ping++ + return nil, pingError + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.watch++ + return nil, nil + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + decoder: decoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/protocol", nil) + request.Body = body + request.Header.Set("Accept", accept) + response := httptest.NewRecorder() + server.ServeHTTP(response, request) + return response, calls, reported +} +` + +const pureSSEBodyRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func TestPureSSEServerClosesTheOriginalBodyOnce(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch","params":{"topic":"alerts"}}` + "`" + `)} + _, reported := servePureSSE(body) + require.Empty(t, reported) + require.Equal(t, 1, body.closes) +} + +func TestPureSSEServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, reported := servePureSSE(body) + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func servePureSSE(body io.ReadCloser) (*httptest.ResponseRecorder, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + server := &Server{ + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { return nil, nil }), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + encoder, + errhandler, + ), + decoder: goahttp.RequestDecoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/decode", nil) + request.Body = body + response := httptest.NewRecorder() + server.handleSSE(response, request) + return response, reported +} +` diff --git a/jsonrpc/codegen/single_endpoint_test.go b/jsonrpc/codegen/single_endpoint_test.go index 497253ba0c..b768cfef50 100644 --- a/jsonrpc/codegen/single_endpoint_test.go +++ b/jsonrpc/codegen/single_endpoint_test.go @@ -111,42 +111,4 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { assert.NotNil(t, svc.Meta["jsonrpc:service"], "service should be auto-marked as JSON-RPC") }) - t.Run("WebSocket forces GET", func(t *testing.T) { - root := expr.RunDSL(t, func() { - dsl.Service("stream", func() { - dsl.JSONRPC(func() {}) - - dsl.Method("echo", func() { - dsl.StreamingPayload(func() { - dsl.ID("id") - dsl.Attribute("msg", dsl.String) - }) - dsl.StreamingResult(func() { - dsl.ID("id") - dsl.Attribute("echo", dsl.String) - }) - dsl.JSONRPC(func() {}) - }) - }) - }) - - // Check route method - httpSvc := root.API.JSONRPC.Service("stream") - require.NotNil(t, httpSvc) - - // Prepare the service to create routes - httpSvc.Prepare() - - // Find first endpoint with route - var route *expr.RouteExpr - for _, e := range httpSvc.HTTPEndpoints { - if e.IsJSONRPC() && len(e.Routes) > 0 { - route = e.Routes[0] - break - } - } - - require.NotNil(t, route) - assert.Equal(t, "GET", route.Method, "WebSocket should force GET method") - }) } diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index 07d8ba0055..cf5868b526 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -35,15 +35,19 @@ func sseServerFile(planned *servicePlan) *codegen.File { imports = append(imports, &codegen.ImportSpec{Path: "bytes"}, &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "errors"}, &codegen.ImportSpec{Path: "fmt"}, &codegen.ImportSpec{Path: "net/http"}, &codegen.ImportSpec{Path: "sync"}, - codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), + data.ServerServiceImport(), ) + for _, endpoint := range planned.endpoints { + if endpoint.SSE != nil && endpoint.Method.ViewedResult != nil && endpoint.Method.ViewedResult.ViewName == "" { + imports = append(imports, codegen.GoaImport("")) + break + } + } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { @@ -91,6 +95,7 @@ func sseClientFile(planned *servicePlan) *codegen.File { {Path: "bytes"}, {Path: "context"}, {Path: "encoding/json"}, + {Path: "errors"}, {Path: "fmt"}, {Path: "io"}, {Path: "net/http"}, @@ -98,7 +103,7 @@ func sseClientFile(planned *servicePlan) *codegen.File { {Path: "sync"}, codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), + data.ClientServiceImport(), }, ), ) diff --git a/jsonrpc/codegen/templates.go b/jsonrpc/codegen/templates.go index d35339c1d9..ad6393dd12 100644 --- a/jsonrpc/codegen/templates.go +++ b/jsonrpc/codegen/templates.go @@ -29,19 +29,6 @@ const ( viewedResultDecodeT = "viewed_result_decode" viewedResultEncodeT = "viewed_result_encode" - // WebSocket templates - websocketServerStreamT = "websocket_server_stream" - websocketServerStreamWrapperT = "websocket_server_stream_wrapper" - websocketServerHandlerT = "websocket_server_handler" - websocketServerSendT = "websocket_server_send" - websocketServerRecvT = "websocket_server_recv" - websocketServerCloseT = "websocket_server_close" - - // JSON-RPC WebSocket client templates - websocketClientConnT = "websocket_client_conn" - websocketClientStreamT = "websocket_client_stream" - websocketStreamErrorTypesT = "websocket_stream_error_types" - // SSE templates sseServerStreamBaseT = "sse_server_stream_base" sseServerStreamT = "sse_server_stream" diff --git a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl index 92254cc0da..f253008f5b 100644 --- a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl +++ b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,7 +1,6 @@ -{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} +{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes JSON-RPC requests to the %s service %s method." .EndpointInit .ServiceName .Method.Name | comment }} func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint { -{{- if not (isWebSocketEndpoint .) }} var ( {{- if .RequestEncoderDeclaration }} encodeRequest = {{ .RequestEncoderDeclaration.Name }}(c.encoder) @@ -10,14 +9,12 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) {{- end }} ) -{{- end }} {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} -{{- if not (isWebSocketEndpoint .) }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } @@ -26,36 +23,7 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint return nil, err } {{- end }} -{{- end }} -{{- if isWebSocketEndpoint . }} - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - // The method stream uses the client response reader for each WebSocket result. - decodeResponse := c.decoder - {{- end }} - - conn, err := c.getConn(ctx) - if err != nil { - return nil, err - } - - // Closing the method stream cancels this context. - streamCtx, cancel := context.WithCancel(ctx) - - stream := &{{ .ClientWebSocket.VarDeclaration.Name }}{ - conn: conn, - owner: &{{ websocketRequestOwnerName }}{}, - ctx: streamCtx, - cancel: cancel, - {{- if and .ClientWebSocket.SendName .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - pendingReady: make(chan struct{}, 1), - {{- end }} - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - decoder: decodeResponse, - {{- end }} - } - - return stream, nil -{{- else if isSSEEndpoint . }} +{{- if isSSEEndpoint . }} // For SSE endpoints, send JSON-RPC request and establish stream resp, err := c.Doer.Do(req) if err != nil { @@ -63,15 +31,21 @@ func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } // Create the SSE client stream diff --git a/jsonrpc/codegen/templates/client_init.go.tpl b/jsonrpc/codegen/templates/client_init.go.tpl index 8cab9f4b9d..72405ff856 100644 --- a/jsonrpc/codegen/templates/client_init.go.tpl +++ b/jsonrpc/codegen/templates/client_init.go.tpl @@ -6,17 +6,7 @@ func {{ .ClientInitDeclaration.Name }}( enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restoreBody bool, - {{- if hasWebSocket . }} - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, - {{- end }} ) *{{ .ClientStructDeclaration.Name }} { - {{- if hasWebSocket . }} - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - {{- end }} - return &{{ .ClientStructDeclaration.Name }}{ Doer: doer, {{- range .Endpoints }} @@ -29,10 +19,5 @@ func {{ .ClientInitDeclaration.Name }}( host: host, decoder: dec, encoder: enc, - {{- if hasWebSocket . }} - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - {{- end }} } } diff --git a/jsonrpc/codegen/templates/client_struct.go.tpl b/jsonrpc/codegen/templates/client_struct.go.tpl index 41c690f17a..067a01507f 100644 --- a/jsonrpc/codegen/templates/client_struct.go.tpl +++ b/jsonrpc/codegen/templates/client_struct.go.tpl @@ -16,23 +16,8 @@ type {{ .ClientStructDeclaration.Name }} struct { host string encoder func(*http.Request) goahttp.Encoder decoder func(*http.Response) goahttp.Decoder - {{- if hasWebSocket . }} - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.Mutex - conn *{{ .WebSocketConnection.Name }} - connecting chan struct{} - closed atomic.Bool - - // streamConfig sets request timeouts and the function called when a - // WebSocket request or connection fails. - streamConfig *jsonrpc.StreamConfig - {{- end }} } -{{- if not (hasWebSocket .) }} {{ printf "%s reuses byte buffers while requests are encoded." .BufferPool.Name | comment }} var {{ .BufferPool.Name }} = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -{{- end }} diff --git a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl index 756bf8122d..d35286fe2f 100644 --- a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl @@ -1,14 +1,147 @@ -// ServeHTTP writes server-sent events when the Accept header requests them and -// writes one ordinary JSON-RPC response for every other request. +// ServeHTTP decodes one request and uses the response type designed for its method. func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // The event-stream media type asks this server to keep writing results. - accept := r.Header.Get("Accept") - if strings.Contains(accept, "text/event-stream") { - // handleSSE writes each streaming result as a server-sent event. - s.handleSSE(w, r) + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) + + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } + + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. + var req jsonrpc.RawRequest + if err := s.decoder(r).Decode(&req); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } return } - - // handleHTTP writes one response and completes the request. - s.handleHTTP(w, r) + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || req.Method == "" { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + + switch req.Method { +{{- range .Endpoints }} + {{- if .SSE }} + case {{ printf "%q" .Method.Name }}: + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + {{- else }} + case {{ printf "%q" .Method.Name }}: + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + {{- end }} +{{- end }} + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } } diff --git a/jsonrpc/codegen/templates/partial/single_response.go.tpl b/jsonrpc/codegen/templates/partial/single_response.go.tpl index 542355238a..e3554591eb 100644 --- a/jsonrpc/codegen/templates/partial/single_response.go.tpl +++ b/jsonrpc/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,19 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) diff --git a/jsonrpc/codegen/templates/response_decoder.go.tpl b/jsonrpc/codegen/templates/response_decoder.go.tpl index bbb9801302..6a8ae0921c 100644 --- a/jsonrpc/codegen/templates/response_decoder.go.tpl +++ b/jsonrpc/codegen/templates/response_decoder.go.tpl @@ -1,20 +1,30 @@ {{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } @@ -32,7 +42,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Error.Data)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -42,8 +52,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(jresp.Error.Data)) } } @@ -54,7 +63,7 @@ func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) return res, nil {{- else if .ClientBody }} return body, nil diff --git a/jsonrpc/codegen/templates/server_encode_error.go.tpl b/jsonrpc/codegen/templates/server_encode_error.go.tpl index f4e80b6664..dcc49cc409 100644 --- a/jsonrpc/codegen/templates/server_encode_error.go.tpl +++ b/jsonrpc/codegen/templates/server_encode_error.go.tpl @@ -1,9 +1,9 @@ -{{ printf "encodeJSONRPCError writes one JSON-RPC error response and preserves a missing request ID." | comment }} +{{ printf "encodeJSONRPCError writes one error, copying the request ID or using null when none is available." | comment }} func (s *{{ .ServerStructDeclaration.Name }}) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { {{ .EncodeError.Name }}(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -{{ printf "%s writes one JSON-RPC error response and preserves a missing request ID." .EncodeError.Name | comment }} +{{ printf "%s writes one error, copying the request ID or using null when none is available." .EncodeError.Name | comment }} func {{ .EncodeError.Name }}( ctx context.Context, w http.ResponseWriter, @@ -14,10 +14,8 @@ func {{ .EncodeError.Name }}( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/templates/server_handler.go.tpl b/jsonrpc/codegen/templates/server_handler.go.tpl index 01b357d7f1..f49e159ba6 100644 --- a/jsonrpc/codegen/templates/server_handler.go.tpl +++ b/jsonrpc/codegen/templates/server_handler.go.tpl @@ -1,36 +1,45 @@ -{{- if and (not (isWebSocketEndpoint (index .Endpoints 0))) (not (hasMixedTransports)) }} +{{- if not (hasMixedTransports) }} // ServeHTTP handles JSON-RPC requests. func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } -{{- end }} -{{- comment "handleHTTP handles JSON-RPC requests." }} +{{ comment "handleHTTP reads one JSON-RPC request object or one array of requests." }} func (s *{{ .ServerStructDeclaration.Name }}) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -38,11 +47,11 @@ func (s *{{ .ServerStructDeclaration.Name }}) handleHTTP(w http.ResponseWriter, s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *{{ .ServerStructDeclaration.Name }}) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -51,37 +60,48 @@ func (s *{{ .ServerStructDeclaration.Name }}) handleSingle(w http.ResponseWriter } s.processRequest(r.Context(), r, &req, w) } +{{- end }} -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *{{ .ServerStructDeclaration.Name }}) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &{{ .BatchWriter.Name }}{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *{{ .ServerStructDeclaration.Name }}) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -93,17 +113,26 @@ func (s *{{ .ServerStructDeclaration.Name }}) processRequest(ctx context.Context switch req.Method { {{- range .Endpoints }} + {{- if not .SSE }} case {{ printf "%q" .Method.Name }}: if err := s.{{ .Method.VarName }}(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err)) } + {{- else }} + case {{ printf "%q" .Method.Name }}: + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + } + {{- end }} {{- end }} default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -{{ printf "%s joins the responses written for one JSON-RPC batch request." .BatchWriter.Name | comment }} +{{ printf "%s inserts JSON array separators around responses from one request array." .BatchWriter.Name | comment }} type {{ .BatchWriter.Name }} struct { io.Writer header http.Header @@ -126,11 +155,13 @@ func (rb *{{ .BatchWriter.Name }}) WriteHeader(statusCode int) { } func (rb *{{ .BatchWriter.Name }}) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } diff --git a/jsonrpc/codegen/templates/server_handler_init.go.tpl b/jsonrpc/codegen/templates/server_handler_init.go.tpl index 78304f5f0b..fabf41bb96 100644 --- a/jsonrpc/codegen/templates/server_handler_init.go.tpl +++ b/jsonrpc/codegen/templates/server_handler_init.go.tpl @@ -1,42 +1,35 @@ -{{ printf "%s creates a JSON-RPC handler which calls the %q service %q endpoint." .HandlerInitDeclaration.Name .ServiceName .Method.Name | comment }} -func {{ .HandlerInitDeclaration.Name }}( +{{ printf "%s creates a JSON-RPC handler which calls the %q service %q endpoint." .HandlerInit .ServiceName .Method.Name | comment }} +func {{ .HandlerInit }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, -{{- if not (isWebSocketEndpoint .) }} encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), -{{- end }} -) func(context.Context, *http.Request, *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { {{- if and (not (isSSEEndpoint .)) .Payload.Ref }} - {{- if not (and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) - {{- end }} {{- end }} - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, w http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, {{ printf "%q" .Method.Name }}) ctx = context.WithValue(ctx, goa.ServiceKey, {{ printf "%q" .ServiceName }}) {{- if isSSEEndpoint . }} - // Create the stream before decoding so a request error can be written to it. + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. strm := &{{ .SSE.StructDeclaration.Name }}{ {{ sseStreamName }}: {{ sseStreamName }}{ w: w, encoder: encoder, }, - requestID: req.ID, } {{- if .Payload.Ref }} decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) params, err := decodeParams(r, req) - if err != nil { - // Write the request error as a JSON-RPC server-sent event when the request has an ID. - if req.ID != nil && req.ID != "" { - if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil { - return err - } - } - return nil + if err != nil { + if req.HasID { + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) + } + return nil } {{- if .Payload.IDAttribute }} {{- if .Payload.IDAttributeRequired }} @@ -56,10 +49,10 @@ func {{ .HandlerInitDeclaration.Name }}( if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { ctx = context.WithValue(ctx, "last-event-id", lastEventID) {{- if .Payload.Ref }} - {{- if .Payload.Request }} - {{- if eq .Payload.Request.PayloadTypeName "Object" }} + {{- if .SSE.RequestIDPointer }} + params.{{ .SSE.RequestIDField }} = &lastEventID + {{- else }} params.{{ .SSE.RequestIDField }} = lastEventID - {{- end }} {{- end }} {{- end }} } @@ -70,33 +63,53 @@ func {{ .HandlerInitDeclaration.Name }}( Payload: params, {{- end }} } - if _, err := endpoint(ctx, v); err != nil { - return err - } - return nil + {{- if .Payload.Ref }} + _, err = endpoint(ctx, v) + {{- else }} + _, err := endpoint(ctx, v) + {{- end }} + if err != nil { + if !req.HasID { + return nil + } + {{- if .Errors }} + var named goa.GoaErrorNamer + if errors.As(err, &named) { + switch named.GoaErrorName() { + {{- range $group := .Errors }} + {{- range $mapped := $group.Errors }} + case {{ printf "%q" $mapped.Name }}: + {{- with $mapped.Response }} + return strm.sendError(ctx, req.ID, {{ .Code }}, err.Error(), err) + {{- end }} + {{- end }} + {{- end }} + } + } + {{- end }} + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + } + if !req.HasID { + return nil + } + + response := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": nil, + } + return strm.sendSSEEvent(ctx, "response", response) {{- else }} {{- if .Payload.Ref }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) - {{- end }} params, err := decodeParams(r, req) if err != nil { - {{- if isWebSocketEndpoint . }} - return nil, err - {{- else }} - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - {{ encodeErrorName }}(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil - {{- end }} } {{- if .Payload.IDAttribute }} {{- if .Payload.IDAttributeRequired }} @@ -111,15 +124,6 @@ func {{ .HandlerInitDeclaration.Name }}( {{- end }} {{- end }} {{- end }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // For {{ if eq .Method.ServerStream.Kind 3 }}server{{ else }}bidirectional{{ end }} streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - {{- if .Payload.Ref }} - return params, nil - {{- else }} - return nil, nil - {{- end }} - {{- else }} {{- if not .Result.Ref }} {{- if .Payload.Ref }} _, err = endpoint(ctx, params) @@ -129,54 +133,38 @@ func {{ .HandlerInitDeclaration.Name }}( {{- else }} res, err := endpoint(ctx, {{ if .Payload.Ref }}params{{ else }}nil{{ end }}) {{- end }} - {{- end }} - {{- if isWebSocketEndpoint . }} - {{- if not (and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - return res, err - {{- end }} - {{- else }} if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { + if req.HasID { + {{- if .Errors }} var en goa.GoaErrorNamer - if !errors.As(err, &en) { - {{ encodeErrorName }}(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { + if errors.As(err, &en) { + switch en.GoaErrorName() { {{- range $gerr := .Errors }} {{- range $err := $gerr.Errors }} - case {{ printf "%q" .Name }}: + case {{ printf "%q" .Name }}: {{- with .Response}} - {{ encodeErrorName }}(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + return nil {{- end }} {{- end }} {{- end }} - case "invalid_params": - {{ encodeErrorName }}(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - {{ encodeErrorName }}(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams } - {{ encodeErrorName }}(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } + {{- end }} + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification - {{- if not .Result.Ref }} - if req.ID == nil || req.ID == "" { - // Notification - no response + if !req.HasID { + // A notification has no ID field and receives no response. return nil } - // Request with no result - send empty success response + + {{- if not .Result.Ref }} + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -207,11 +195,6 @@ func {{ .HandlerInitDeclaration.Name }}( id = req.ID {{- end }} - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result {{- if .Method.ViewedResult }} viewedRes := res.({{ .Method.ViewedResult.FullRef }}) @@ -225,7 +208,7 @@ func {{ .HandlerInitDeclaration.Name }}( response := jsonrpc.MakeSuccessResponse(id, body) {{- else if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} // Build the response body with the fields and JSON names declared by the service. - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(res.({{ .Result.Ref }})) + body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Declaration.Name }}(res.({{ .Result.Ref }})) response := jsonrpc.MakeSuccessResponse(id, body) {{- else }} response := jsonrpc.MakeSuccessResponse(id, res) @@ -235,7 +218,6 @@ func {{ .HandlerInitDeclaration.Name }}( } return nil {{- end }} - {{- end }} {{- end }} } } diff --git a/jsonrpc/codegen/templates/server_init.go.tpl b/jsonrpc/codegen/templates/server_init.go.tpl index c2c927297c..02051ed7c3 100644 --- a/jsonrpc/codegen/templates/server_init.go.tpl +++ b/jsonrpc/codegen/templates/server_init.go.tpl @@ -1,17 +1,10 @@ {{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInitDeclaration.Name .Service.Name | comment }} func {{ .ServerInitDeclaration.Name }}( -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - streamHandler func(context.Context, {{ .Service.PkgName }}.{{ .Service.StreamDeclaration.Name }}) error, -{{- end }} endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, - {{- end }} ) *{{ .ServerStructDeclaration.Name }} { s := &{{ .ServerStructDeclaration.Name }}{ Methods: []string{ @@ -19,36 +12,21 @@ func {{ .ServerInitDeclaration.Name }}( {{ printf "%q" .Method.Name }}, {{- end }} }, -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - StreamHandler: streamHandler, -{{- end }} {{- range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(endpoints.{{ .Method.VarName }}, mux, decoder), - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: endpoints.{{ .Method.VarName }}, - {{- end }} - {{- else }} - {{ .Method.VarName }}: {{ .HandlerInitDeclaration.Name }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), - {{- end }} + {{ .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), {{- end }} decoder: decoder, encoder: encoder, errhandler: errhandler, - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader: upgrader, - configfn: configfn, - {{- end }} } // Install the request handler required by this service's methods. - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - // ServeHTTP changes the HTTP connection to a WebSocket connection. + {{- if hasMixedTransports }} s.Handler = http.HandlerFunc(s.ServeHTTP) {{- else if isSSEEndpoint (index .Endpoints 0) }} // handleSSE writes each result as a server-sent event. s.Handler = http.HandlerFunc(s.handleSSE) {{- else }} - // ServeHTTP writes one JSON-RPC response for each request. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) {{- end }} return s diff --git a/jsonrpc/codegen/templates/server_mount.go.tpl b/jsonrpc/codegen/templates/server_mount.go.tpl index 688acd1f5c..3bc32d2b08 100644 --- a/jsonrpc/codegen/templates/server_mount.go.tpl +++ b/jsonrpc/codegen/templates/server_mount.go.tpl @@ -1,19 +1,17 @@ {{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- if .HasMixed }} - // ServeHTTP checks the Accept header and chooses an ordinary response or server-sent events. + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- else if .HasSSE }} - // Every method in this server writes server-sent events. - {{- range .Endpoints }} - {{- range .Routes }} + // This server handles every method through server-sent events. + {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.handleSSE) - {{- end }} {{- end }} {{- else }} - // Every method in this server writes one ordinary JSON-RPC response. + // This server handles ordinary JSON-RPC request bodies. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} diff --git a/jsonrpc/codegen/templates/server_struct.go.tpl b/jsonrpc/codegen/templates/server_struct.go.tpl index 43d16bbeca..ca10934849 100644 --- a/jsonrpc/codegen/templates/server_struct.go.tpl +++ b/jsonrpc/codegen/templates/server_struct.go.tpl @@ -3,27 +3,12 @@ type {{ .ServerStructDeclaration.Name }} struct { http.Handler // Methods is the list of methods served by this server. Methods []string -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, {{ .Service.PkgName }}.{{ .Service.StreamDeclaration.Name }}) error -{{- end }} {{ range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} - {{- else }} {{ printf "%s is the handler for the %s method." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error - {{- end }} {{- end }} decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder errhandler func(context.Context, http.ResponseWriter, error) -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -{{- end }} } diff --git a/jsonrpc/codegen/templates/sse_client_stream.go.tpl b/jsonrpc/codegen/templates/sse_client_stream.go.tpl index 8bb664249c..e32e198b0f 100644 --- a/jsonrpc/codegen/templates/sse_client_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_client_stream.go.tpl @@ -1,8 +1,8 @@ type ( {{ printf "%s reads results sent as server-sent events." .SSE.ClientInterfaceDeclaration.Name | comment }} {{ .SSE.ClientInterfaceDeclaration.Name }} interface { - {{ .Method.ClientStream.RecvName }}() ({{ .Result.Ref }}, error) - {{ .Method.ClientStream.RecvWithContextName }}(context.Context) ({{ .Result.Ref }}, error) + {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) + {{ .Method.ClientStream.RecvWithContextName }}(context.Context) ({{ .SSE.EventTypeRef }}, error) Close() error } @@ -16,6 +16,10 @@ type ( decoder func(*http.Response) goahttp.Decoder // closed records whether Close was called or the response ended. closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error // lock prevents two calls from reading or closing the response at once. lock sync.Mutex } @@ -30,8 +34,25 @@ func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*ht } } -// parseSSEEvent reads one complete event from the response. -func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() var event strings.Builder var dataLines []string @@ -72,120 +93,103 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent() (eventType str } {{ comment .Method.ClientStream.RecvDesc }} -func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) } {{ comment .Method.ClientStream.RecvWithContextDesc }} -func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(_ context.Context) ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) ({{ .SSE.EventTypeRef }}, error) { s.lock.Lock() defer s.lock.Unlock() - var zero {{ .Result.Ref }} + var zero {{ .SSE.EventTypeRef }} if s.closed { return zero, io.EOF } for { - eventType, data, err := s.parseSSEEvent() + eventType, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } switch eventType { case "notification": - // Parse JSON-RPC notification + // Read the streamed service result from the notification parameters. var notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) } - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) } if notification.Method != {{ printf "%q" .Method.Name }} { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) } - // Decode the result from params - {{- if .Method.Result }} result, err := s.decodeResult(notification.Params) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) } return result, nil - {{- else }} - // Method has no result - return zero, nil - {{- end }} case "response": - // Final response - parse and return + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - - {{- if .Method.Result }} - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") - } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) - } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) - } - - // Mark stream as closed after final response - s.closed = true - return result, nil - {{- else }} - // Method has no result - s.closed = true - return zero, nil - {{- end }} + return zero, s.endStream(io.EOF) case "error": - // Error response + // A JSON-RPC error completes the stream. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) } - - s.closed = true if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - return zero, fmt.Errorf("unexpected error response") + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) default: - // Ignore unknown event types - continue + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) } } } -{{- if .Method.Result }} +// closeBody closes the HTTP response body once and returns its close error. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + // decodeResult passes one successful stream item to the decoder configured by NewClient. -func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMessage) ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMessage) ({{ .SSE.EventTypeRef }}, error) { {{- if .Method.ViewedResult }} // The HTTP 200 status tells the configured decoder that this stream item is // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. @@ -199,7 +203,7 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMess } decoder := s.decoder(resp) - var result {{ .Result.Ref }} + var result {{ .SSE.EventTypeRef }} if err := decoder.Decode(&result); err != nil { return result, err } @@ -207,18 +211,15 @@ func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMess return result, nil {{- end }} } -{{- end }} {{ comment "Close closes the stream." }} func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() - if !s.closed { - s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } - } + if !s.closed { + s.closed = true + return s.closeBody() + } return nil } diff --git a/jsonrpc/codegen/templates/sse_server_handler.go.tpl b/jsonrpc/codegen/templates/sse_server_handler.go.tpl index 10c0ffc57b..ae710d7bbf 100644 --- a/jsonrpc/codegen/templates/sse_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_handler.go.tpl @@ -1,10 +1,15 @@ +{{- if not (hasMixedTransports) }} // handleSSE finds the requested method and writes its results as server-sent events. func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + originalBody := r.Body + r.Body = io.NopCloser(originalBody) // Read the JSON-RPC request. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { + closeErr := originalBody.Close() + s.errhandler(ctx, w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) // Write the parse error as a server-sent event. stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { @@ -12,9 +17,20 @@ func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r } return } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(ctx, w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + s.processSSERequest(ctx, r, &req, w) +} +{{- end }} + +// processSSERequest validates and runs one server-sent-event request. +func (s *{{ .ServerStructDeclaration.Name }}) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { // Reject requests that do not use JSON-RPC 2.0. - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) @@ -38,8 +54,11 @@ func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r case {{ printf "%q" .Method.Name }}: handler = s.{{ .Method.VarName }} {{- end }} -{{- end }} + {{- end }} default: + if !req.HasID { + return + } stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) @@ -48,20 +67,7 @@ func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r } // Call the requested method. - if err := handler(ctx, r, &req, w); err != nil { + if err := handler(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) - return - } - - // A request without an ID receives no response when the method sends one result. - switch req.Method { -{{- range .Endpoints }} - {{- if and .SSE (not .Method.ServerStream) }} - case {{ printf "%q" .Method.Name }}: - if req.ID == nil { - w.WriteHeader(http.StatusNoContent) - } - {{- end }} -{{- end }} } } diff --git a/jsonrpc/codegen/templates/sse_server_stream.go.tpl b/jsonrpc/codegen/templates/sse_server_stream.go.tpl index 1514f6fc8f..1580513e70 100644 --- a/jsonrpc/codegen/templates/sse_server_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream.go.tpl @@ -2,166 +2,62 @@ type {{ .SSE.StructDeclaration.Name }} struct { // {{ sseStreamName }} writes JSON-RPC messages as server-sent events. {{ sseStreamName }} - // requestID identifies the request in the final response. - requestID any - // closed records whether SendAndClose has written the final response. - closed bool - // mu protects closed and view while service code sends results. - mu sync.Mutex {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} - // view is the result view selected for the next event sent by this request. + // view is the result view used to encode later stream values. view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string {{- end }} } {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} -{{ comment "SetView selects the result view used by later sends on this request stream." }} +{{ comment "SetView selects the result view used by later stream values." }} func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { - s.mu.Lock() s.view = view - s.mu.Unlock() } {{- end }} -{{ comment "Send sends a JSON-RPC notification to the client." }} -{{ comment "Notifications do not expect a response from the client." }} -func (s *{{ .SSE.StructDeclaration.Name }}) Send(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.EventDeclaration.Name }}) error { - {{ comment "Reject a send after SendAndClose wrote the final response." }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} - view := s.view - {{- end }} - s.mu.Unlock() +{{ comment .Method.ServerStream.SendDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendName }}(event {{ .SSE.EventTypeRef }}) error { + return s.{{ .Method.ServerStream.SendWithContextName }}(context.Background(), event) +} - {{ comment "Read the service result value from the event." }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } +{{ comment .Method.ServerStream.SendWithContextDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendWithContextName }}(ctx context.Context, event {{ .SSE.EventTypeRef }}) error { + result := event {{- if .Method.ViewedResult }} - body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) - if err != nil { - return err - } - {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Build the JSON body declared for this service result." }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - - {{ comment "Write a notification without a request ID." }} - message := map[string]any{ - "jsonrpc": "2.0", - "method": {{ printf "%q" .Method.Name }}, - "params": body, - } - - return s.sendSSEEvent(ctx, "notification", message) -} - -{{ comment "SendAndClose sends a final JSON-RPC response to the client and closes the stream." }} -{{ comment "The response will include the original request ID unless the result has an ID field populated." }} -{{ comment "After calling this method, no more events can be sent on this stream." }} -func (s *{{ .SSE.StructDeclaration.Name }}) SendAndClose(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.EventDeclaration.Name }}) error { - {{ comment "Reject a second final response." }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + {{- if not .Method.ViewedResult.ViewName }} view := s.view - {{- end }} - s.mu.Unlock() - - {{ comment "Read the service result value from the event." }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) + if view == "" { + view = "default" } - - {{ comment "Start with the ID of the request that opened this stream." }} - var id any = s.requestID - {{- if .Result.IDAttribute }} - {{- if .Result.IDAttributeRequired }} - if result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the result ID when the service supplied one." }} - id = result.{{ .Result.IDAttribute }} - {{ comment "Remove the ID from the result body because the response already contains it." }} - result.{{ .Result.IDAttribute }} = "" + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) } - {{- else }} - if result.{{ .Result.IDAttribute }} != nil && *result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the result ID when the service supplied one." }} - id = *result.{{ .Result.IDAttribute }} - {{ comment "Remove the ID from the result body because the response already contains it." }} - result.{{ .Result.IDAttribute }} = nil - } - {{- end }} {{- end }} - - {{- if .Method.ViewedResult }} body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) if err != nil { return err } - {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Build the JSON body declared for this service result." }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) + {{- if not .Method.ViewedResult.ViewName }} + s.sentView = view + {{- end }} + {{- else if and .SSE.HasResponseBody .SSE.Response (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}(result) {{- else }} body := result {{- end }} - {{ comment "Write the final response with its request ID." }} message := map[string]any{ "jsonrpc": "2.0", - "id": id, - "result": body, + "method": {{ printf "%q" .Method.Name }}, + "params": body, } - - return s.sendSSEEvent(ctx, "response", message) + return s.sendSSEEvent(ctx, "notification", message) } -{{ comment "SendError sends a JSON-RPC error response." }} -func (s *{{ .SSE.StructDeclaration.Name }}) SendError(ctx context.Context, id string, err error) error { - {{- if .Errors }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - switch en.GoaErrorName() { - {{- range $gerr := .Errors }} - {{- range $err := $gerr.Errors }} - case {{ printf "%q" $err.Name }}: - {{- with $err.Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} - {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - {{- else }} - {{ comment "Report request validation failures as invalid parameters and all other failures as internal errors." }} - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} +{{ comment "Close does nothing because the HTTP response closes when the service method returns." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { + return nil } diff --git a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl index 51cf6f87ae..54c060803a 100644 --- a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl @@ -16,11 +16,14 @@ type ( } ) +// Header returns the headers written while the event is being encoded. func (b *{{ .Buffer.Name }}) Header() http.Header { return b.header } -func (b *{{ .Buffer.Name }}) WriteHeader(int) {} +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *{{ .Buffer.Name }}) WriteHeader(int) { +} // initSSEHeaders writes the response headers before the first event. func (s *{{ .Stream.Name }}) initSSEHeaders() { @@ -43,10 +46,8 @@ func (s *{{ .Stream.Name }}) sendSSEEvent(ctx context.Context, eventType string, } s.initSSEHeaders() - if eventType != "" { - if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { - return fmt.Errorf("write server-sent event name: %w", err) - } + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) } if _, err := s.w.Write([]byte("data: ")); err != nil { return fmt.Errorf("write server-sent event data label: %w", err) diff --git a/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl index 9a785d94df..e3e0f1c08e 100644 --- a/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl +++ b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl @@ -1,7 +1,7 @@ {{ printf "%s decodes one JSON-RPC result value with the configured HTTP decoder." .Name | comment }} func {{ .Name }}(decoder func(*http.Response) goahttp.Decoder, data json.RawMessage, target any) error { // A JSON-RPC result is a successful HTTP value even when it arrived inside - // a server-sent event or WebSocket message. + // a server-sent event. resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), diff --git a/jsonrpc/codegen/templates/viewed_result_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl index d87ca05d37..a2dc5d2a49 100644 --- a/jsonrpc/codegen/templates/viewed_result_decode.go.tpl +++ b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl @@ -22,15 +22,15 @@ func {{ .Decode.Name }}(decoder func(*http.Response) goahttp.Decoder, resp *http resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) {{- end }} {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} - projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - viewed := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ Projected: projected, View: view, } - if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}(viewed); err != nil { + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { return nil, err } - return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}(viewed), nil + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}), nil {{- end }} default: return nil, goa.InvalidEnumValueError("view", view, []any{ @@ -43,15 +43,15 @@ func {{ .Decode.Name }}(decoder func(*http.Response) goahttp.Decoder, resp *http resp.Body = io.NopCloser(bytes.NewBuffer(data)) {{- end }} {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} - projected := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - viewed := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ Projected: projected, View: {{ printf "%q" $.FixedView }}, } - if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}(viewed); err != nil { + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { return nil, err } - return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}(viewed), nil + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}), nil {{- end }} {{- end }} } diff --git a/jsonrpc/codegen/templates/viewed_result_encode.go.tpl b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl index b85dec06de..ba24e979d2 100644 --- a/jsonrpc/codegen/templates/viewed_result_encode.go.tpl +++ b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl @@ -10,7 +10,7 @@ func {{ .Encode.Name }}(viewed {{ .ViewedTypeRef }}) (any, error) { {{- if .ServerBody }} {{- if .ServerBody.Init }} res := viewed - body := {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}) + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}) {{- else }} body := viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }} {{- end }} @@ -37,7 +37,7 @@ func {{ .Encode.Name }}(viewed {{ .ViewedTypeRef }}) (any, error) { {{- if .ServerBody }} {{- if .ServerBody.Init }} res := viewed - return {{ .ServerBody.Init.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}), nil + return {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}), nil {{- else }} return viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }}, nil {{- end }} diff --git a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl b/jsonrpc/codegen/templates/websocket_client_conn.go.tpl deleted file mode 100644 index 8794b6fec5..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl +++ /dev/null @@ -1,495 +0,0 @@ -{{/* -websocket_client_conn.go.tpl generates the WebSocket shared by every JSON-RPC -method on one client. One function reads every server message. Each request -gets a unique ID, a timeout, and a function that receives its result or error. -The response ID selects that function. Only one caller writes to the socket at -a time. -*/}} -type ( - // {{ .WebSocketConnection.Name }} keeps the socket, the next request ID, and the - // functions called when waiting requests receive a result or error. - {{ .WebSocketConnection.Name }} struct { - ws *websocket.Conn - - writeMu sync.Mutex - nextID atomic.Uint64 - - stateMu sync.Mutex - pending map[string]*{{ .WebSocketPendingRequest.Name }} - err error - done chan struct{} - - closeOnce sync.Once - closeErr error - - ctx context.Context - config *jsonrpc.StreamConfig - } - - // {{ .WebSocketRequestOwner.Name }} identifies one method stream. closed records that - // Close was called so the stream cannot accept another request. - {{ .WebSocketRequestOwner.Name }} struct { - closed atomic.Bool - } - - // {{ .WebSocketPendingRequest.Name }} stores one request's context, timer, and function - // that receives its result or error. - {{ .WebSocketPendingRequest.Name }} struct { - owner *{{ .WebSocketRequestOwner.Name }} - ctx context.Context - timer *time.Timer - complete func(context.Context, *jsonrpc.RawResponse, error) - } - - // {{ .WebSocketMessage.Name }} keeps enough of an incoming JSON-RPC message to tell a - // server notification from a response, including an explicit null ID. - {{ .WebSocketMessage.Name }} struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *jsonrpc.RawErrorResponse `json:"error,omitempty"` - ID json.RawMessage `json:"id"` - } -) - -// {{ .WebSocketClosedError.Name }} is returned after Close prevents a method -// stream from sending or receiving more messages. -var {{ .WebSocketClosedError.Name }} = errors.New("JSON-RPC WebSocket method stream is closed") - -// {{ .NewWebSocketConnection.Name }} returns a connection that uses config for timeouts and -// error reporting. It also starts readResponses, the only function that reads -// from ws. Socket errors use a context that remains valid after the method that -// opened the socket returns. -func {{ .NewWebSocketConnection.Name }}(ws *websocket.Conn, config *jsonrpc.StreamConfig) *{{ .WebSocketConnection.Name }} { - conn := &{{ .WebSocketConnection.Name }}{ - ws: ws, - pending: make(map[string]*{{ .WebSocketPendingRequest.Name }}), - done: make(chan struct{}), - ctx: context.Background(), - config: config, - } - go conn.readResponses() - return conn -} - -// getConn returns the open WebSocket connection. If no socket exists, it uses -// ctx to open one for all waiting callers. After the socket fails, later calls -// return that error instead of opening a new socket while earlier requests are -// still waiting for results. -func (c *{{ .ClientStructDeclaration.Name }}) getConn(ctx context.Context) (*{{ .WebSocketConnection.Name }}, error) { - for { - c.connMu.Lock() - if c.closed.Load() { - c.connMu.Unlock() - return nil, fmt.Errorf("JSON-RPC WebSocket client is closed") - } - if c.conn != nil { - conn := c.conn - c.connMu.Unlock() - if err := conn.terminalError(); err != nil { - return nil, err - } - return conn, nil - } - if c.connecting != nil { - connecting := c.connecting - c.connMu.Unlock() - select { - case <-connecting: - continue - case <-ctx.Done(): - return nil, ctx.Err() - } - } - connecting := make(chan struct{}) - c.connecting = connecting - c.connMu.Unlock() - return c.connect(ctx, connecting) - } -} - -// connect uses ctx to open and configure one socket. It returns that socket to -// the caller and makes it available to other getConn callers, unless Close ran -// while the dial was in progress. -func (c *{{ .ClientStructDeclaration.Name }}) connect(ctx context.Context, connecting chan struct{}) (*{{ .WebSocketConnection.Name }}, error) { - - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - - {{- $found := false }} - {{- range .Endpoints }} - {{- range .Routes }} - {{- if and (eq .Verb "GET") (ne .Path "/") (not $found) }} - url := wsScheme + "://" + c.host + {{ printf "%q" .Path }} - {{ $found = true }} - {{- end }} - {{- end }} - {{- end }} - {{- if not $found }} - url := wsScheme + "://" + c.host - {{- end }} - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - c.finishConnect(connecting, nil) - return nil, goahttp.ErrRequestError("{{ .Service.Name }}", "connect", err) - } - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - conn := {{ .NewWebSocketConnection.Name }}(ws, c.streamConfig) - if c.finishConnect(connecting, conn) { - return conn, nil - } - err = fmt.Errorf("JSON-RPC WebSocket client closed while connecting") - if closeErr := conn.close(); closeErr != nil { - err = fmt.Errorf("%w; close new connection: %v", err, closeErr) - } - return nil, err -} - -// finishConnect stores conn unless the client closed while the dial was in -// progress. It wakes every getConn caller waiting for the dial and returns -// whether conn was stored. -func (c *{{ .ClientStructDeclaration.Name }}) finishConnect(connecting chan struct{}, conn *{{ .WebSocketConnection.Name }}) bool { - c.connMu.Lock() - accepted := !c.closed.Load() && conn != nil - if accepted { - c.conn = conn - } - c.connecting = nil - c.connMu.Unlock() - close(connecting) - return accepted -} - -// sendRequest stores the function passed in complete, writes request, and -// returns its new ID. It returns an error without writing if ctx is canceled, -// the method stream is closed, or the socket has failed. Its timer calls -// complete with a timeout error even if the caller never calls Recv. -func (c *{{ .WebSocketConnection.Name }}) sendRequest(ctx context.Context, request *jsonrpc.Request, owner *{{ .WebSocketRequestOwner.Name }}, complete func(context.Context, *jsonrpc.RawResponse, error)) (string, error) { - id := strconv.FormatUint(c.nextID.Add(1), 10) - request.ID = id - pending := &{{ .WebSocketPendingRequest.Name }}{ - owner: owner, - ctx: ctx, - complete: complete, - } - - c.writeMu.Lock() - select { - case <-ctx.Done(): - err := ctx.Err() - if owner.closed.Load() { - err = {{ .WebSocketClosedError.Name }} - } - c.writeMu.Unlock() - return "", err - default: - } - c.stateMu.Lock() - switch { - case c.err != nil: - err := c.err - c.stateMu.Unlock() - c.writeMu.Unlock() - return "", err - case owner.closed.Load(): - c.stateMu.Unlock() - c.writeMu.Unlock() - return "", {{ .WebSocketClosedError.Name }} - } - c.pending[id] = pending - pending.timer = time.AfterFunc(c.config.RequestTimeout, func() { - c.timeoutRequest(id) - }) - c.stateMu.Unlock() - err := c.ws.WriteJSON(request) - c.writeMu.Unlock() - if err == nil { - return id, nil - } - - c.removeRequest(id) - err = fmt.Errorf("failed to write JSON-RPC WebSocket request: %w", err) - c.fail(err) - return "", err -} - -// sendNotification waits for the current socket write to finish and then -// writes request without an ID. It returns an error if ctx is canceled, the -// method stream is closed, or the socket write fails. -func (c *{{ .WebSocketConnection.Name }}) sendNotification(ctx context.Context, request *jsonrpc.Request, owner *{{ .WebSocketRequestOwner.Name }}) error { - c.writeMu.Lock() - select { - case <-ctx.Done(): - err := ctx.Err() - if owner.closed.Load() { - err = {{ .WebSocketClosedError.Name }} - } - c.writeMu.Unlock() - return err - default: - } - c.stateMu.Lock() - switch { - case c.err != nil: - err := c.err - c.stateMu.Unlock() - c.writeMu.Unlock() - return err - case owner.closed.Load(): - c.stateMu.Unlock() - c.writeMu.Unlock() - return {{ .WebSocketClosedError.Name }} - } - c.stateMu.Unlock() - err := c.ws.WriteJSON(request) - c.writeMu.Unlock() - if err != nil { - err = fmt.Errorf("failed to write JSON-RPC WebSocket notification: %w", err) - c.fail(err) - return err - } - return nil -} - -// readResponses reads every message from the shared WebSocket. No other -// function reads from that socket. It reports server notifications and uses -// each response ID to find the function that receives the request result. -func (c *{{ .WebSocketConnection.Name }}) readResponses() { - for { - var message {{ .WebSocketMessage.Name }} - if err := c.ws.ReadJSON(&message); err != nil { - c.fail(fmt.Errorf("failed to read JSON-RPC WebSocket message: %w", err)) - return - } - if message.Method != "" { - c.handleIncomingMethod(&message) - continue - } - c.handleIncomingResponse(&message) - } -} - -// handleIncomingMethod reports the name of a server notification. A message -// with an ID, including null, is a server request that this client does not -// support. -func (c *{{ .WebSocketConnection.Name }}) handleIncomingMethod(message *{{ .WebSocketMessage.Name }}) { - if len(message.ID) > 0 { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received unsupported JSON-RPC WebSocket server request %q", message.Method), nil) - return - } - c.handleError(c.ctx, jsonrpc.StreamErrorNotification, fmt.Errorf("received JSON-RPC WebSocket notification %q", message.Method), nil) -} - -// handleIncomingResponse checks the response ID and passes the unchanged -// result or error to the function stored under that ID. -func (c *{{ .WebSocketConnection.Name }}) handleIncomingResponse(message *{{ .WebSocketMessage.Name }}) { - response := &jsonrpc.RawResponse{ - JSONRPC: message.JSONRPC, - Result: message.Result, - Error: message.Error, - } - if len(message.ID) == 0 { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response without an ID"), response) - return - } - if string(message.ID) == "null" { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response with a null ID"), response) - return - } - if err := json.Unmarshal(message.ID, &response.ID); err != nil { - c.handleError(c.ctx, jsonrpc.StreamErrorParsing, fmt.Errorf("decode JSON-RPC WebSocket response ID: %w", err), response) - return - } - id := jsonrpc.IDToString(response.ID) - pending := c.removeRequest(id) - if pending == nil { - c.handleError(c.ctx, jsonrpc.StreamErrorOrphaned, fmt.Errorf("received JSON-RPC WebSocket response for unknown ID %q", id), response) - return - } - pending.complete(pending.ctx, response, nil) -} - -// timeoutRequest removes the request with id, reports its timeout, and calls -// the function waiting for its result even if the method stream has not called -// Recv. It does nothing if a response, cancellation, or close already removed -// the request. -func (c *{{ .WebSocketConnection.Name }}) timeoutRequest(id string) { - c.stateMu.Lock() - pending := c.pending[id] - if pending != nil { - delete(c.pending, id) - } - c.stateMu.Unlock() - if pending == nil { - return - } - err := fmt.Errorf("JSON-RPC WebSocket request timed out after %v", c.config.RequestTimeout) - c.handleError(pending.ctx, jsonrpc.StreamErrorTimeout, err, nil) - pending.complete(pending.ctx, nil, err) -} - -// removeRequest removes one request and stops its timer. It returns nil if a -// response, timeout, cancellation, or close already removed the request. -func (c *{{ .WebSocketConnection.Name }}) removeRequest(id string) *{{ .WebSocketPendingRequest.Name }} { - c.stateMu.Lock() - pending := c.pending[id] - if pending != nil { - delete(c.pending, id) - pending.timer.Stop() - } - c.stateMu.Unlock() - return pending -} - -// cancelRequest removes the request with id and passes err to the function -// waiting for its result. It returns whether it found and canceled the request. -func (c *{{ .WebSocketConnection.Name }}) cancelRequest(id string, err error) bool { - pending := c.removeRequest(id) - if pending == nil { - return false - } - pending.complete(pending.ctx, nil, err) - return true -} - -// closeOwner marks owner closed and passes {{ .WebSocketClosedError.Name }} to -// every request sent by that method stream. Requests from other method streams -// continue on the same socket. -func (c *{{ .WebSocketConnection.Name }}) closeOwner(owner *{{ .WebSocketRequestOwner.Name }}) { - c.stateMu.Lock() - if owner.closed.Swap(true) { - c.stateMu.Unlock() - return - } - var canceled []*{{ .WebSocketPendingRequest.Name }} - for id, pending := range c.pending { - if pending.owner == owner { - delete(c.pending, id) - pending.timer.Stop() - canceled = append(canceled, pending) - } - } - c.stateMu.Unlock() - for _, pending := range canceled { - pending.complete(pending.ctx, nil, {{ .WebSocketClosedError.Name }}) - } -} - -// terminalError returns the error that closed the connection. -func (c *{{ .WebSocketConnection.Name }}) terminalError() error { - c.stateMu.Lock() - defer c.stateMu.Unlock() - return c.err -} - -// fail records the first socket error, closes the socket to interrupt a blocked -// read or write, reports the error, and passes it to every function waiting for -// a request result. Later calls do nothing because the first failure already -// closed the socket. -func (c *{{ .WebSocketConnection.Name }}) fail(err error) { - pending, ended := c.beginEnd(err) - if !ended { - return - } - if closeErr := c.closeSocket(); closeErr != nil { - err = fmt.Errorf("%w; close JSON-RPC WebSocket: %v", err, closeErr) - } - c.finishEnd(err) - c.handleError(c.ctx, jsonrpc.StreamErrorConnection, err, nil) - for _, request := range pending { - request.complete(request.ctx, nil, err) - } -} - -// beginEnd records err and returns every request that was waiting for a -// response. Its boolean result is false if another call already recorded the -// connection error. The caller closes the socket and calls the waiting request -// functions after the shared request map is unlocked. -func (c *{{ .WebSocketConnection.Name }}) beginEnd(err error) ([]*{{ .WebSocketPendingRequest.Name }}, bool) { - c.stateMu.Lock() - defer c.stateMu.Unlock() - if c.err != nil { - return nil, false - } - c.err = err - pending := make([]*{{ .WebSocketPendingRequest.Name }}, 0, len(c.pending)) - for id, request := range c.pending { - delete(c.pending, id) - request.timer.Stop() - pending = append(pending, request) - } - return pending, true -} - -// finishEnd replaces the connection error with err and closes done so Recv -// calls know that the socket has closed. -func (c *{{ .WebSocketConnection.Name }}) finishEnd(err error) { - c.stateMu.Lock() - c.err = err - close(c.done) - c.stateMu.Unlock() -} - -// closeSocket closes ws exactly once and returns the socket close error. It -// does not wait for a current WriteJSON call to finish, because closing the -// network connection must interrupt that blocked write. -func (c *{{ .WebSocketConnection.Name }}) closeSocket() error { - c.closeOnce.Do(func() { - c.closeErr = c.ws.Close() - }) - return c.closeErr -} - -// handleError passes errorType, err, and response to the configured error -// function. Request errors use the request context and socket errors use the -// connection context. Callers must finish changing the shared connection state -// first because user code may call back into the client. -func (c *{{ .WebSocketConnection.Name }}) handleError(ctx context.Context, errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if c.config.ErrorHandler != nil { - c.config.ErrorHandler(ctx, errorType, err, response) - } -} - -// close closes the shared socket, passes a connection-closed error to every -// function waiting for a request result, and returns the socket close error. -func (c *{{ .WebSocketConnection.Name }}) close() error { - err := fmt.Errorf("JSON-RPC WebSocket connection closed") - pending, ended := c.beginEnd(err) - if !ended { - return c.closeSocket() - } - closeErr := c.closeSocket() - c.finishEnd(err) - for _, request := range pending { - request.complete(request.ctx, nil, err) - } - return closeErr -} - -// Close rejects future client calls, closes the shared WebSocket, and returns -// the socket close error. -func (c *{{ .ClientStructDeclaration.Name }}) Close() error { - if c.closed.Swap(true) { - return nil - } - c.connMu.Lock() - conn := c.conn - c.conn = nil - c.connMu.Unlock() - if conn == nil { - return nil - } - return conn.close() -} - -// IsClosed reports whether Close has closed this client. -func (c *{{ .ClientStructDeclaration.Name }}) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl b/jsonrpc/codegen/templates/websocket_client_stream.go.tpl deleted file mode 100644 index 127d3216c8..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl +++ /dev/null @@ -1,281 +0,0 @@ -{{/* -This file writes one JSON-RPC method stream. One shared connection reads and -writes the socket and assigns request IDs. It finds the function waiting for -each response. This stream turns response fields into service results, returns -them in send order, and handles cancellation. -*/}} -{{- $hasRecv := and .RecvName .RecvTypeRef }} -{{- $hasSend := .SendName }} -{{- $isBidirectional := and $hasSend $hasRecv }} -{{- $pendingType := .Pending.Name }} -{{- $resultType := .Result.Name }} -{{ printf "%s implements the %s client stream." .VarDeclaration.Name .Endpoint.Method.Name | comment }} -type ( - {{ .VarDeclaration.Name }} struct { - conn *{{ .Connection.Name }} - owner *{{ .RequestOwner.Name }} - - ctx context.Context - cancel context.CancelFunc - closeOnce sync.Once - - {{- if $hasRecv }} - decoder func(*http.Response) goahttp.Decoder - {{- end }} - {{- if $isBidirectional }} - - sendMu sync.Mutex - pendingMu sync.Mutex - pending []*{{ $pendingType }} - pendingReady chan struct{} - {{- end }} - } - - {{- if $hasRecv }} - // {{ $pendingType }} stores the channel that receives the result or error - // for one request. The shared connection starts and stops its timer. - {{ $pendingType }} struct { - id string - resultChan chan {{ $resultType }} - } - - // {{ $resultType }} contains the decoded result or error returned by one - // request. - {{ $resultType }} struct { - result {{ .RecvTypeRef }} - err error - } - {{- end }} -) - -{{- if $hasSend }} -{{ comment .SendDesc }} -func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { - return s.{{ .SendName }}WithContext(s.ctx, v) -} - -{{ comment .SendWithContextDesc }} -func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}WithContext(ctx context.Context, v {{ .SendTypeRef }}) error { - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - } - {{- if $isBidirectional }} - pending := &{{ $pendingType }}{ - resultChan: make(chan {{ $resultType }}, 1), - } - - s.sendMu.Lock() - id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { - s.completeResponse(ctx, pending, response, err) - }) - if err == nil { - pending.id = id - s.enqueuePending(pending) - } - s.sendMu.Unlock() - if err != nil { - return err - } - return nil - {{- else }} - return s.conn.sendNotification(ctx, request, s.owner) - {{- end }} -} -{{- end }} - -{{- if $hasRecv }} -{{ comment .RecvDesc }} -func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { - return s.{{ .RecvName }}WithContext(s.ctx) -} - -{{ comment .RecvWithContextDesc }} -func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}WithContext(ctx context.Context) ({{ .RecvTypeRef }}, error) { - {{- if $isBidirectional }} - pending, err := s.nextPending(ctx) - if err != nil { - var zero {{ .RecvTypeRef }} - return zero, err - } - return s.awaitPending(ctx, pending) - {{- else }} - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - } - pending := &{{ $pendingType }}{ - resultChan: make(chan {{ $resultType }}, 1), - } - id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { - s.completeResponse(ctx, pending, response, err) - }) - if err != nil { - var zero {{ .RecvTypeRef }} - return zero, err - } - pending.id = id - return s.awaitPending(ctx, pending) - {{- end }} -} - -// awaitPending waits for pending to receive a result or error. It returns the -// closed-stream error if Close runs, even when another cancellation is ready. -func (s *{{ .VarDeclaration.Name }}) awaitPending(ctx context.Context, pending *{{ $pendingType }}) ({{ .RecvTypeRef }}, error) { - for { - if s.owner.closed.Load() { - var zero {{ .RecvTypeRef }} - return zero, {{ .ClosedError.Name }} - } - select { - case result := <-pending.resultChan: - return result.result, s.methodStreamError(result.err) - case <-ctx.Done(): - err := s.methodStreamError(ctx.Err()) - s.conn.cancelRequest(pending.id, err) - var zero {{ .RecvTypeRef }} - return zero, err - case <-s.ctx.Done(): - err := s.methodStreamError(s.ctx.Err()) - s.conn.cancelRequest(pending.id, err) - var zero {{ .RecvTypeRef }} - return zero, err - case <-s.conn.done: - var zero {{ .RecvTypeRef }} - return zero, s.methodStreamError(s.conn.terminalError()) - } - } -} - -// completeResponse turns response into this method's service result, or uses -// err when the request failed, and sends it to the Recv call waiting for pending. -func (s *{{ .VarDeclaration.Name }}) completeResponse(ctx context.Context, pending *{{ $pendingType }}, response *jsonrpc.RawResponse, err error) { - var result {{ $resultType }} - switch { - case err != nil: - result.err = err - case response.Error != nil: - result.err = response.Error - s.conn.handleError(ctx, jsonrpc.StreamErrorProtocol, response.Error, response) - default: - parsedResult, decodeErr := s.decodeResponse(response.Result) - if decodeErr != nil { - result.err = fmt.Errorf("failed to decode JSON-RPC WebSocket response: %w", decodeErr) - s.conn.handleError(ctx, jsonrpc.StreamErrorParsing, result.err, response) - } else { - {{- if .Endpoint.Result.IDAttribute }} - {{- if .Endpoint.Result.IDAttributeRequired }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - parsedResult.{{ .Endpoint.Result.IDAttribute }} = jsonrpc.IDToString(response.ID) - } - {{- else }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == nil || *parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - id := jsonrpc.IDToString(response.ID) - parsedResult.{{ .Endpoint.Result.IDAttribute }} = &id - } - {{- end }} - {{- end }} - result.result = parsedResult - } - } - pending.resultChan <- result -} - -{{- if $isBidirectional }} -// enqueuePending adds pending to the requests waiting for Recv. It keeps their -// send order even when the server responds in a different order. -func (s *{{ .VarDeclaration.Name }}) enqueuePending(pending *{{ $pendingType }}) { - s.pendingMu.Lock() - s.pending = append(s.pending, pending) - if s.owner.closed.Load() { - s.pending = s.pending[:len(s.pending)-1] - s.pendingMu.Unlock() - return - } - s.pendingMu.Unlock() - select { - case s.pendingReady <- struct{}{}: - default: - } -} - -// nextPending returns the first request sent by this method stream that has not -// yet been passed to Recv. It returns an error if the caller cancels, Close -// runs, or the socket fails first. -func (s *{{ .VarDeclaration.Name }}) nextPending(ctx context.Context) (*{{ $pendingType }}, error) { - for { - if s.owner.closed.Load() { - return nil, {{ .ClosedError.Name }} - } - s.pendingMu.Lock() - if len(s.pending) > 0 { - if s.owner.closed.Load() { - s.pendingMu.Unlock() - return nil, {{ .ClosedError.Name }} - } - pending := s.pending[0] - s.pending = s.pending[1:] - s.pendingMu.Unlock() - return pending, nil - } - s.pendingMu.Unlock() - select { - case <-s.pendingReady: - case <-ctx.Done(): - return nil, s.methodStreamError(ctx.Err()) - case <-s.ctx.Done(): - return nil, s.methodStreamError(s.ctx.Err()) - case <-s.conn.done: - return nil, s.methodStreamError(s.conn.terminalError()) - } - } -} -{{- end }} - -// methodStreamError returns the closed-stream error if Close has run. -// Otherwise it returns the supplied err unchanged. -func (s *{{ .VarDeclaration.Name }}) methodStreamError(err error) error { - if s.owner.closed.Load() { - return {{ .ClosedError.Name }} - } - return err -} - -// decodeResponse reads data using this method's response format and returns the -// service result. -func (s *{{ .VarDeclaration.Name }}) decodeResponse(data json.RawMessage) ({{ .RecvTypeRef }}, error) { - {{- if .Endpoint.Method.ViewedResult }} - // The HTTP 200 status tells the configured decoder that this stream item is - // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. - resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} - return {{ viewedDecodeName .Endpoint.Method.Name }}(s.decoder, resp, data) - {{- else }} - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - dec := s.decoder(resp) - var out {{ .RecvTypeRef }} - if err := dec.Decode(&out); err != nil { - var zero {{ .RecvTypeRef }} - return zero, err - } - return out, nil - {{- end }} -} -{{- end }} - -{{ printf "Close closes the %s method stream without closing the WebSocket shared by other methods." .Endpoint.Method.Name | comment }} -func (s *{{ .VarDeclaration.Name }}) Close() error { - s.closeOnce.Do(func() { - s.conn.closeOwner(s.owner) - {{- if $isBidirectional }} - s.pendingMu.Lock() - s.pending = nil - s.pendingMu.Unlock() - {{- end }} - s.cancel() - }) - return nil -} diff --git a/jsonrpc/codegen/templates/websocket_server_close.go.tpl b/jsonrpc/codegen/templates/websocket_server_close.go.tpl deleted file mode 100644 index f26ae52610..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_close.go.tpl +++ /dev/null @@ -1,16 +0,0 @@ -{{ printf "Close asks the %s client to close normally, closes the WebSocket, and returns errors from either operation." .Service.Name | comment }} -func (s *{{ websocketServerStreamName }}) Close() error { - controlErr := s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), - time.Now().Add(time.Second), - ) - if controlErr != nil { - controlErr = fmt.Errorf("write normal WebSocket close message: %w", controlErr) - } - closeErr := s.conn.Close() - if closeErr != nil { - closeErr = fmt.Errorf("close WebSocket connection: %w", closeErr) - } - return errors.Join(controlErr, closeErr) -} diff --git a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl b/jsonrpc/codegen/templates/websocket_server_handler.go.tpl deleted file mode 100644 index d5415b8f31..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl +++ /dev/null @@ -1,28 +0,0 @@ -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &{{ websocketServerStreamName }}{ - {{- range .Endpoints }} - {{ lowerInitial .Method.VarName }}: s.{{ lowerInitial .Method.VarName }}, - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: s.{{ lowerInitial .Method.VarName }}Endpoint, - {{- end }} - {{- end }} - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} diff --git a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl b/jsonrpc/codegen/templates/websocket_server_recv.go.tpl deleted file mode 100644 index 91757fff39..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl +++ /dev/null @@ -1,114 +0,0 @@ -{{ printf "Recv reads JSON-RPC requests from the %s service stream." .Service.Name | comment }} -func (s *{{ websocketServerStreamName }}) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Return an unexpected connection close because no later request can be read. - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - return err - } - - // Report every other read failure as a JSON-RPC parse error. - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // Return when the parse-error response cannot be written to the client. - return fmt.Errorf("failed to send parse error: %w", err) - } - // The next Recv call reads the next request from this connection. - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *{{ websocketServerStreamName }}) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - {{- range .Endpoints }} - case {{ printf "%q" .Method.Name }}: - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // Decode the request fields for this {{ if eq .Method.ServerStream.Kind 3 }}server-streaming{{ else }}bidirectional-streaming{{ end }} call. - {{- if .Payload.Ref }} - payload, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- else }} - _, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- end }} - if err != nil { - return fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - // Give the service a stream that writes responses on this connection - // with the ID from this request. - streamWrapper := &{{ websocketWrapperName .Method.Name }}{ - stream: s, - requestID: req.ID, - {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} - view: {{ printf "%q" .Result.View }}, - {{- end }} - } - // Pass the decoded payload, when present, and this request's stream - // to the service. - endpointInput := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - {{- if .Payload.Ref }} - Payload: payload.({{ .Payload.Ref }}), - {{- end }} - Stream: streamWrapper, - } - if _, err := s.{{ lowerInitial .Method.VarName }}Endpoint(ctx, endpointInput); err != nil { - // Send the service error to callers that supplied a request ID. - if req.HasID { - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // The error response completes this request. The next Recv call - // reads another request from the same connection. - return nil - } - // Notifications have no response, so finish this request without - // writing to the connection. - return nil - } - return nil - {{- else }} - res, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - if err != nil { - // Send the call error only when the caller supplied a request ID. - if req.HasID { - if sendErr := s.SendError(ctx, req.ID, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - } - return nil - } - // A notification has no request ID and receives no response. - if req.HasID { - if res == nil { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - if r, ok := res.({{ printf "*%s.%sResult" .ServicePkgName .Method.VarName }}); ok { - if err := s.Send{{ .Method.VarName }}Response(ctx, req.ID, r); err != nil { - return fmt.Errorf("send response error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - } else { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - } - return nil - {{- end }} - {{- end }} - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} diff --git a/jsonrpc/codegen/templates/websocket_server_send.go.tpl b/jsonrpc/codegen/templates/websocket_server_send.go.tpl deleted file mode 100644 index f61324f8f1..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_send.go.tpl +++ /dev/null @@ -1,92 +0,0 @@ -{{- range .Endpoints }} - {{- if .Result.Ref }} -{{ printf "Send%sNotification sends a JSON-RPC notification for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ websocketServerStreamName }}) Send{{ .Method.VarName }}Notification(ctx context.Context, result {{ .Result.Ref }}{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, view string{{ end }}) error { - {{- if .Method.ViewedResult }} - body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) - if err != nil { - return err - } - {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.writeJSON(jsonrpc.MakeNotification({{ printf "%q" .Method.Name }}, body)) -} - -{{ printf "Send%sResponse sends a JSON-RPC response for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ websocketServerStreamName }}) Send{{ .Method.VarName }}Response(ctx context.Context, id any, result {{ .Result.Ref }}{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, view string{{ end }}) error { - {{- if .Method.ViewedResult }} - body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) - if err != nil { - return err - } - {{- else if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.writeJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - {{- end }} -{{- end }} - - -{{ printf "SendError streams JSON-RPC errors." | comment }} -func (s *{{ websocketServerStreamName }}) SendError(ctx context.Context, id any, err error) error { - {{- if allErrors .JSONRPCServiceSnapshot }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - switch en.GoaErrorName() { - {{- range allErrors .JSONRPCServiceSnapshot }} - case {{ printf "%q" .Name }}: - {{- with .Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - {{- else }} - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} -} - -{{ printf "send writes a JSON-RPC response to the websocket connection." | comment }} -func (s *{{ websocketServerStreamName }}) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.writeJSON(jsonrpc.MakeNotification(method, result)) - } - return s.writeJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -{{ printf "sendError sends a JSON-RPC error response to the websocket connection." | comment }} -func (s *{{ websocketServerStreamName }}) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.writeJSON(response) -} - -{{ printf "writeJSON waits for the current socket write to finish, then writes one JSON-RPC message." | comment }} -func (s *{{ websocketServerStreamName }}) writeJSON(message any) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.conn.WriteJSON(message) -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream.go.tpl deleted file mode 100644 index 1b8b59dd2a..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl +++ /dev/null @@ -1,21 +0,0 @@ -{{ printf "%s implements the Stream interface." (websocketServerStreamName) | comment }} -type {{ websocketServerStreamName }} struct { -{{- range .Endpoints }} - {{ printf "%s decodes requests for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ printf "%sEndpoint is the endpoint for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} -{{- end }} - {{ comment "cancel is the context cancellation function which cancels the request context when invoked." }} - cancel context.CancelFunc - {{ comment "w is the HTTP response writer used in upgrading the connection." }} - w http.ResponseWriter - {{ comment "r is the HTTP request." }} - r *http.Request - {{ comment "conn is the underlying websocket connection." }} - conn *websocket.Conn - {{ comment "writeMu allows only one caller at a time to write a message to conn." }} - writeMu sync.Mutex -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl deleted file mode 100644 index 01a37a5f61..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl +++ /dev/null @@ -1,49 +0,0 @@ -{{- range .Endpoints }} - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} -// {{ websocketWrapperName .Method.Name }} gives this method its request ID and selected result view. -type {{ websocketWrapperName .Method.Name }} struct { - stream *{{ websocketServerStreamName }} - requestID any // Store the JSON-RPC request ID for responses - {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} - viewMu sync.RWMutex - view string - {{- end }} -} - -{{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} -// SetView selects the result view used by later sends for this request. -func (w *{{ websocketWrapperName .Method.Name }}) SetView(view string) { - w.viewMu.Lock() - w.view = view - w.viewMu.Unlock() -} - -// selectedView returns the result view selected for this request. -func (w *{{ websocketWrapperName .Method.Name }}) selectedView() string { - w.viewMu.RLock() - defer w.viewMu.RUnlock() - return w.view -} -{{- end }} - -// SendNotification sends a notification to the client (no response expected). -func (w *{{ websocketWrapperName .Method.Name }}) SendNotification(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Notification(ctx, res{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, w.selectedView(){{ end }}) -} - -// SendResponse sends a response to the client for the original request. -func (w *{{ websocketWrapperName .Method.Name }}) SendResponse(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Response(ctx, w.requestID, res{{ if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }}, w.selectedView(){{ end }}) -} - -// SendError sends an error response to the client. -func (w *{{ websocketWrapperName .Method.Name }}) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *{{ websocketWrapperName .Method.Name }}) Close() error { - return w.stream.Close() -} - {{- end }} -{{- end }} diff --git a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl b/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl deleted file mode 100644 index c1e96500d9..0000000000 --- a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{{ printf "%s identifies the kind of WebSocket stream error." .Type.Name | comment }} -type {{ .Type.Name }} int - -const ( - {{ .Connection.Name }} {{ .Type.Name }} = iota // The WebSocket connection failed. - {{ .Protocol.Name }} // The JSON-RPC message was invalid. - {{ .Parsing.Name }} // The response could not be read. - {{ .Orphaned.Name }} // The response matched no request. - {{ .Timeout.Name }} // The request waited too long. -) - -{{ printf "%s receives WebSocket stream errors." .Handler.Name | comment }} -type {{ .Handler.Name }} func(ctx context.Context, errorType {{ .Type.Name }}, err error, response *jsonrpc.RawResponse) diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden index 91e52fcc27..5a67c060bd 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden @@ -3,92 +3,28 @@ type StreamServerStream struct { // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID identifies the request in the final response. - requestID any - // closed records whether SendAndClose has written the final response. - closed bool - // mu protects closed and view while service code sends results. - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Reject a send after SendAndClose wrote the final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "StreamResult". +func (s *StreamServerStream) Send(event *jsonrpcsseobjectservice.StreamResult) error { + return s.SendWithContext(context.Background(), event) +} - // Read the service result value from the event. - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Build the JSON body declared for this service result. +// SendWithContext streams instances of "StreamResult" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event *jsonrpcsseobjectservice.StreamResult) error { + result := event body := NewStreamResponseBody(result) - // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - return s.sendSSEEvent(ctx, "notification", message) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Reject a second final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Read the service result value from the event. - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Start with the ID of the request that opened this stream. - var id any = s.requestID - if result.ID != nil && *result.ID != "" { - // Use the result ID when the service supplied one. - id = *result.ID - // Remove the ID from the result body because the response already contains it. - result.ID = nil - } - // Build the JSON body declared for this service result. - body := NewStreamResponseBody(result) - - // Write the final response with its request ID. - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent(ctx, "response", message) -} - -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // Report request validation failures as invalid parameters and all other - // failures as internal errors. - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden index 5c9289caa3..11186529fa 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden @@ -3,84 +3,28 @@ type StreamServerStream struct { // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID identifies the request in the final response. - requestID any - // closed records whether SendAndClose has written the final response. - closed bool - // mu protects closed and view while service code sends results. - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Reject a send after SendAndClose wrote the final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "string". +func (s *StreamServerStream) Send(event string) error { + return s.SendWithContext(context.Background(), event) +} - // Read the service result value from the event. - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } +// SendWithContext streams instances of "string" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event string) error { + result := event body := result - // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - return s.sendSSEEvent(ctx, "notification", message) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Reject a second final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Read the service result value from the event. - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Start with the ID of the request that opened this stream. - var id any = s.requestID - body := result - - // Write the final response with its request ID. - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent(ctx, "response", message) -} - -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // Report request validation failures as invalid parameters and all other - // failures as internal errors. - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden deleted file mode 100644 index fdd4c40270..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden +++ /dev/null @@ -1,52 +0,0 @@ -package kitchensink - -import ( - "context" - - chat "generated.local/gen/chat" - "goa.design/clue/log" -) - -// Chat service example implementation. -// The example methods log the requests and return zero values. -type chatsrvc struct{} - -// NewChat returns the Chat service implementation. -func NewChat() chat.Service { - return &chatsrvc{} -} - -// Echo implements echo. -func (s *chatsrvc) Echo(ctx context.Context, p *chat.EchoPayload, stream chat.EchoServerStream) (err error) { - log.Printf(ctx, "chat.echo") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &chat.EchoResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &chat.EchoResult{} - return stream.SendAndClose(ctx, final) - } - return -} - -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *chatsrvc) HandleStream(ctx context.Context, stream chat.Stream) error { - log.Printf(ctx, "chat.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden index b36ce29741..f0b99b05eb 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden @@ -1,16 +1,18 @@ package main import ( + "context" + "flag" "fmt" + "io" "net/http" "time" cli "generated.local/gen/http/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -30,13 +32,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "health": + switch flag.Arg(1) { + case "check": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden index 5463e09276..ce81913622 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden @@ -1,17 +1,19 @@ package main import ( + "context" + "flag" "fmt" + "io" "net/http" "time" + feed "generated.local/gen/feed" cli2 "generated.local/gen/jsonrpc/cli/kitchen_sink" - "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doJSONRPC(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -22,13 +24,6 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, } } - var ( - dialer *websocket.Dialer - ) - { - dialer = websocket.DefaultDialer - } - endpoint, payload, err := cli2.ParseEndpoint( scheme, host, @@ -36,17 +31,40 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, goahttp.RequestEncoder, goahttp.ResponseDecoder, debug, - dialer, - nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func jsonrpcUsageCommands() []string { - return cli2.UsageCommands() + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "ping": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "log": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "feed": + switch flag.Arg(1) { + case "watch": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(feed.WatchClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + case "snapshot": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed JSON-RPC command has no generated result writer") } func jsonrpcUsageExamples() string { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden index c450c8697b..2db5370daa 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden @@ -6,10 +6,9 @@ import ( "errors" "flag" "fmt" + "io" "net/url" "os" - "slices" - "sort" "strings" goa "goa.design/goa/v3/pkg" @@ -64,19 +63,37 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "ping": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "log": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + case "feed": + switch flag.Arg(1) { + case "watch": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "snapshot": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } default: @@ -93,24 +110,55 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "calc (add|ping|log)", + "feed (watch|snapshot)", + "health check", + "mixed lookup", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the kitchen-sink API. Usage: diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden index 66e379e589..7dc70c327f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden @@ -8,17 +8,14 @@ import ( "time" calc "generated.local/gen/calc" - chat "generated.local/gen/chat" feed "generated.local/gen/feed" health "generated.local/gen/health" healthsvr "generated.local/gen/http/health/server" mixedsvr "generated.local/gen/http/mixed/server" calcjssvr "generated.local/gen/jsonrpc/calc/server" - chatjssvr "generated.local/gen/jsonrpc/chat/server" feedjssvr "generated.local/gen/jsonrpc/feed/server" mixedjssvr "generated.local/gen/jsonrpc/mixed/server" mixed "generated.local/gen/mixed" - "github.com/gorilla/websocket" "goa.design/clue/debug" "goa.design/clue/log" goahttp "goa.design/goa/v3/http" @@ -26,7 +23,7 @@ import ( // handleHTTPServer starts configures and starts a HTTP server on the given // URL. It shuts down the server if any error is received in the error channel. -func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, chatSvc chat.Service, chatEndpoints *chat.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { // Provide the transport specific request decoder and response encoder. // The goa http package has built-in support for JSON, XML and gob. // Other encodings can be used by providing the corresponding functions, @@ -57,17 +54,14 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedServer *mixedsvr.Server healthServer *healthsvr.Server calcJSONRPCServer *calcjssvr.Server - chatJSONRPCServer *chatjssvr.Server feedJSONRPCServer *feedjssvr.Server mixedJSONRPCServer *mixedjssvr.Server ) { eh := errorHandler(ctx) - upgrader := &websocket.Upgrader{} mixedServer = mixedsvr.New(mixedEndpoints, mux, dec, enc, eh, nil) healthServer = healthsvr.New(healthEndpoints, mux, dec, enc, eh, nil) calcJSONRPCServer = calcjssvr.New(calcEndpoints, mux, dec, enc, eh) - chatJSONRPCServer = chatjssvr.New(chatSvc.HandleStream, chatEndpoints, mux, dec, enc, eh, upgrader, nil) feedJSONRPCServer = feedjssvr.New(feedEndpoints, mux, dec, enc, eh) mixedJSONRPCServer = mixedjssvr.New(mixedEndpoints, mux, dec, enc, eh) } @@ -76,7 +70,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedsvr.Mount(mux, mixedServer) healthsvr.Mount(mux, healthServer) calcjssvr.Mount(mux, calcJSONRPCServer) - chatjssvr.Mount(mux, chatJSONRPCServer) feedjssvr.Mount(mux, feedJSONRPCServer) mixedjssvr.Mount(mux, mixedJSONRPCServer) @@ -99,9 +92,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End for _, m := range calcJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /rpc", m) } - for _, m := range chatJSONRPCServer.Methods { - log.Printf(ctx, "JSON-RPC method %q mounted on GET /ws/ws", m) - } for _, m := range feedJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /feed", m) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden index 1040da0244..49d67debe1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden @@ -13,7 +13,6 @@ import ( kitchensink "generated.local" calc "generated.local/gen/calc" - chat "generated.local/gen/chat" feed "generated.local/gen/feed" health "generated.local/gen/health" mixed "generated.local/gen/mixed" @@ -48,14 +47,12 @@ func main() { // Initialize the services. var ( calcSvc calc.Service - chatSvc chat.Service feedSvc feed.Service mixedSvc mixed.Service healthSvc health.Service ) { calcSvc = kitchensink.NewCalc() - chatSvc = kitchensink.NewChat() feedSvc = kitchensink.NewFeed() mixedSvc = kitchensink.NewMixed() healthSvc = kitchensink.NewHealth() @@ -65,7 +62,6 @@ func main() { // potentially running in different processes. var ( calcEndpoints *calc.Endpoints - chatEndpoints *chat.Endpoints feedEndpoints *feed.Endpoints mixedEndpoints *mixed.Endpoints healthEndpoints *health.Endpoints @@ -74,9 +70,6 @@ func main() { calcEndpoints = calc.NewEndpoints(calcSvc) calcEndpoints.Use(debug.LogPayloads()) calcEndpoints.Use(log.Endpoint) - chatEndpoints = chat.NewEndpoints(chatSvc) - chatEndpoints.Use(debug.LogPayloads()) - chatEndpoints.Use(log.Endpoint) feedEndpoints = feed.NewEndpoints(feedSvc) feedEndpoints.Use(debug.LogPayloads()) feedEndpoints.Use(log.Endpoint) @@ -127,7 +120,7 @@ func main() { } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "80") } - handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, chatSvc, chatEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) + handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) } default: diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden index d2c06f5951..6b7f16c5b9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden @@ -19,16 +19,11 @@ func NewFeed() feed.Service { // Watch implements watch. func (s *feedsrvc) Watch(ctx context.Context, p *feed.WatchPayload, stream feed.WatchServerStream) (err error) { log.Printf(ctx, "feed.watch") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &feed.WatchResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &feed.WatchResult{} - return stream.SendAndClose(ctx, final) - } + return +} + +// Snapshot implements snapshot. +func (s *feedsrvc) Snapshot(ctx context.Context, p *feed.SnapshotPayload) (res string, err error) { + log.Printf(ctx, "feed.snapshot") return } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden index 1ad12c6838..a4f1eebf6a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -36,18 +37,24 @@ func (c *Client) BuildCheckRequest(ctx context.Context, v any) (*http.Request, e // check endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Health", "check", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -61,7 +68,10 @@ func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBo } return body, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) + } return nil, goahttp.ErrInvalidResponse("Health", "check", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden index 3945edfc8f..56df3687b2 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -53,18 +54,24 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // lookup endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -83,7 +90,10 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB res := NewLookupResultOK(&body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden index 275d9c1f24..922564e6cd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden index 7d2f888e70..d729bd6005 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -63,21 +64,31 @@ func EncodeAddRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re // service add JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "add", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) } @@ -104,8 +115,7 @@ func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody } return nil, NewAddOverflow(&body) default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) @@ -164,21 +174,31 @@ func EncodePingRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.R // service ping JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "ping", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) } @@ -190,8 +210,7 @@ func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBod if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) @@ -251,21 +270,31 @@ func EncodeLogRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re // service log JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "log", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) } @@ -277,8 +306,7 @@ func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index dfaaa043e2..6e88c7eea6 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -44,7 +44,7 @@ type PingResponseBody struct { } // AddOverflowResponseBody is the type of the "Calc" service "add" endpoint -// HTTP response body. +// HTTP response body for the "overflow" error. type AddOverflowResponseBody struct { // Name is the name of this class of errors. Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` @@ -95,7 +95,7 @@ func NewAddResultOK(body *AddResponseBody) *calc.AddResult { // NewAddOverflow builds a Calc service add endpoint overflow error. func NewAddOverflow(body *AddOverflowResponseBody) *goa.ServiceError { - v := &calc.Error{ + v := &goa.ServiceError{ Name: *body.Name, ID: *body.ID, Message: *body.Message, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden index 91c1a38d80..50eec75e95 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden @@ -62,7 +62,7 @@ func New( errhandler: errhandler, } // Install the request handler required by this service's methods. - // ServeHTTP writes one JSON-RPC response for each request. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -81,32 +81,44 @@ func (s *Server) MethodNames() []string { return calc.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -114,11 +126,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -128,36 +140,46 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -181,11 +203,14 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "log", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter joins the responses written for one JSON-RPC batch request. +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -208,18 +233,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Calc service methods. func Mount(mux goahttp.Muxer, h *Server) { - // Every method in this server writes one ordinary JSON-RPC response. + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/rpc", h.ServeHTTP) } @@ -243,13 +270,8 @@ func NewAddHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) @@ -261,35 +283,26 @@ func NewAddHandler( } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { + if req.HasID { var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "overflow": - encodeJSONRPCError(ctx, w, req, -32602, err.Error(), err, encoder, errhandler) - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "overflow": + encodeJSONRPCError(ctx, w, req, -32602, err.Error(), err, encoder, errhandler) + return nil } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any @@ -301,11 +314,6 @@ func NewAddHandler( id = req.ID } - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result // Build the response body with the fields and JSON names declared by the service. body := NewAddResponseBody(res.(*calc.AddResult)) @@ -331,44 +339,24 @@ func NewPingHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") res, err := endpoint(ctx, nil) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any // No ID field in result - use request ID id = req.ID - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result // Build the response body with the fields and JSON names declared by the service. body := NewPingResponseBody(res.(*calc.PingResult)) @@ -395,13 +383,8 @@ func NewLogHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) @@ -414,38 +397,19 @@ func NewLogHandler( } _, err = endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification - if req.ID == nil || req.ID == "" { - // Notification - no response + if !req.HasID { + // A notification has no ID field and receives no response. return nil } - // Request with no result - send empty success response + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -454,14 +418,14 @@ func NewLogHandler( } } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -472,10 +436,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden index 229784602a..c8ea49b12a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden @@ -44,7 +44,7 @@ type PingResponseBody struct { } // AddOverflowResponseBody is the type of the "Calc" service "add" endpoint -// HTTP response body. +// HTTP response body for the "overflow" error. type AddOverflowResponseBody struct { // Name is the name of this class of errors. Name string `form:"name" json:"name" xml:"name"` diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden deleted file mode 100644 index 55aba4ad27..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client CLI support package -// -// Command: -// goa - -package client - -import ( - "encoding/json" - "fmt" - - chat "generated.local/gen/chat" -) - -// BuildEchoPayload builds the payload for the Chat echo endpoint from CLI -// flags. -func BuildEchoPayload(chatEchoBody string) (*chat.EchoPayload, error) { - var err error - var body EchoStreamingBody - { - err = json.Unmarshal([]byte(chatEchoBody), &body) - if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'") - } - } - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v, nil -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden deleted file mode 100644 index a3c79325c8..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden +++ /dev/null @@ -1,583 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat client JSON-RPC transport -// -// Command: -// goa - -package client - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Client lists the Chat service endpoint HTTP clients. -type Client struct { - // Doer is the HTTP client used to make requests to the Chat service. - Doer goahttp.Doer - // RestoreResponseBody controls whether the response bodies are reset after - // decoding so they can be read again. - RestoreResponseBody bool - - scheme string - host string - encoder func(*http.Request) goahttp.Encoder - decoder func(*http.Response) goahttp.Decoder - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.Mutex - conn *websocketClientConn - connecting chan struct{} - closed atomic.Bool - - // streamConfig sets request timeouts and the function called when a - // WebSocket request or connection fails. - streamConfig *jsonrpc.StreamConfig -} - -// NewClient creates HTTP clients for all the Chat service servers. -func NewClient( - scheme string, - host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restoreBody bool, - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, -) *Client { - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - - return &Client{ - Doer: doer, - RestoreResponseBody: restoreBody, - scheme: scheme, - host: host, - decoder: dec, - encoder: enc, - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - } -} - -// Echo returns an endpoint that makes JSON-RPC requests to the Chat service -// echo method. -func (c *Client) Echo() goa.Endpoint { - return func(ctx context.Context, v any) (any, error) { - // The method stream uses the client response reader for each WebSocket result. - decodeResponse := c.decoder - - conn, err := c.getConn(ctx) - if err != nil { - return nil, err - } - - // Closing the method stream cancels this context. - streamCtx, cancel := context.WithCancel(ctx) - - stream := &EchoClientStream{ - conn: conn, - owner: &websocketRequestOwner{}, - ctx: streamCtx, - cancel: cancel, - pendingReady: make(chan struct{}, 1), - decoder: decodeResponse, - } - - return stream, nil - } -} - -type ( - // websocketClientConn keeps the socket, the next request ID, and the - // functions called when waiting requests receive a result or error. - websocketClientConn struct { - ws *websocket.Conn - - writeMu sync.Mutex - nextID atomic.Uint64 - - stateMu sync.Mutex - pending map[string]*websocketPendingRequest - err error - done chan struct{} - - closeOnce sync.Once - closeErr error - - ctx context.Context - config *jsonrpc.StreamConfig - } - - // websocketRequestOwner identifies one method stream. closed records that - // Close was called so the stream cannot accept another request. - websocketRequestOwner struct { - closed atomic.Bool - } - - // websocketPendingRequest stores one request's context, timer, and function - // that receives its result or error. - websocketPendingRequest struct { - owner *websocketRequestOwner - ctx context.Context - timer *time.Timer - complete func(context.Context, *jsonrpc.RawResponse, error) - } - - // websocketMessage keeps enough of an incoming JSON-RPC message to tell a - // server notification from a response, including an explicit null ID. - websocketMessage struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *jsonrpc.RawErrorResponse `json:"error,omitempty"` - ID json.RawMessage `json:"id"` - } -) - -// errWebsocketMethodStreamClosed is returned after Close prevents a method -// stream from sending or receiving more messages. -var errWebsocketMethodStreamClosed = errors.New("JSON-RPC WebSocket method stream is closed") - -// newWebsocketClientConn returns a connection that uses config for timeouts and -// error reporting. It also starts readResponses, the only function that reads -// from ws. Socket errors use a context that remains valid after the method that -// opened the socket returns. -func newWebsocketClientConn(ws *websocket.Conn, config *jsonrpc.StreamConfig) *websocketClientConn { - conn := &websocketClientConn{ - ws: ws, - pending: make(map[string]*websocketPendingRequest), - done: make(chan struct{}), - ctx: context.Background(), - config: config, - } - go conn.readResponses() - return conn -} - -// getConn returns the open WebSocket connection. If no socket exists, it uses -// ctx to open one for all waiting callers. After the socket fails, later calls -// return that error instead of opening a new socket while earlier requests are -// still waiting for results. -func (c *Client) getConn(ctx context.Context) (*websocketClientConn, error) { - for { - c.connMu.Lock() - if c.closed.Load() { - c.connMu.Unlock() - return nil, fmt.Errorf("JSON-RPC WebSocket client is closed") - } - if c.conn != nil { - conn := c.conn - c.connMu.Unlock() - if err := conn.terminalError(); err != nil { - return nil, err - } - return conn, nil - } - if c.connecting != nil { - connecting := c.connecting - c.connMu.Unlock() - select { - case <-connecting: - continue - case <-ctx.Done(): - return nil, ctx.Err() - } - } - connecting := make(chan struct{}) - c.connecting = connecting - c.connMu.Unlock() - return c.connect(ctx, connecting) - } -} - -// connect uses ctx to open and configure one socket. It returns that socket to -// the caller and makes it available to other getConn callers, unless Close ran -// while the dial was in progress. -func (c *Client) connect(ctx context.Context, connecting chan struct{}) (*websocketClientConn, error) { - - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - url := wsScheme + "://" + c.host + "/ws/ws" - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - c.finishConnect(connecting, nil) - return nil, goahttp.ErrRequestError("Chat", "connect", err) - } - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - conn := newWebsocketClientConn(ws, c.streamConfig) - if c.finishConnect(connecting, conn) { - return conn, nil - } - err = fmt.Errorf("JSON-RPC WebSocket client closed while connecting") - if closeErr := conn.close(); closeErr != nil { - err = fmt.Errorf("%w; close new connection: %v", err, closeErr) - } - return nil, err -} - -// finishConnect stores conn unless the client closed while the dial was in -// progress. It wakes every getConn caller waiting for the dial and returns -// whether conn was stored. -func (c *Client) finishConnect(connecting chan struct{}, conn *websocketClientConn) bool { - c.connMu.Lock() - accepted := !c.closed.Load() && conn != nil - if accepted { - c.conn = conn - } - c.connecting = nil - c.connMu.Unlock() - close(connecting) - return accepted -} - -// sendRequest stores the function passed in complete, writes request, and -// returns its new ID. It returns an error without writing if ctx is canceled, -// the method stream is closed, or the socket has failed. Its timer calls -// complete with a timeout error even if the caller never calls Recv. -func (c *websocketClientConn) sendRequest(ctx context.Context, request *jsonrpc.Request, owner *websocketRequestOwner, complete func(context.Context, *jsonrpc.RawResponse, error)) (string, error) { - id := strconv.FormatUint(c.nextID.Add(1), 10) - request.ID = id - pending := &websocketPendingRequest{ - owner: owner, - ctx: ctx, - complete: complete, - } - - c.writeMu.Lock() - select { - case <-ctx.Done(): - err := ctx.Err() - if owner.closed.Load() { - err = errWebsocketMethodStreamClosed - } - c.writeMu.Unlock() - return "", err - default: - } - c.stateMu.Lock() - switch { - case c.err != nil: - err := c.err - c.stateMu.Unlock() - c.writeMu.Unlock() - return "", err - case owner.closed.Load(): - c.stateMu.Unlock() - c.writeMu.Unlock() - return "", errWebsocketMethodStreamClosed - } - c.pending[id] = pending - pending.timer = time.AfterFunc(c.config.RequestTimeout, func() { - c.timeoutRequest(id) - }) - c.stateMu.Unlock() - err := c.ws.WriteJSON(request) - c.writeMu.Unlock() - if err == nil { - return id, nil - } - - c.removeRequest(id) - err = fmt.Errorf("failed to write JSON-RPC WebSocket request: %w", err) - c.fail(err) - return "", err -} - -// sendNotification waits for the current socket write to finish and then -// writes request without an ID. It returns an error if ctx is canceled, the -// method stream is closed, or the socket write fails. -func (c *websocketClientConn) sendNotification(ctx context.Context, request *jsonrpc.Request, owner *websocketRequestOwner) error { - c.writeMu.Lock() - select { - case <-ctx.Done(): - err := ctx.Err() - if owner.closed.Load() { - err = errWebsocketMethodStreamClosed - } - c.writeMu.Unlock() - return err - default: - } - c.stateMu.Lock() - switch { - case c.err != nil: - err := c.err - c.stateMu.Unlock() - c.writeMu.Unlock() - return err - case owner.closed.Load(): - c.stateMu.Unlock() - c.writeMu.Unlock() - return errWebsocketMethodStreamClosed - } - c.stateMu.Unlock() - err := c.ws.WriteJSON(request) - c.writeMu.Unlock() - if err != nil { - err = fmt.Errorf("failed to write JSON-RPC WebSocket notification: %w", err) - c.fail(err) - return err - } - return nil -} - -// readResponses reads every message from the shared WebSocket. No other -// function reads from that socket. It reports server notifications and uses -// each response ID to find the function that receives the request result. -func (c *websocketClientConn) readResponses() { - for { - var message websocketMessage - if err := c.ws.ReadJSON(&message); err != nil { - c.fail(fmt.Errorf("failed to read JSON-RPC WebSocket message: %w", err)) - return - } - if message.Method != "" { - c.handleIncomingMethod(&message) - continue - } - c.handleIncomingResponse(&message) - } -} - -// handleIncomingMethod reports the name of a server notification. A message -// with an ID, including null, is a server request that this client does not -// support. -func (c *websocketClientConn) handleIncomingMethod(message *websocketMessage) { - if len(message.ID) > 0 { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received unsupported JSON-RPC WebSocket server request %q", message.Method), nil) - return - } - c.handleError(c.ctx, jsonrpc.StreamErrorNotification, fmt.Errorf("received JSON-RPC WebSocket notification %q", message.Method), nil) -} - -// handleIncomingResponse checks the response ID and passes the unchanged -// result or error to the function stored under that ID. -func (c *websocketClientConn) handleIncomingResponse(message *websocketMessage) { - response := &jsonrpc.RawResponse{ - JSONRPC: message.JSONRPC, - Result: message.Result, - Error: message.Error, - } - if len(message.ID) == 0 { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response without an ID"), response) - return - } - if string(message.ID) == "null" { - c.handleError(c.ctx, jsonrpc.StreamErrorProtocol, fmt.Errorf("received JSON-RPC WebSocket response with a null ID"), response) - return - } - if err := json.Unmarshal(message.ID, &response.ID); err != nil { - c.handleError(c.ctx, jsonrpc.StreamErrorParsing, fmt.Errorf("decode JSON-RPC WebSocket response ID: %w", err), response) - return - } - id := jsonrpc.IDToString(response.ID) - pending := c.removeRequest(id) - if pending == nil { - c.handleError(c.ctx, jsonrpc.StreamErrorOrphaned, fmt.Errorf("received JSON-RPC WebSocket response for unknown ID %q", id), response) - return - } - pending.complete(pending.ctx, response, nil) -} - -// timeoutRequest removes the request with id, reports its timeout, and calls -// the function waiting for its result even if the method stream has not called -// Recv. It does nothing if a response, cancellation, or close already removed -// the request. -func (c *websocketClientConn) timeoutRequest(id string) { - c.stateMu.Lock() - pending := c.pending[id] - if pending != nil { - delete(c.pending, id) - } - c.stateMu.Unlock() - if pending == nil { - return - } - err := fmt.Errorf("JSON-RPC WebSocket request timed out after %v", c.config.RequestTimeout) - c.handleError(pending.ctx, jsonrpc.StreamErrorTimeout, err, nil) - pending.complete(pending.ctx, nil, err) -} - -// removeRequest removes one request and stops its timer. It returns nil if a -// response, timeout, cancellation, or close already removed the request. -func (c *websocketClientConn) removeRequest(id string) *websocketPendingRequest { - c.stateMu.Lock() - pending := c.pending[id] - if pending != nil { - delete(c.pending, id) - pending.timer.Stop() - } - c.stateMu.Unlock() - return pending -} - -// cancelRequest removes the request with id and passes err to the function -// waiting for its result. It returns whether it found and canceled the request. -func (c *websocketClientConn) cancelRequest(id string, err error) bool { - pending := c.removeRequest(id) - if pending == nil { - return false - } - pending.complete(pending.ctx, nil, err) - return true -} - -// closeOwner marks owner closed and passes errWebsocketMethodStreamClosed to -// every request sent by that method stream. Requests from other method streams -// continue on the same socket. -func (c *websocketClientConn) closeOwner(owner *websocketRequestOwner) { - c.stateMu.Lock() - if owner.closed.Swap(true) { - c.stateMu.Unlock() - return - } - var canceled []*websocketPendingRequest - for id, pending := range c.pending { - if pending.owner == owner { - delete(c.pending, id) - pending.timer.Stop() - canceled = append(canceled, pending) - } - } - c.stateMu.Unlock() - for _, pending := range canceled { - pending.complete(pending.ctx, nil, errWebsocketMethodStreamClosed) - } -} - -// terminalError returns the error that closed the connection. -func (c *websocketClientConn) terminalError() error { - c.stateMu.Lock() - defer c.stateMu.Unlock() - return c.err -} - -// fail records the first socket error, closes the socket to interrupt a blocked -// read or write, reports the error, and passes it to every function waiting for -// a request result. Later calls do nothing because the first failure already -// closed the socket. -func (c *websocketClientConn) fail(err error) { - pending, ended := c.beginEnd(err) - if !ended { - return - } - if closeErr := c.closeSocket(); closeErr != nil { - err = fmt.Errorf("%w; close JSON-RPC WebSocket: %v", err, closeErr) - } - c.finishEnd(err) - c.handleError(c.ctx, jsonrpc.StreamErrorConnection, err, nil) - for _, request := range pending { - request.complete(request.ctx, nil, err) - } -} - -// beginEnd records err and returns every request that was waiting for a -// response. Its boolean result is false if another call already recorded the -// connection error. The caller closes the socket and calls the waiting request -// functions after the shared request map is unlocked. -func (c *websocketClientConn) beginEnd(err error) ([]*websocketPendingRequest, bool) { - c.stateMu.Lock() - defer c.stateMu.Unlock() - if c.err != nil { - return nil, false - } - c.err = err - pending := make([]*websocketPendingRequest, 0, len(c.pending)) - for id, request := range c.pending { - delete(c.pending, id) - request.timer.Stop() - pending = append(pending, request) - } - return pending, true -} - -// finishEnd replaces the connection error with err and closes done so Recv -// calls know that the socket has closed. -func (c *websocketClientConn) finishEnd(err error) { - c.stateMu.Lock() - c.err = err - close(c.done) - c.stateMu.Unlock() -} - -// closeSocket closes ws exactly once and returns the socket close error. It -// does not wait for a current WriteJSON call to finish, because closing the -// network connection must interrupt that blocked write. -func (c *websocketClientConn) closeSocket() error { - c.closeOnce.Do(func() { - c.closeErr = c.ws.Close() - }) - return c.closeErr -} - -// handleError passes errorType, err, and response to the configured error -// function. Request errors use the request context and socket errors use the -// connection context. Callers must finish changing the shared connection state -// first because user code may call back into the client. -func (c *websocketClientConn) handleError(ctx context.Context, errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if c.config.ErrorHandler != nil { - c.config.ErrorHandler(ctx, errorType, err, response) - } -} - -// close closes the shared socket, passes a connection-closed error to every -// function waiting for a request result, and returns the socket close error. -func (c *websocketClientConn) close() error { - err := fmt.Errorf("JSON-RPC WebSocket connection closed") - pending, ended := c.beginEnd(err) - if !ended { - return c.closeSocket() - } - closeErr := c.closeSocket() - c.finishEnd(err) - for _, request := range pending { - request.complete(request.ctx, nil, err) - } - return closeErr -} - -// Close rejects future client calls, closes the shared WebSocket, and returns -// the socket close error. -func (c *Client) Close() error { - if c.closed.Swap(true) { - return nil - } - c.connMu.Lock() - conn := c.conn - c.conn = nil - c.connMu.Unlock() - if conn == nil { - return nil - } - return conn.close() -} - -// IsClosed reports whether Close has closed this client. -func (c *Client) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden deleted file mode 100644 index 45f0e4eec4..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client encoders and decoders -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "io" - "net/http" - "net/url" - - chat "generated.local/gen/chat" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// BuildEchoRequest instantiates a HTTP request object with method and path set -// to call the "Chat" service "echo" endpoint -func (c *Client) BuildEchoRequest(ctx context.Context, v any) (*http.Request, error) { - scheme := c.scheme - switch c.scheme { - case "http": - scheme = "ws" - case "https": - scheme = "wss" - } - u := &url.URL{Scheme: scheme, Host: c.host, Path: EchoChatPath()} - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, goahttp.ErrInvalidURL("Chat", "echo", u.String(), err) - } - if ctx != nil { - req = req.WithContext(ctx) - } - - return req, nil -} - -// EncodeEchoRequest returns an encoder for requests sent to the Chat echo -// server. -func EncodeEchoRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { - p, ok := v.(*chat.EchoPayload) - if !ok { - return goahttp.ErrInvalidType("Chat", "echo", "*chat.EchoPayload", v) - } - b := NewEchoStreamingBody(p) - body := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: b, - } - if p.ID != nil && *p.ID != "" { - body.ID = p.ID - } - // If ID is nil or empty, this is a notification - no ID field - if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Chat", "echo", err) - } - return nil - } -} - -// DecodeEchoResponse returns a decoder for responses returned by the Chat -// service echo JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodeEchoResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - - var jresp jsonrpc.RawResponse - if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - - if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - } - resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) - var ( - body EchoResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - res := NewEchoResultOK(&body) - return res, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden deleted file mode 100644 index 8122b4ec17..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package client - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden deleted file mode 100644 index 8942c9dcc0..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client types -// -// Command: -// goa - -package client - -import ( - chat "generated.local/gen/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoStreamingBody builds the HTTP request body from the payload of the -// "echo" endpoint of the "Chat" service. -func NewEchoStreamingBody(p *chat.EchoPayload) *EchoStreamingBody { - body := &EchoStreamingBody{ - ID: p.ID, - Msg: p.Msg, - } - return body -} - -// NewEchoResultOK builds a "Chat" service "echo" endpoint result from a HTTP -// "OK" response. -func NewEchoResultOK(body *EchoResponseBody) *chat.EchoResult { - v := &chat.EchoResult{ - ID: body.ID, - Echo: body.Echo, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden deleted file mode 100644 index 27ec78741e..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden +++ /dev/null @@ -1,258 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket JSON-RPC client -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "sync" - - chat "generated.local/gen/chat" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// StreamErrorType identifies the kind of WebSocket stream error. -type StreamErrorType int - -const ( - StreamErrorConnection StreamErrorType = iota // The WebSocket connection failed. - StreamErrorProtocol // The JSON-RPC message was invalid. - StreamErrorParsing // The response could not be read. - StreamErrorOrphaned // The response matched no request. - StreamErrorTimeout // The request waited too long. -) - -// StreamErrorHandler receives WebSocket stream errors. -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) - -// EchoClientStream implements the echo client stream. -type ( - EchoClientStream struct { - conn *websocketClientConn - owner *websocketRequestOwner - - ctx context.Context - cancel context.CancelFunc - closeOnce sync.Once - decoder func(*http.Response) goahttp.Decoder - - sendMu sync.Mutex - pendingMu sync.Mutex - pending []*echoClientStreamPendingRequest - pendingReady chan struct{} - } - // echoClientStreamPendingRequest stores the channel that receives the result or error - // for one request. The shared connection starts and stops its timer. - echoClientStreamPendingRequest struct { - id string - resultChan chan echoClientStreamStreamResult - } - - // echoClientStreamStreamResult contains the decoded result or error returned by one - // request. - echoClientStreamStreamResult struct { - result *chat.EchoResult - err error - } -) - -// Send streams instances of "chat.EchoPayload" to the "echo" endpoint -// websocket connection. -func (s *EchoClientStream) Send(v *chat.EchoPayload) error { - return s.SendWithContext(s.ctx, v) -} - -// SendWithContext streams instances of "chat.EchoPayload" to the "echo" -// endpoint websocket connection with context. -func (s *EchoClientStream) SendWithContext(ctx context.Context, v *chat.EchoPayload) error { - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: v, - } - pending := &echoClientStreamPendingRequest{ - resultChan: make(chan echoClientStreamStreamResult, 1), - } - - s.sendMu.Lock() - id, err := s.conn.sendRequest(ctx, request, s.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { - s.completeResponse(ctx, pending, response, err) - }) - if err == nil { - pending.id = id - s.enqueuePending(pending) - } - s.sendMu.Unlock() - if err != nil { - return err - } - return nil -} - -// Recv reads instances of "chat.EchoResult" from the "echo" endpoint websocket -// connection. -func (s *EchoClientStream) Recv() (*chat.EchoResult, error) { - return s.RecvWithContext(s.ctx) -} - -// RecvWithContext reads instances of "chat.EchoResult" from the "echo" -// endpoint websocket connection with context. -func (s *EchoClientStream) RecvWithContext(ctx context.Context) (*chat.EchoResult, error) { - pending, err := s.nextPending(ctx) - if err != nil { - var zero *chat.EchoResult - return zero, err - } - return s.awaitPending(ctx, pending) -} - -// awaitPending waits for pending to receive a result or error. It returns the -// closed-stream error if Close runs, even when another cancellation is ready. -func (s *EchoClientStream) awaitPending(ctx context.Context, pending *echoClientStreamPendingRequest) (*chat.EchoResult, error) { - for { - if s.owner.closed.Load() { - var zero *chat.EchoResult - return zero, errWebsocketMethodStreamClosed - } - select { - case result := <-pending.resultChan: - return result.result, s.methodStreamError(result.err) - case <-ctx.Done(): - err := s.methodStreamError(ctx.Err()) - s.conn.cancelRequest(pending.id, err) - var zero *chat.EchoResult - return zero, err - case <-s.ctx.Done(): - err := s.methodStreamError(s.ctx.Err()) - s.conn.cancelRequest(pending.id, err) - var zero *chat.EchoResult - return zero, err - case <-s.conn.done: - var zero *chat.EchoResult - return zero, s.methodStreamError(s.conn.terminalError()) - } - } -} - -// completeResponse turns response into this method's service result, or uses -// err when the request failed, and sends it to the Recv call waiting for pending. -func (s *EchoClientStream) completeResponse(ctx context.Context, pending *echoClientStreamPendingRequest, response *jsonrpc.RawResponse, err error) { - var result echoClientStreamStreamResult - switch { - case err != nil: - result.err = err - case response.Error != nil: - result.err = response.Error - s.conn.handleError(ctx, jsonrpc.StreamErrorProtocol, response.Error, response) - default: - parsedResult, decodeErr := s.decodeResponse(response.Result) - if decodeErr != nil { - result.err = fmt.Errorf("failed to decode JSON-RPC WebSocket response: %w", decodeErr) - s.conn.handleError(ctx, jsonrpc.StreamErrorParsing, result.err, response) - } else { - if parsedResult.ID == nil || *parsedResult.ID == "" { - id := jsonrpc.IDToString(response.ID) - parsedResult.ID = &id - } - result.result = parsedResult - } - } - pending.resultChan <- result -} - -// enqueuePending adds pending to the requests waiting for Recv. It keeps their -// send order even when the server responds in a different order. -func (s *EchoClientStream) enqueuePending(pending *echoClientStreamPendingRequest) { - s.pendingMu.Lock() - s.pending = append(s.pending, pending) - if s.owner.closed.Load() { - s.pending = s.pending[:len(s.pending)-1] - s.pendingMu.Unlock() - return - } - s.pendingMu.Unlock() - select { - case s.pendingReady <- struct{}{}: - default: - } -} - -// nextPending returns the first request sent by this method stream that has not -// yet been passed to Recv. It returns an error if the caller cancels, Close -// runs, or the socket fails first. -func (s *EchoClientStream) nextPending(ctx context.Context) (*echoClientStreamPendingRequest, error) { - for { - if s.owner.closed.Load() { - return nil, errWebsocketMethodStreamClosed - } - s.pendingMu.Lock() - if len(s.pending) > 0 { - if s.owner.closed.Load() { - s.pendingMu.Unlock() - return nil, errWebsocketMethodStreamClosed - } - pending := s.pending[0] - s.pending = s.pending[1:] - s.pendingMu.Unlock() - return pending, nil - } - s.pendingMu.Unlock() - select { - case <-s.pendingReady: - case <-ctx.Done(): - return nil, s.methodStreamError(ctx.Err()) - case <-s.ctx.Done(): - return nil, s.methodStreamError(s.ctx.Err()) - case <-s.conn.done: - return nil, s.methodStreamError(s.conn.terminalError()) - } - } -} - -// methodStreamError returns the closed-stream error if Close has run. -// Otherwise it returns the supplied err unchanged. -func (s *EchoClientStream) methodStreamError(err error) error { - if s.owner.closed.Load() { - return errWebsocketMethodStreamClosed - } - return err -} - -// decodeResponse reads data using this method's response format and returns the -// service result. -func (s *EchoClientStream) decodeResponse(data json.RawMessage) (*chat.EchoResult, error) { - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - dec := s.decoder(resp) - var out *chat.EchoResult - if err := dec.Decode(&out); err != nil { - var zero *chat.EchoResult - return zero, err - } - return out, nil -} - -// Close closes the echo method stream without closing the WebSocket shared by -// other methods. -func (s *EchoClientStream) Close() error { - s.closeOnce.Do(func() { - s.conn.closeOwner(s.owner) - s.pendingMu.Lock() - s.pending = nil - s.pendingMu.Unlock() - s.cancel() - }) - return nil -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden deleted file mode 100644 index 9b99f1e7b4..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server encoders and decoders -// -// Command: -// goa - -package server - -import ( - "bytes" - "errors" - "io" - "net/http" - - chat "generated.local/gen/chat" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// DecodeEchoRequest returns a decoder for requests sent to the Chat echo -// endpoint. -func DecodeEchoRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - return func(r *http.Request, req *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - r.Body = io.NopCloser(bytes.NewReader(req.Params)) - var payload *chat.EchoPayload - var ( - body EchoStreamingBody - err error - ) - err = decoder(r).Decode(&body) - if err != nil { - if errors.Is(err, io.EOF) { - return payload, goa.MissingPayloadError() - } - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) - } - payload = NewEchoPayload(&body) - - return payload, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden deleted file mode 100644 index b2ebfec66b..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package server - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden deleted file mode 100644 index af64190b3d..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden +++ /dev/null @@ -1,140 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server -// -// Command: -// goa - -package server - -import ( - "context" - "fmt" - "net/http" - - chat "generated.local/gen/chat" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Server handles JSON-RPC requests for the Chat service. -type Server struct { - http.Handler - // Methods is the list of methods served by this server. - Methods []string - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, chat.Stream) error - - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - echoEndpoint goa.Endpoint - - decoder func(*http.Request) goahttp.Decoder - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder - errhandler func(context.Context, http.ResponseWriter, error) - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -} - -// New creates a JSON-RPC server which loads HTTP requests and calls the "Chat" -// service methods. -func New( - streamHandler func(context.Context, chat.Stream) error, - endpoints *chat.Endpoints, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, - errhandler func(context.Context, http.ResponseWriter, error), - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, -) *Server { - s := &Server{ - Methods: []string{ - "echo", - }, - StreamHandler: streamHandler, - echo: NewEchoHandler(endpoints.Echo, mux, decoder), - echoEndpoint: endpoints.Echo, - decoder: decoder, - encoder: encoder, - errhandler: errhandler, - upgrader: upgrader, - configfn: configfn, - } - // Install the request handler required by this service's methods. - // ServeHTTP changes the HTTP connection to a WebSocket connection. - s.Handler = http.HandlerFunc(s.ServeHTTP) - return s -} - -// Service returns the name of the service served. -func (s *Server) Service() string { return "Chat" } - -// Use wraps the server handlers with the given middleware. -func (s *Server) Use(m func(http.Handler) http.Handler) { - s.Handler = m(s.Handler) -} - -// MethodNames returns the methods served. -func (s *Server) MethodNames() []string { return chat.MethodNames[:] } - -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &chatStream{ - echo: s.echo, - echoEndpoint: s.echoEndpoint, - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func Mount(mux goahttp.Muxer, h *Server) { - // Every method in this server writes one ordinary JSON-RPC response. - mux.Handle("GET", "/ws/ws", h.ServeHTTP) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func (s *Server) Mount(mux goahttp.Muxer) { - Mount(mux, s) -} - -// NewEchoHandler creates a JSON-RPC handler which calls the "Chat" service -// "echo" endpoint. -func NewEchoHandler( - endpoint goa.Endpoint, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, -) func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) { - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest) (any, error) { - ctx = context.WithValue(ctx, goa.MethodKey, "echo") - ctx = context.WithValue(ctx, goa.ServiceKey, "Chat") - decodeParams := DecodeEchoRequest(mux, decoder) - params, err := decodeParams(r, req) - if err != nil { - return nil, err - } - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.ID = &idStr - } - // For bidirectional streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - return params, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden deleted file mode 100644 index 1c2dc5e069..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server types -// -// Command: -// goa - -package server - -import ( - chat "generated.local/gen/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoResponseBody builds the HTTP response body from the result of the -// "echo" endpoint of the "Chat" service. -func NewEchoResponseBody(res *chat.EchoResult) *EchoResponseBody { - body := &EchoResponseBody{ - ID: res.ID, - Echo: res.Echo, - } - return body -} - -// NewEchoPayload builds a Chat service echo endpoint payload. -func NewEchoPayload(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} - -// NewEchoStreamingBody builds a Chat service echo endpoint payload. -func NewEchoStreamingBody(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden deleted file mode 100644 index 4858e45f44..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden +++ /dev/null @@ -1,208 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket server streaming -// -// Command: -// goa - -package server - -import ( - "context" - "errors" - "fmt" - "net/http" - "sync" - "time" - - chat "generated.local/gen/chat" - "github.com/gorilla/websocket" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// chatStream implements the Stream interface. -type chatStream struct { - // echo decodes requests for the echo method - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - // echoEndpoint is the endpoint for the echo method - echoEndpoint goa.Endpoint - // cancel is the context cancellation function which cancels the request - // context when invoked. - cancel context.CancelFunc - // w is the HTTP response writer used in upgrading the connection. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // conn is the underlying websocket connection. - conn *websocket.Conn - // writeMu allows only one caller at a time to write a message to conn. - writeMu sync.Mutex -} - -// echoStreamWrapper gives this method its request ID and selected result view. -type echoStreamWrapper struct { - stream *chatStream - requestID any // Store the JSON-RPC request ID for responses -} - -// SendNotification sends a notification to the client (no response expected). -func (w *echoStreamWrapper) SendNotification(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoNotification(ctx, res) -} - -// SendResponse sends a response to the client for the original request. -func (w *echoStreamWrapper) SendResponse(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoResponse(ctx, w.requestID, res) -} - -// SendError sends an error response to the client. -func (w *echoStreamWrapper) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *echoStreamWrapper) Close() error { - return w.stream.Close() -} - -// SendEchoNotification sends a JSON-RPC notification for the echo method. -func (s *chatStream) SendEchoNotification(ctx context.Context, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.writeJSON(jsonrpc.MakeNotification("echo", body)) -} - -// SendEchoResponse sends a JSON-RPC response for the echo method. -func (s *chatStream) SendEchoResponse(ctx context.Context, id any, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.writeJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - -// SendError streams JSON-RPC errors. -func (s *chatStream) SendError(ctx context.Context, id any, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) -} - -// send writes a JSON-RPC response to the websocket connection. -func (s *chatStream) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.writeJSON(jsonrpc.MakeNotification(method, result)) - } - return s.writeJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -// sendError sends a JSON-RPC error response to the websocket connection. -func (s *chatStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.writeJSON(response) -} - -// writeJSON waits for the current socket write to finish, then writes one -// JSON-RPC message. -func (s *chatStream) writeJSON(message any) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.conn.WriteJSON(message) -} - -// Recv reads JSON-RPC requests from the Chat service stream. -func (s *chatStream) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Return an unexpected connection close because no later request can be read. - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - return err - } - - // Report every other read failure as a JSON-RPC parse error. - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // Return when the parse-error response cannot be written to the client. - return fmt.Errorf("failed to send parse error: %w", err) - } - // The next Recv call reads the next request from this connection. - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *chatStream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - case "echo": - // Decode the request fields for this bidirectional-streaming call. - payload, err := s.echo(ctx, s.r, req) - if err != nil { - return fmt.Errorf("handler error for %s: %w", "echo", err) - } - // Give the service a stream that writes responses on this connection - // with the ID from this request. - streamWrapper := &echoStreamWrapper{ - stream: s, - requestID: req.ID, - } - // Pass the decoded payload, when present, and this request's stream - // to the service. - endpointInput := &chat.EchoEndpointInput{ - Payload: payload.(*chat.EchoPayload), - Stream: streamWrapper, - } - if _, err := s.echoEndpoint(ctx, endpointInput); err != nil { - // Send the service error to callers that supplied a request ID. - if req.HasID { - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // The error response completes this request. The next Recv call - // reads another request from the same connection. - return nil - } - // Notifications have no response, so finish this request without - // writing to the connection. - return nil - } - return nil - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} - -// Close asks the Chat client to close normally, closes the WebSocket, and -// returns errors from either operation. -func (s *chatStream) Close() error { - controlErr := s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), - time.Now().Add(time.Second), - ) - if controlErr != nil { - controlErr = fmt.Errorf("write normal WebSocket close message: %w", controlErr) - } - closeErr := s.conn.Close() - if closeErr != nil { - closeErr = fmt.Errorf("close WebSocket connection: %w", closeErr) - } - return errors.Join(controlErr, closeErr) -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index bd616fa13e..90c70e13ff 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -14,9 +14,8 @@ import ( "os" calcc "generated.local/gen/jsonrpc/calc/client" - chatc "generated.local/gen/jsonrpc/chat/client" feedc "generated.local/gen/jsonrpc/feed/client" - mixedc2 "generated.local/gen/jsonrpc/mixed/client" + mixedc "generated.local/gen/jsonrpc/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) @@ -27,8 +26,7 @@ import ( func UsageCommands() []string { return []string{ "calc (add|ping|log)", - "chat echo", - "feed watch", + "feed (watch|snapshot)", "mixed lookup", } } @@ -36,7 +34,6 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { return os.Args[0] + " " + "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'" + "\n" + - os.Args[0] + " " + "chat echo --body '{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'" + "\n" + os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'" + "\n" + os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'" + "\n" + "" @@ -50,8 +47,6 @@ func ParseEndpoint( enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restore bool, - dialer goahttp.Dialer, - chatConfigFn goahttp.ConnConfigureFunc, ) (goa.Endpoint, any, error) { var ( calcFlags = flag.NewFlagSet("calc", flag.ContinueOnError) @@ -64,16 +59,14 @@ func ParseEndpoint( calcLogFlags = flag.NewFlagSet("log", flag.ExitOnError) calcLogBodyFlag = calcLogFlags.String("body", "REQUIRED", "") - chatFlags = flag.NewFlagSet("chat", flag.ContinueOnError) - - chatEchoFlags = flag.NewFlagSet("echo", flag.ExitOnError) - chatEchoBodyFlag = chatEchoFlags.String("body", "REQUIRED", "") - feedFlags = flag.NewFlagSet("feed", flag.ContinueOnError) feedWatchFlags = flag.NewFlagSet("watch", flag.ExitOnError) feedWatchBodyFlag = feedWatchFlags.String("body", "REQUIRED", "") + feedSnapshotFlags = flag.NewFlagSet("snapshot", flag.ExitOnError) + feedSnapshotBodyFlag = feedSnapshotFlags.String("body", "REQUIRED", "") + mixedFlags = flag.NewFlagSet("mixed", flag.ContinueOnError) mixedLookupFlags = flag.NewFlagSet("lookup", flag.ExitOnError) @@ -84,11 +77,9 @@ func ParseEndpoint( calcPingFlags.Usage = calcPingUsage calcLogFlags.Usage = calcLogUsage - chatFlags.Usage = chatUsage - chatEchoFlags.Usage = chatEchoUsage - feedFlags.Usage = feedUsage feedWatchFlags.Usage = feedWatchUsage + feedSnapshotFlags.Usage = feedSnapshotUsage mixedFlags.Usage = mixedUsage mixedLookupFlags.Usage = mixedLookupUsage @@ -110,8 +101,6 @@ func ParseEndpoint( switch svcn { case "calc": svcf = calcFlags - case "chat": - svcf = chatFlags case "feed": svcf = feedFlags case "mixed": @@ -144,18 +133,14 @@ func ParseEndpoint( } - case "chat": - switch epn { - case "echo": - epf = chatEchoFlags - - } - case "feed": switch epn { case "watch": epf = feedWatchFlags + case "snapshot": + epf = feedSnapshotFlags + } case "mixed": @@ -197,26 +182,22 @@ func ParseEndpoint( endpoint = c.Log() data, err = calcc.BuildLogPayload(*calcLogBodyFlag) } - case "chat": - c := chatc.NewClient(scheme, host, doer, enc, dec, restore, dialer, chatConfigFn) - switch epn { - case "echo": - endpoint = c.Echo() - data, err = chatc.BuildEchoPayload(*chatEchoBodyFlag) - } case "feed": c := feedc.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "watch": endpoint = c.Watch() data, err = feedc.BuildWatchPayload(*feedWatchBodyFlag) + case "snapshot": + endpoint = c.Snapshot() + data, err = feedc.BuildSnapshotPayload(*feedSnapshotBodyFlag) } case "mixed": - c := mixedc2.NewClient(scheme, host, doer, enc, dec, restore) + c := mixedc.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "lookup": endpoint = c.Lookup() - data, err = mixedc2.BuildLookupPayload(*mixedLookupBodyFlag) + data, err = mixedc.BuildLookupPayload(*mixedLookupBodyFlag) } } } @@ -291,60 +272,51 @@ func calcLogUsage() { fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Quae consectetur.\",\n \"message\": \"Quo excepturi.\"\n }'") } -// chatUsage displays the usage of the chat command and its subcommands. -func chatUsage() { - fmt.Fprintln(os.Stderr, `Service is the Chat service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] chat COMMAND [flags]\n\n", os.Args[0]) +// feedUsage displays the usage of the feed command and its subcommands. +func feedUsage() { + fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) + fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` echo: Echo implements echo.`) + fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) + fmt.Fprintln(os.Stderr, ` snapshot: Snapshot implements snapshot.`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s chat COMMAND --help\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) } -func chatEchoUsage() { +func feedWatchUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] chat echo", os.Args[0]) + fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Echo implements echo.`) + fmt.Fprintln(os.Stderr, `Watch implements watch.`) // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "chat echo --body '{\n \"id\": \"Cum omnis ut dolor doloremque velit.\",\n \"msg\": \"Soluta illum.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") } -// feedUsage displays the usage of the feed command and its subcommands. -func feedUsage() { - fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) - fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) -} -func feedWatchUsage() { +func feedSnapshotUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) + fmt.Fprintf(os.Stderr, "%s [flags] feed snapshot", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Watch implements watch.`) + fmt.Fprintln(os.Stderr, `Snapshot implements snapshot.`) // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed snapshot --body '{\n \"request_id\": \"Sed assumenda enim quod.\"\n }'") } // mixedUsage displays the usage of the mixed command and its subcommands. diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden index 86c9749362..265911ad57 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden @@ -32,3 +32,21 @@ func BuildWatchPayload(feedWatchBody string) (*feed.WatchPayload, error) { return v, nil } + +// BuildSnapshotPayload builds the payload for the Feed snapshot endpoint from +// CLI flags. +func BuildSnapshotPayload(feedSnapshotBody string) (*feed.SnapshotPayload, error) { + var err error + var body SnapshotRequestBody + { + err = json.Unmarshal([]byte(feedSnapshotBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"request_id\": \"Sed assumenda enim quod.\"\n }'") + } + } + v := &feed.SnapshotPayload{ + RequestID: body.RequestID, + } + + return v, nil +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden index 593b351e64..8367680edd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -50,7 +51,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, WatchDoer: doer, @@ -83,18 +83,47 @@ func (c *Client) Watch() goa.Endpoint { } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "watch", err) + } return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("Feed", "watch", err)) + } + return nil, contentTypeErr } // Create the SSE client stream return NewWatchStream(resp, c.decoder), nil } } + +// Snapshot returns an endpoint that makes JSON-RPC requests to the Feed +// service snapshot method. +func (c *Client) Snapshot() goa.Endpoint { + var ( + encodeRequest = EncodeSnapshotRequest(c.encoder) + decodeResponse = DecodeSnapshotResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildSnapshotRequest(ctx, v) + if err != nil { + return nil, err + } + if err := encodeRequest(req, v); err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Feed", "snapshot", err) + } + return decodeResponse(resp) + } +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden index 8a28db0dfc..39df2f857d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -59,50 +60,98 @@ func EncodeWatchRequest(encoder func(*http.Request) goahttp.Encoder) func(*http. } } -// DecodeWatchResponse returns a decoder for responses returned by the Feed -// service watch JSON-RPC method. restoreBody controls whether the response +// BuildSnapshotRequest instantiates a HTTP request object with method and path +// set to call the "Feed" service "snapshot" endpoint +func (c *Client) BuildSnapshotRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: SnapshotFeedPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Feed", "snapshot", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeSnapshotRequest returns an encoder for requests sent to the Feed +// snapshot server. +func EncodeSnapshotRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*feed.SnapshotPayload) + if !ok { + return goahttp.ErrInvalidType("Feed", "snapshot", "*feed.SnapshotPayload", v) + } + b := NewSnapshotRequestBody(p) + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "snapshot", + Params: b, + } + if p.RequestID != "" { + body.ID = p.RequestID + } + // If ID is empty, this is a notification - no ID field + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("Feed", "snapshot", err) + } + return nil + } +} + +// DecodeSnapshotResponse returns a decoder for responses returned by the Feed +// service snapshot JSON-RPC method. restoreBody controls whether the response // body should be restored after having been read. -func DecodeWatchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +func DecodeSnapshotResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Feed", "snapshot", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) + } + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, string(body)) } var jresp jsonrpc.RawResponse if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) var ( - body WatchResponseBody + body string err error ) err = decoder(resp).Decode(&body) if err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } - res := NewWatchResultOK(&body) - return res, nil + return body, nil } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden index 1330b366e8..6bb9493df4 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden @@ -11,3 +11,8 @@ package client func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden index 7e590d11b1..46d1928c96 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden @@ -12,6 +12,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -41,6 +42,10 @@ type ( decoder func(*http.Response) goahttp.Decoder // closed records whether Close was called or the response ended. closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error // lock prevents two calls from reading or closing the response at once. lock sync.Mutex } @@ -55,8 +60,25 @@ func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.De } } -// parseSSEEvent reads one complete event from the response. -func (s *WatchStreamImpl) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() var event strings.Builder var dataLines []string @@ -103,7 +125,7 @@ func (s *WatchStreamImpl) Recv() (*feed.WatchResult, error) { // RecvWithContext reads instances of "WatchResult" from the stream with // context. -func (s *WatchStreamImpl) RecvWithContext(_ context.Context) (*feed.WatchResult, error) { +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*feed.WatchResult, error) { s.lock.Lock() defer s.lock.Unlock() @@ -114,89 +136,85 @@ func (s *WatchStreamImpl) RecvWithContext(_ context.Context) (*feed.WatchResult, } for { - eventType, data, err := s.parseSSEEvent() + eventType, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } switch eventType { case "notification": - // Parse JSON-RPC notification + // Read the streamed service result from the notification parameters. var notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) } - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) } if notification.Method != "watch" { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) } - // Decode the result from params result, err := s.decodeResult(notification.Params) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) } return result, nil case "response": - // Final response - parse and return + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } if response.Error != nil { - return zero, response.Error - } - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") - } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) + return zero, s.endStream(response.Error) } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) - } - - // Mark stream as closed after final response - s.closed = true - return result, nil + return zero, s.endStream(io.EOF) case "error": - // Error response + // A JSON-RPC error completes the stream. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) } - - s.closed = true if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - return zero, fmt.Errorf("unexpected error response") + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) default: - // Ignore unknown event types - continue + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) } } } +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + // decodeResult passes one successful stream item to the decoder configured by NewClient. func (s *WatchStreamImpl) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { // Give the configured decoder the successful result bytes as an HTTP response body. @@ -221,9 +239,7 @@ func (s *WatchStreamImpl) Close() error { if !s.closed { s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } + return s.closeBody() } return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden index d60d1d6f93..6e24c5971f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden @@ -20,6 +20,13 @@ type WatchRequestBody struct { LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` } +// SnapshotRequestBody is the type of the "Feed" service "snapshot" endpoint +// HTTP request body. +type SnapshotRequestBody struct { + // Request ID + RequestID string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` +} + // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. type WatchResponseBody struct { @@ -39,6 +46,15 @@ func NewWatchRequestBody(p *feed.WatchPayload) *WatchRequestBody { return body } +// NewSnapshotRequestBody builds the HTTP request body from the payload of the +// "snapshot" endpoint of the "Feed" service. +func NewSnapshotRequestBody(p *feed.SnapshotPayload) *SnapshotRequestBody { + body := &SnapshotRequestBody{ + RequestID: p.RequestID, + } + return body +} + // NewWatchResultOK builds a "Feed" service "watch" endpoint result from a HTTP // "OK" response. func NewWatchResultOK(body *WatchResponseBody) *feed.WatchResult { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden index 538fa5542c..b33add53be 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden @@ -49,3 +49,34 @@ func DecodeWatchRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.D return payload, nil } } + +// DecodeSnapshotRequest returns a decoder for requests sent to the Feed +// snapshot endpoint. +func DecodeSnapshotRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *feed.SnapshotPayload + var ( + body SnapshotRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateSnapshotRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewSnapshotPayload(&body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden index 8df7009ac8..e9ccf29038 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden @@ -11,3 +11,8 @@ package server func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden index 2f8bfbade5..ba3fdd2712 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden @@ -8,9 +8,15 @@ package server import ( + "bufio" "context" + "errors" "fmt" + "io" + "mime" "net/http" + "strconv" + "strings" feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" @@ -26,6 +32,8 @@ type Server struct { // Watch is the handler for the watch method. Watch func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error + // Snapshot is the handler for the snapshot method. + Snapshot func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder @@ -44,15 +52,16 @@ func New( s := &Server{ Methods: []string{ "watch", + "snapshot", }, Watch: NewWatchHandler(endpoints.Watch, mux, decoder, encoder, errhandler), + Snapshot: NewSnapshotHandler(endpoints.Snapshot, mux, decoder, encoder, errhandler), decoder: decoder, encoder: encoder, errhandler: errhandler, } // Install the request handler required by this service's methods. - // handleSSE writes each result as a server-sent event. - s.Handler = http.HandlerFunc(s.handleSSE) + s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -67,23 +76,254 @@ func (s *Server) Use(m func(http.Handler) http.Handler) { // MethodNames returns the methods served. func (s *Server) MethodNames() []string { return feed.MethodNames[:] } -// handleSSE finds the requested method and writes its results as server-sent events. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() +// ServeHTTP decodes one request and uses the response type designed for its method. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) - // Read the JSON-RPC request. + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } + + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Write the parse error as a server-sent event. - stream := &sseServerStream{w: w, encoder: s.encoder} - if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - s.errhandler(ctx, w, fmt.Errorf("write parse error event: %w", err)) + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &sseServerStream{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || req.Method == "" { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + + switch req.Method { + case "watch": + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + case "snapshot": + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } +} + +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { + var reqs []jsonrpc.RawRequest + if err := s.decoder(r).Decode(&reqs); err != nil { + // An array that cannot be decoded receives the JSON-RPC parse error. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + return + } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) } return } + // Write every response into one JSON array. + w.Header().Set("Content-Type", "application/json") + writer := &batchWriter{Writer: w} + + for _, req := range reqs { + // The writer inserts the array separators around each response. + s.processRequest(r.Context(), r, &req, writer) + } + + // Write the closing bracket only when at least one request produced a response. + if writer.written { + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } + } +} + +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + + if req.Method == "" { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) + return + } + + switch req.Method { + case "watch": + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + } + case "snapshot": + if err := s.Snapshot(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "snapshot", err)) + } + default: + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } + } +} + +// batchWriter inserts JSON array separators around responses from one request +// array. +type batchWriter struct { + io.Writer + header http.Header + statusCode int + written bool +} + +func (rb *batchWriter) Header() http.Header { + if rb.header == nil { + rb.header = make(http.Header) + } + return rb.header +} + +func (rb *batchWriter) WriteHeader(statusCode int) { + if rb.written { + return + } + rb.statusCode = statusCode +} + +func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') + if !rb.written { + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err + } + rb.written = true + return rb.Writer.Write(data) +} + +// processSSERequest validates and runs one server-sent-event request. +func (s *Server) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + // Reject requests that do not use JSON-RPC 2.0. - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { stream := &sseServerStream{w: w, encoder: s.encoder} if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) @@ -105,6 +345,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { case "watch": handler = s.Watch default: + if !req.HasID { + return + } stream := &sseServerStream{w: w, encoder: s.encoder} if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) @@ -113,20 +356,15 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { } // Call the requested method. - if err := handler(ctx, r, &req, w); err != nil { + if err := handler(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) - return - } - - // A request without an ID receives no response when the method sends one result. - switch req.Method { } } // Mount configures the mux to serve the JSON-RPC Feed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // Every method in this server writes server-sent events. - mux.Handle("POST", "/feed", h.handleSSE) + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. + mux.Handle("POST", "/feed", h.ServeHTTP) } // Mount configures the mux to serve the JSON-RPC Feed service methods. @@ -146,22 +384,19 @@ func NewWatchHandler( return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, "watch") ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") - // Create the stream before decoding so a request error can be written to it. + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. strm := &WatchServerStream{ sseServerStream: sseServerStream{ w: w, encoder: encoder, }, - requestID: req.ID, } decodeParams := DecodeWatchRequest(mux, decoder) params, err := decodeParams(r, req) if err != nil { - // Write the request error as a JSON-RPC server-sent event when the request has an ID. - if req.ID != nil && req.ID != "" { - if err := strm.SendError(ctx, jsonrpc.IDToString(req.ID), err); err != nil { - return err - } + if req.HasID { + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) } return nil } @@ -171,26 +406,95 @@ func NewWatchHandler( // Set Last-Event-ID header if present if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { ctx = context.WithValue(ctx, "last-event-id", lastEventID) + params.LastEventID = &lastEventID } v := &feed.WatchEndpointInput{ Stream: strm, Payload: params, } - if _, err := endpoint(ctx, v); err != nil { - return err + _, err = endpoint(ctx, v) + if err != nil { + if !req.HasID { + return nil + } + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + } + if !req.HasID { + return nil + } + + response := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": nil, + } + return strm.sendSSEEvent(ctx, "response", response) + } +} + +// NewSnapshotHandler creates a JSON-RPC handler which calls the "Feed" service +// "snapshot" endpoint. +func NewSnapshotHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeSnapshotRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "snapshot") + ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") + params, err := decodeParams(r, req) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) + } + return nil + } + if req.ID != nil { + params.RequestID = jsonrpc.IDToString(req.ID) + } + res, err := endpoint(ctx, params) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) + } + return nil + } + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } + + // For methods with results, determine the ID to use for the response + var id any + // No ID field in result - use request ID + id = req.ID + + // Send response with the result + response := jsonrpc.MakeSuccessResponse(id, res) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } return nil } } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -201,10 +505,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden index a332dd721d..945730ccbd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden @@ -17,7 +17,6 @@ import ( feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" ) type ( @@ -38,11 +37,14 @@ type ( } ) +// Header returns the headers written while the event is being encoded. func (b *sseEventBuffer) Header() http.Header { return b.header } -func (b *sseEventBuffer) WriteHeader(int) {} +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *sseEventBuffer) WriteHeader(int) { +} // initSSEHeaders writes the response headers before the first event. func (s *sseServerStream) initSSEHeaders() { @@ -65,10 +67,8 @@ func (s *sseServerStream) sendSSEEvent(ctx context.Context, eventType string, va } s.initSSEHeaders() - if eventType != "" { - if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { - return fmt.Errorf("write server-sent event name: %w", err) - } + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) } if _, err := s.w.Write([]byte("data: ")); err != nil { return fmt.Errorf("write server-sent event data label: %w", err) @@ -96,86 +96,28 @@ func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Co type WatchServerStream struct { // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID identifies the request in the final response. - requestID any - // closed records whether SendAndClose has written the final response. - closed bool - // mu protects closed and view while service code sends results. - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) error { - // Reject a send after SendAndClose wrote the final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "WatchResult". +func (s *WatchServerStream) Send(event *feed.WatchResult) error { + return s.SendWithContext(context.Background(), event) +} - // Read the service result value from the event. - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Build the JSON body declared for this service result. +// SendWithContext streams instances of "WatchResult" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error { + result := event body := NewWatchResponseBody(result) - // Write a notification without a request ID. message := map[string]any{ "jsonrpc": "2.0", "method": "watch", "params": body, } - return s.sendSSEEvent(ctx, "notification", message) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *WatchServerStream) SendAndClose(ctx context.Context, event feed.WatchEvent) error { - // Reject a second final response. - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Read the service result value from the event. - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Start with the ID of the request that opened this stream. - var id any = s.requestID - // Build the JSON body declared for this service result. - body := NewWatchResponseBody(result) - - // Write the final response with its request ID. - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent(ctx, "response", message) -} - -// SendError sends a JSON-RPC error response. -func (s *WatchServerStream) SendError(ctx context.Context, id string, err error) error { - // Report request validation failures as invalid parameters and all other - // failures as internal errors. - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden index b028989817..bb39c8aea9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden @@ -21,6 +21,13 @@ type WatchRequestBody struct { LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` } +// SnapshotRequestBody is the type of the "Feed" service "snapshot" endpoint +// HTTP request body. +type SnapshotRequestBody struct { + // Request ID + RequestID *string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` +} + // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. type WatchResponseBody struct { @@ -50,6 +57,15 @@ func NewWatchPayload(body *WatchRequestBody) *feed.WatchPayload { return v } +// NewSnapshotPayload builds a Feed service snapshot endpoint payload. +func NewSnapshotPayload(body *SnapshotRequestBody) *feed.SnapshotPayload { + v := &feed.SnapshotPayload{ + RequestID: *body.RequestID, + } + + return v +} + // ValidateWatchRequestBody runs the validations defined on WatchRequestBody func ValidateWatchRequestBody(body *WatchRequestBody) (err error) { if body.RequestID == nil { @@ -57,3 +73,12 @@ func ValidateWatchRequestBody(body *WatchRequestBody) (err error) { } return } + +// ValidateSnapshotRequestBody runs the validations defined on +// SnapshotRequestBody +func ValidateSnapshotRequestBody(body *SnapshotRequestBody) (err error) { + if body.RequestID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("request_id", "body")) + } + return +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden index e0017c39dd..4b3f482b3e 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden index c658a60a55..3c1632bfa0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -63,21 +64,31 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // service lookup JSON-RPC method. restoreBody controls whether the response // body should be restored after having been read. func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } @@ -89,8 +100,7 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden index c51b1a741e..46e5977fb5 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden @@ -54,7 +54,7 @@ func New( errhandler: errhandler, } // Install the request handler required by this service's methods. - // ServeHTTP writes one JSON-RPC response for each request. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -73,32 +73,44 @@ func (s *Server) MethodNames() []string { return mixed.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -106,11 +118,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -120,36 +132,46 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -165,11 +187,14 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "lookup", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter joins the responses written for one JSON-RPC batch request. +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -192,18 +217,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Mixed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // Every method in this server writes one ordinary JSON-RPC response. + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/mixed/rpc/mixed/rpc", h.ServeHTTP) } @@ -227,13 +254,8 @@ func NewLookupHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Mixed") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) @@ -245,33 +267,18 @@ func NewLookupHandler( } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any @@ -283,11 +290,6 @@ func NewLookupHandler( id = req.ID } - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result // Build the response body with the fields and JSON names declared by the service. body := NewLookupResponseBody(res.(*mixed.LookupResult)) @@ -299,14 +301,14 @@ func NewLookupHandler( } } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError writes one JSON-RPC error response and preserves a -// missing request ID. +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -317,10 +319,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden index df69561d71..2b5dca25b8 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden @@ -1,5 +1,4 @@ calc.go -chat.go cmd/kitchen_sink-cli/http.go cmd/kitchen_sink-cli/jsonrpc.go cmd/kitchen_sink-cli/main.go @@ -34,17 +33,6 @@ gen/jsonrpc/calc/server/encode_decode.go gen/jsonrpc/calc/server/paths.go gen/jsonrpc/calc/server/server.go gen/jsonrpc/calc/server/types.go -gen/jsonrpc/chat/client/cli.go -gen/jsonrpc/chat/client/client.go -gen/jsonrpc/chat/client/encode_decode.go -gen/jsonrpc/chat/client/paths.go -gen/jsonrpc/chat/client/types.go -gen/jsonrpc/chat/client/websocket.go -gen/jsonrpc/chat/server/encode_decode.go -gen/jsonrpc/chat/server/paths.go -gen/jsonrpc/chat/server/server.go -gen/jsonrpc/chat/server/types.go -gen/jsonrpc/chat/server/websocket.go gen/jsonrpc/cli/kitchen_sink/cli.go gen/jsonrpc/feed/client/cli.go gen/jsonrpc/feed/client/client.go diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden new file mode 100644 index 0000000000..f399928346 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden @@ -0,0 +1,85 @@ +// decodeFetchViewedResult decodes the JSON body selected by the result view +// for the viewed service fetch method. +func decodeFetchViewedResult(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage) (*viewed.ViewedGolden, error) { + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := decodeJSONRPCResult(decoder, data, &representation); err != nil { + return nil, err + } + if representation.View == nil { + return nil, goa.MissingFieldError("view", "result") + } + view := *representation.View + switch view { + case "summary": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodySummary + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultSummaryOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + case "detailed": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodyDetailed + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultDetailedOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + case "default": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultDefaultOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + default: + return nil, goa.InvalidEnumValueError("view", view, []any{"summary", "detailed", "default"}) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden new file mode 100644 index 0000000000..a3622a2994 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden @@ -0,0 +1,48 @@ +// encodeFetchViewedResult builds the JSON body selected by the result view for +// the viewed service fetch method. +func encodeFetchViewedResult(viewed *viewedviews.ViewedGolden) (any, error) { + if err := viewedviews.ValidateViewedGolden(viewed); err != nil { + return nil, err + } + switch viewed.View { + case "summary": + res := viewed + body := NewFetchResponseBodySummary(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "summary", + Body: body, + }, nil + case "detailed": + res := viewed + body := NewFetchResponseBodyDetailed(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "detailed", + Body: body, + }, nil + case "default": + res := viewed + body := NewFetchResponseBody(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "default", + Body: body, + }, nil + default: + panic("validated viewed result has no JSON-RPC representation") + } +} + +// encodeFetchResult builds and validates the selected result view before +// JSON-RPC encoding. +func encodeFetchResult(result *viewed.ViewedGolden, view string) (any, error) { + viewed := viewed.NewViewedViewedGolden(result, view) + return encodeFetchViewedResult(viewed) +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden new file mode 100644 index 0000000000..ccec2cef8c --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden @@ -0,0 +1,210 @@ +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*viewed.ViewedGolden, error) + RecvWithContext(context.Context) (*viewed.ViewedGolden, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } +} + +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() + var event strings.Builder + var dataLines []string + + for { + line, err := s.reader.ReadString('\n') + if err != nil { + if err == io.EOF && len(dataLines) > 0 { + // Return the last event even when the response has no final blank line. + break + } + return "", nil, err + } + + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + + if line == "" { + // A blank line ends the current event. + if len(dataLines) > 0 { + break + } + continue + } + + if strings.HasPrefix(line, "event:") { + event.WriteString(strings.TrimSpace(line[6:])) + } else if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(line[5:])) + } + // This client does not use the id and retry fields. + } + + if len(dataLines) > 0 { + data = []byte(strings.Join(dataLines, "\n")) + } + + return event.String(), data, nil +} + +// Recv reads instances of "ViewedGolden" from the stream. +func (s *WatchStreamImpl) Recv() (*viewed.ViewedGolden, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "ViewedGolden" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*viewed.ViewedGolden, error) { + s.lock.Lock() + defer s.lock.Unlock() + + var zero *viewed.ViewedGolden + + if s.closed { + return zero, io.EOF + } + + for { + eventType, data, err := s.parseSSEEvent(ctx) + if err != nil { + return zero, s.endStream(err) + } + + switch eventType { + case "notification": + // Read the streamed service result from the notification parameters. + var notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(data, ¬ification); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + + if notification.JSONRPC != "2.0" { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + + if notification.Method != "watch" { + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + + result, err := s.decodeResult(notification.Params) + if err != nil { + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) + } + return result, nil + + case "response": + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. + var response jsonrpc.Response + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) + } + + if response.Error != nil { + return zero, s.endStream(response.Error) + } + return zero, s.endStream(io.EOF) + + case "error": + // A JSON-RPC error completes the stream. + var response jsonrpc.Response + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) + } + if response.Error != nil { + return zero, s.endStream(response.Error) + } + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) + + default: + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) + } + } +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage) (*viewed.ViewedGolden, error) { + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return decodeWatchViewedResult(s.decoder, resp, data) +} + +// Close closes the stream. +func (s *WatchStreamImpl) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.closed { + s.closed = true + return s.closeBody() + } + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden new file mode 100644 index 0000000000..5fb3f9e06e --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden @@ -0,0 +1,51 @@ +// WatchServerStream implements the viewed.WatchServerStream interface using +// Server-Sent Events. +type WatchServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream + // view is the result view used to encode later stream values. + view string + // sentView is the result view used by the first event. Later sends must use + // the same view. + sentView string +} + +// SetView selects the result view used by later stream values. +func (s *WatchServerStream) SetView(view string) { + s.view = view +} + +// Send streams instances of "ViewedGolden". +func (s *WatchServerStream) Send(event *viewed.ViewedGolden) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "ViewedGolden" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *viewed.ViewedGolden) error { + result := event + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + body, err := encodeWatchResult(result, view) + if err != nil { + return err + } + s.sentView = view + + message := map[string]any{ + "jsonrpc": "2.0", + "method": "watch", + "params": body, + } + return s.sendSSEEvent(ctx, "notification", message) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden new file mode 100644 index 0000000000..a00efefbbe --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden @@ -0,0 +1,46 @@ +// DecodeFetchResponse returns a decoder for responses returned by the viewed +// service fetch JSON-RPC method. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeFetchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("viewed", "fetch", err)) + } + }() + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, string(body)) + } + + var jresp jsonrpc.RawResponse + if err := decoder(resp).Decode(&jresp); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + + if jresp.Error != nil { + switch jresp.Error.Code { + default: + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, string(jresp.Error.Data)) + } + } + return decodeFetchViewedResult(decoder, resp, jresp.Result) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden new file mode 100644 index 0000000000..018bb9e531 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden @@ -0,0 +1,45 @@ +// NewFetchHandler creates a JSON-RPC handler which calls the "viewed" service +// "fetch" endpoint. +func NewFetchHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "fetch") + ctx = context.WithValue(ctx, goa.ServiceKey, "viewed") + res, err := endpoint(ctx, nil) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) + } + return nil + } + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } + + // For methods with results, determine the ID to use for the response + var id any + // No ID field in result - use request ID + id = req.ID + + // Send response with the result + viewedRes := res.(*viewedviews.ViewedGolden) + body, err := encodeFetchViewedResult(viewedRes) + if err != nil { + return err + } + response := jsonrpc.MakeSuccessResponse(id, body) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go index cdc507fa0a..a41f7cf86c 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go @@ -7,10 +7,9 @@ import ( // JSONRPCKitchenSinkDSL exercises the full JSON-RPC generated surface in one // design so golden tests can pin every generator output: a plain JSON-RPC // service (required and optional request IDs, a no-payload method, a method -// with no result, custom errors with JSON-RPC code mappings), a -// WebSocket-only streaming service, an SSE streaming service, a service -// mixing HTTP and JSON-RPC transports on the same methods, and a plain HTTP -// service sharing the design. +// with no result, custom errors with JSON-RPC code mappings), an SSE streaming +// service, a service mixing HTTP and JSON-RPC transports on the same methods, +// and a plain HTTP service sharing the design. var JSONRPCKitchenSinkDSL = func() { API("kitchen-sink", func() { JSONRPC(func() {}) @@ -53,23 +52,6 @@ var JSONRPCKitchenSinkDSL = func() { }) }) - Service("Chat", func() { - JSONRPC(func() { - Path("/ws") - }) - Method("echo", func() { - StreamingPayload(func() { - ID("id", String, "Request ID") - Attribute("msg", String) - }) - StreamingResult(func() { - ID("id", String, "Request ID") - Attribute("echo", String) - }) - JSONRPC(func() {}) - }) - }) - Service("Feed", func() { JSONRPC(func() { POST("/feed") @@ -91,6 +73,14 @@ var JSONRPCKitchenSinkDSL = func() { }) }) }) + Method("snapshot", func() { + Payload(func() { + ID("request_id", String, "Request ID") + Required("request_id") + }) + Result(String) + JSONRPC(func() {}) + }) }) Service("Mixed", func() { diff --git a/jsonrpc/codegen/viewed_result.go b/jsonrpc/codegen/viewed_result.go index 488c2a761d..4fb36a6691 100644 --- a/jsonrpc/codegen/viewed_result.go +++ b/jsonrpc/codegen/viewed_result.go @@ -1,7 +1,7 @@ // This file connects each JSON-RPC result view to the HTTP JSON body and -// service constructor chosen for that endpoint. Unary calls, SSE streams, and -// WebSocket streams use the same generated functions, so clients decode the -// same JSON shape that servers encode. +// service constructor chosen for that endpoint. Unary calls and SSE streams +// use the same generated functions, so clients decode the same JSON shape that +// servers encode. package codegen import ( @@ -34,6 +34,7 @@ type ( ServiceResultConstructor string ServiceViewedConstructor string ServicePkg string + ViewedValue string ResultRef string IsCollection bool HasResponseMetadata bool @@ -171,6 +172,10 @@ func viewedResultData(service *servicePlan, endpoint *endpointPlan) *viewedResul Cookies: branch.cookies, } } + localScope := codegen.NewNameScope() + localScope.Unique(representation.servicePkg) + localScope.Unique(viewed.ViewsPkg) + return &viewedResultTemplateData{ ServiceName: endpoint.ServiceName, MethodName: endpoint.Method.Name, @@ -189,6 +194,7 @@ func viewedResultData(service *servicePlan, endpoint *endpointPlan) *viewedResul ServiceResultConstructor: viewed.ResultInit.Name(), ServiceViewedConstructor: viewed.Init.Name(), ServicePkg: representation.servicePkg, + ViewedValue: localScope.Unique("viewed"), ResultRef: representation.resultRef, IsCollection: viewed.IsCollection, HasResponseMetadata: representationHasMetadata(representation), diff --git a/jsonrpc/codegen/viewed_result_golden_test.go b/jsonrpc/codegen/viewed_result_golden_test.go new file mode 100644 index 0000000000..2e88d70bf5 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_golden_test.go @@ -0,0 +1,146 @@ +// This file checks the generated JSON-RPC code that carries a selected result +// view through unary responses and server-sent events. +package codegen + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +func TestVariableViewedResultGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, variableViewedResultGoldenDSL) + require.Len(t, plan.services, 1) + service := plan.services[0] + + clientConversions := clientViewedResultSections(service) + serverConversions := serverViewedResultSections(service) + require.Len(t, clientConversions, 2) + require.Len(t, serverConversions, 2) + clientData := clientConversions[0].Data.(*viewedResultTemplateData) + require.Equal(t, "fetch", clientData.MethodName) + require.Equal(t, "viewed2", clientData.ViewedValue) + require.Equal(t, "fetch", serverConversions[0].Data.(*viewedResultTemplateData).MethodName) + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_decoder.go.golden", + codegen.SectionCode(t, clientConversions[0]), + ) + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_encoder.go.golden", + codegen.SectionCode(t, serverConversions[0]), + ) + + tests := []struct { + name string + files []*codegen.File + packageName string + fileName string + sectionName string + sectionCount int + golden string + }{ + { + name: "unary client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "encode_decode.go", + sectionName: "jsonrpc-response-decoder", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_unary_client.go.golden", + }, + { + name: "unary server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "server.go", + sectionName: "jsonrpc-server-handler-init", + sectionCount: 2, + golden: "testdata/golden/viewed_result_variable_unary_server.go.golden", + }, + { + name: "SSE client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "stream.go", + sectionName: "jsonrpc-sse-client-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_client.go.golden", + }, + { + name: "SSE server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "sse.go", + sectionName: "jsonrpc-sse-server-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_server.go.golden", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := viewedResultGoldenFile(t, test.files, test.packageName, test.fileName) + sections := file.Section(test.sectionName) + require.Len(t, sections, test.sectionCount) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[0])) + }) + } +} + +// viewedResultGoldenFile returns one generated client or server file for the +// viewed service used by the snapshots. +func viewedResultGoldenFile(t *testing.T, files []*codegen.File, packageName, fileName string) *codegen.File { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != fileName { + continue + } + if filepath.Base(filepath.Dir(file.Path)) != packageName { + continue + } + if filepath.Base(filepath.Dir(filepath.Dir(file.Path))) == "viewed" { + return file + } + } + t.Fatalf("generated viewed/%s/%s file not found", packageName, fileName) + return nil +} + +// variableViewedResultGoldenDSL defines one unary method and one server stream +// whose callers choose between the two named views or the generated default. +func variableViewedResultGoldenDSL() { + result := dsl.ResultType("application/vnd.viewed-golden", func() { + dsl.TypeName("ViewedGolden") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id", "detail") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) +} diff --git a/jsonrpc/codegen/viewed_result_runtime_regression_test.go b/jsonrpc/codegen/viewed_result_runtime_regression_test.go index 3699dde8d7..1812381961 100644 --- a/jsonrpc/codegen/viewed_result_runtime_regression_test.go +++ b/jsonrpc/codegen/viewed_result_runtime_regression_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/require" goacodegen "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" @@ -25,20 +24,26 @@ import ( jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// TestGeneratedViewedClientDecodersReceiveOKStatus renders a unary call, an -// SSE stream, and a WebSocket stream, then runs each generated client. +// TestGeneratedViewedClientDecodersReceiveOKStatus renders a unary call and an +// SSE stream, then runs each generated client. func TestGeneratedViewedClientDecodersReceiveOKStatus(t *testing.T) { dir := renderViewedResultRuntimeModule(t) writeViewedResultRuntimeTest(t, dir, "unary_status", unaryStatusRuntimeTest) writeViewedResultRuntimeTest(t, dir, "sse_status", sseStatusRuntimeTest) - writeViewedResultRuntimeTest(t, dir, "web_socket_status", webSocketStatusRuntimeTest) runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_status/client", "./jsonrpc/sse_status/client", - "./jsonrpc/web_socket_status/client", ) } +// TestGeneratedSSELifecycle renders a result stream and checks every JSON-RPC +// message written when the service sends values and returns. +func TestGeneratedSSELifecycle(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseLifecycleRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + // TestGeneratedMappedObjectBodyValidatesRequiredFields renders an explicit // object response body, decodes both selected views, and checks the required // field when the generated client receives the selected body. @@ -57,6 +62,14 @@ func TestGeneratedViewedUnaryResponseMetadata(t *testing.T) { runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_metadata/server") } +// TestGeneratedServerReturnsRequestBodyFailures makes reading and closing one +// request body fail and checks that the generated server reports both errors. +func TestGeneratedServerReturnsRequestBodyFailures(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "unary_status", requestBodyFailureRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_status/server") +} + // TestGeneratedSSEDecodeErrorReturnsWriteFailure sends a request that omits a // required parameter and makes writing the JSON-RPC error event fail. The // generated server must report that failure once without starting a new @@ -67,6 +80,14 @@ func TestGeneratedSSEDecodeErrorReturnsWriteFailure(t *testing.T) { runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") } +// TestGeneratedSSERecvWithContextStopsBlockedRead renders an SSE client and +// checks that canceling a receive closes its response body and ends the stream. +func TestGeneratedSSERecvWithContextStopsBlockedRead(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseCancellationRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_status/client") +} + // renderViewedResultRuntimeModule writes the generated service and JSON-RPC // client files used by these tests. It uses this Goa checkout and leaves the // repository's generated files unchanged. @@ -88,7 +109,6 @@ func renderViewedResultRuntimeModule(t *testing.T) string { HTTP: httpPlans[0], }) require.NoError(t, err) - require.NoError(t, example.Plan(generation)) require.NoError(t, generation.Freeze()) require.NoError(t, servicePlan.Link()) require.NoError(t, httpPlans[0].Link()) @@ -148,8 +168,8 @@ func runViewedResultRuntimeTests(t *testing.T, moduleDir string, patterns ...str require.NoError(t, err, string(output)) } -// viewedResultRuntimeDSL defines separate services because JSON-RPC WebSocket -// methods cannot share one service endpoint with HTTP or SSE methods. +// viewedResultRuntimeDSL defines the JSON-RPC methods rendered into the +// temporary module used by these tests. func viewedResultRuntimeDSL() { result := viewedStatusResult() dsl.Service("Unary Status", func() { @@ -190,20 +210,20 @@ func viewedResultRuntimeDSL() { }) }) }) - dsl.Service("Web Socket Status", func() { + dsl.Service("Protocol", func() { dsl.JSONRPC(func() { - dsl.Path("/websocket") + dsl.POST("/protocol") + }) + dsl.Method("ping", func() { + dsl.JSONRPC(func() {}) }) dsl.Method("watch", func() { - dsl.StreamingPayload(func() { - dsl.Attribute("key", dsl.String) - dsl.Required("key") + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) }) - dsl.StreamingResult(result) - dsl.JSONRPC(func() {}) }) }) - mapped := dsl.ResultType("application/vnd.mapped-body", func() { dsl.TypeName("MappedBody") dsl.Attribute("id", func() { @@ -310,6 +330,7 @@ const unaryStatusRuntimeTest = `package client import ( "context" + "errors" "io" "net/http" "strings" @@ -326,6 +347,19 @@ func (f doerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } +type trackedResponseBody struct { + reader io.Reader + closeErr error +} + +func (body *trackedResponseBody) Read(buffer []byte) (int, error) { + return body.reader.Read(buffer) +} + +func (body *trackedResponseBody) Close() error { + return body.closeErr +} + func TestUnaryViewedDecoderReceivesHTTPStatusOK(t *testing.T) { statuses := make([]int, 0, 3) decoder := func(response *http.Response) goahttp.Decoder { @@ -349,12 +383,67 @@ func TestUnaryViewedDecoderReceivesHTTPStatusOK(t *testing.T) { require.Equal(t, http.StatusOK, status) } } + +func TestUnaryDecoderReturnsResponseCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"label":"ready"}}}` + "`" + `, + ), + closeErr: closeErr, + }, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Fetch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) +} + +func TestUnaryDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: strings.NewReader("ignored"), + closeErr: closeErr, + }, + }, nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Fetch()(context.Background(), nil) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} ` const sseStatusRuntimeTest = `package client import ( "context" + "errors" "io" "net/http" "strings" @@ -364,6 +453,7 @@ import ( service "generated.local/gen/sse_status" goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" ) type doerFunc func(*http.Request) (*http.Response, error) @@ -372,6 +462,25 @@ func (f doerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } +type failingResponseBody struct { + reader io.Reader + readErr error + closeErr error + closes int +} + +func (body *failingResponseBody) Read(buffer []byte) (int, error) { + if body.reader != nil { + return body.reader.Read(buffer) + } + return 0, body.readErr +} + +func (body *failingResponseBody) Close() error { + body.closes++ + return body.closeErr +} + func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { statuses := make([]int, 0, 2) decoder := func(response *http.Response) goahttp.Decoder { @@ -383,7 +492,8 @@ func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( - "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n", + "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + "event: response\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + "\n\n", )), }, nil }) @@ -394,96 +504,272 @@ func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { var serviceStream service.WatchClientStream = stream _, err = serviceStream.Recv() require.NoError(t, err) + _, err = serviceStream.Recv() + require.ErrorIs(t, err, io.EOF) require.NotEmpty(t, statuses) for _, status := range statuses { require.Equal(t, http.StatusOK, status) } } + +func TestSSETerminalErrorIsReturned(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: error\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed"}}` + "`" + ` + "\n\n", + )), + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + var responseError *jsonrpc.ErrorResponse + require.ErrorAs(t, err, &responseError) + require.Equal(t, jsonrpc.InternalError, responseError.Code) + require.Equal(t, "watch failed", responseError.Message) +} + +func TestSSETerminalErrorPreservesCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &failingResponseBody{ + reader: strings.NewReader( + "event: error\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed"}}` + "`" + ` + "\n\n", + ), + closeErr: closeErr, + } + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + var responseError *jsonrpc.ErrorResponse + require.ErrorAs(t, err, &responseError) + require.Equal(t, "watch failed", responseError.Message) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEInvalidEventClosesBody(t *testing.T) { + tests := []struct { + name string + event string + error string + }{ + {"malformed notification", "event: notification\ndata: {\n\n", "failed to parse notification"}, + {"wrong method", "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"other","params":{}}` + "`" + ` + "\n\n", "received notification for JSON-RPC method"}, + {"unsupported event", "event: other\ndata: {}\n\n", "unsupported server-sent event type"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader(test.event)} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.ErrorContains(t, err, test.error) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + }) + } +} + +func TestSSEReadFailureClosesBody(t *testing.T) { + readErr := errors.New("read failed") + body := &failingResponseBody{readErr: readErr} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEValidNotificationKeepsBodyOpen(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader( + "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + "event: response\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + "\n\n", + )} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.NoError(t, err) + require.Zero(t, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + require.Equal(t, 1, body.closes) +} + +func TestSSEEndpointReturnsResponseBodyFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: &failingResponseBody{readErr: readErr, closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Watch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.ErrorIs(t, err, closeErr) +} + +func TestSSEEndpointReturnsContentTypeAndCloseFailures(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) +} + +func TestSSEEndpointContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} ` -const webSocketStatusRuntimeTest = `package client +const sseCancellationRuntimeTest = `package client import ( "context" + "errors" + "io" "net/http" - "net/http/httptest" - "strings" + "sync" "testing" "time" - "github.com/gorilla/websocket" "github.com/stretchr/testify/require" - service "generated.local/gen/web_socket_status" goahttp "goa.design/goa/v3/http" ) -func TestWebSocketViewedDecoderReceivesHTTPStatusOK(t *testing.T) { - acknowledged := make(chan struct{}) - serverErrors := make(chan error, 2) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - connection, err := (&websocket.Upgrader{}).Upgrade(writer, request, nil) - if err != nil { - serverErrors <- err - return - } - defer func() { - if err := connection.Close(); err != nil { - serverErrors <- err - } - }() - var message struct { - ID any ` + "`" + `json:"id"` + "`" + ` - } - if err := connection.ReadJSON(&message); err != nil { - serverErrors <- err - return - } - if err := connection.WriteJSON(map[string]any{ - "jsonrpc": "2.0", - "id": message.ID, - "result": map[string]any{ - "view": "summary", - "body": map[string]any{"label": "ready"}, - }, - }); err != nil { - serverErrors <- err - return - } - <-acknowledged - })) - t.Cleanup(server.Close) +type blockingResponseBody struct { + readStarted chan struct{} + closed chan struct{} + readOnce sync.Once + closeOnce sync.Once + closeErr error + closes int +} - statuses := make([]int, 0, 2) - decoder := func(response *http.Response) goahttp.Decoder { - statuses = append(statuses, response.StatusCode) - return goahttp.ResponseDecoder(response) +func newBlockingResponseBody(closeErr error) *blockingResponseBody { + return &blockingResponseBody{ + readStarted: make(chan struct{}), + closed: make(chan struct{}), + closeErr: closeErr, } - client := NewClient( - "http", strings.TrimPrefix(server.URL, "http://"), http.DefaultClient, - goahttp.RequestEncoder, decoder, false, websocket.DefaultDialer, nil, - ) - t.Cleanup(func() { - close(acknowledged) - require.NoError(t, client.Close()) +} + +func (body *blockingResponseBody) Read([]byte) (int, error) { + body.readOnce.Do(func() { + close(body.readStarted) + }) + <-body.closed + return 0, io.ErrClosedPipe +} + +func (body *blockingResponseBody) Close() error { + body.closeOnce.Do(func() { + body.closes++ + close(body.closed) }) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + return body.closeErr +} + +func TestRecvWithContextReturnsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + assertBlockedReceiveEndsWithContext(t, ctx, cancel, context.Canceled) +} + +func TestRecvWithContextReturnsDeadlineExceeded(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) defer cancel() - raw, err := client.Watch()(ctx, nil) - require.NoError(t, err) - stream := raw.(*WatchClientStream) - require.NoError(t, stream.Send(&service.WatchPayload{Key: "status"})) - _, err = stream.Recv() + assertBlockedReceiveEndsWithContext(t, ctx, func() {}, context.DeadlineExceeded) +} + +func assertBlockedReceiveEndsWithContext(t *testing.T, ctx context.Context, endContext func(), want error) { + t.Helper() + closeErr := errors.New("close failed") + body := newBlockingResponseBody(closeErr) + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + received := make(chan error, 1) + go func() { + _, err := stream.RecvWithContext(ctx) + received <- err + }() + <-body.readStarted + endContext() + select { - case serverErr := <-serverErrors: - require.NoError(t, serverErr) - default: + case err := <-received: + require.ErrorIs(t, err, want) + require.ErrorIs(t, err, closeErr) + case <-time.After(time.Second): + require.NoError(t, body.Close()) + err := <-received + t.Fatalf("receive remained blocked after context ended; returned %v after closing body", err) } - require.NoError(t, err) - require.NotEmpty(t, statuses) - for _, status := range statuses { - require.Equal(t, http.StatusOK, status) + select { + case <-body.closed: + default: + t.Fatal("receive returned without closing response body") } + _, err := stream.Recv() + require.ErrorIs(t, err, io.EOF) + require.Equal(t, 1, body.closes) } ` @@ -671,6 +957,223 @@ func TestSSEEventReturnsEveryWriteAndFlushError(t *testing.T) { } ` +const requestBodyFailureRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + goahttp "goa.design/goa/v3/http" +) + +type failingRequestBody struct { + readErr error + closeErr error +} + +type failingResponseWriter struct { + *httptest.ResponseRecorder + failAt int + writes int + writeErr error +} + +func (writer *failingResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failAt { + return 0, writer.writeErr + } + return writer.ResponseRecorder.Write(data) +} + +func (body *failingRequestBody) Read([]byte) (int, error) { + return 0, body.readErr +} + +func (body *failingRequestBody) Close() error { + return body.closeErr +} + +func TestServerReportsRequestReadAndCloseFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + var reported error + server := &Server{ + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = err + }, + } + request := httptest.NewRequest(http.MethodPost, "/unary", nil) + request.Body = &failingRequestBody{readErr: readErr, closeErr: closeErr} + + server.handleHTTP(httptest.NewRecorder(), request) + + require.ErrorIs(t, reported, readErr) + require.ErrorIs(t, reported, closeErr) +} + +func TestBatchWriterReturnsOpeningDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 1, + writeErr: writeErr, + } + writer := &batchWriter{Writer: response} + + _, err := writer.Write([]byte("{\"jsonrpc\":\"2.0\",\"result\":null}")) + + require.ErrorIs(t, err, writeErr) +} + +func TestServerReportsBatchClosingDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + var reported []error + server := &Server{ + decoder: goahttp.RequestDecoder, + encoder: goahttp.ResponseEncoder, + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + } + request := httptest.NewRequest( + http.MethodPost, + "/unary", + strings.NewReader("[{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"missing\"}]"), + ) + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 3, + writeErr: writeErr, + } + + server.handleHTTP(response, request) + + require.ErrorIs(t, errors.Join(reported...), writeErr) +} +` + +const sseLifecycleRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +var errWatch = goa.NewServiceError(errors.New("watch failed"), "watch_failed", false, false, false) + +type lifecycleService struct { + fail bool +} + +func (s *lifecycleService) Watch(_ context.Context, _ *service.WatchPayload, stream service.WatchServerStream) error { + if err := stream.Send(&service.WatchResult{Message: "ready"}); err != nil { + return err + } + if s.fail { + return errWatch + } + return nil +} + +func TestSSEStreamWritesNotificationThenNullCompletion(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, "event: notification") + response := strings.Index(body, "event: response") + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.Contains(t, body, ` + "`" + `"method":"watch"` + "`" + `) + require.Contains(t, body, ` + "`" + `"params":{"message":"ready"}` + "`" + `) + require.Contains(t, body, ` + "`" + `"id":"request-1"` + "`" + `) + require.Contains(t, body, ` + "`" + `"result":null` + "`" + `) +} + +func TestSSEStreamWritesReturnedErrorAsTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{fail: true}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, "event: notification") + response := strings.Index(body, "event: error") + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.Contains(t, body, ` + "`" + `"code":-32603` + "`" + `) + require.Contains(t, body, ` + "`" + `"message":"watch failed"` + "`" + `) +} + +func TestSSENotificationRequestHasNoTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, body, "event: notification") + require.NotContains(t, body, "event: response") + require.NotContains(t, body, "event: error") +} + +func TestSSENotificationServiceErrorHasNoTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{fail: true}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, body, "event: notification") + require.NotContains(t, body, "event: response") + require.NotContains(t, body, "event: error") +} + +func TestSSENotificationDecodeErrorHasNoResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{}}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, body) +} + +func TestSSEUnknownNotificationReceivesNoResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, body) +} + +func TestSSEInvalidRequestsReceiveError(t *testing.T) { + for _, request := range []string{ + ` + "`" + `{"jsonrpc":"2.0"}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `, + } { + body, reported := serveLifecycle(&lifecycleService{}, request) + require.Empty(t, reported) + require.Contains(t, body, "event: error") + require.Contains(t, body, ` + "`" + `"id":null` + "`" + `) + require.Contains(t, body, ` + "`" + `"code":-32600` + "`" + `) + } +} + +func serveLifecycle(svc *lifecycleService, body string) (string, []error) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader(body)) + server.ServeHTTP(writer, request) + return writer.Body.String(), reported +} +` + const unaryMetadataRuntimeTest = `package server import ( diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go deleted file mode 100644 index 959baf9f9f..0000000000 --- a/jsonrpc/codegen/websocket_client.go +++ /dev/null @@ -1,146 +0,0 @@ -// This file renders the JSON-RPC WebSocket client for each service and adds -// the imports used by that service's methods. -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -type ( - // websocketClientTemplateData stores the names and request and result values - // used to write one method stream. - websocketClientTemplateData struct { - *httpcodegen.JSONRPCWebSocketData - // Endpoint contains the request and result values for this method. - Endpoint *endpointPlan - // Pending is the type that waits for one method result. - Pending *codegen.NameDeclaration - // Result is the type passed from the shared reader to the waiting method. - Result *codegen.NameDeclaration - // Connection is the WebSocket connection shared by all methods. - Connection *codegen.NameDeclaration - // RequestOwner is the type that marks one method stream closed. - RequestOwner *codegen.NameDeclaration - // ClosedError is returned after the method stream closes. - ClosedError *codegen.NameDeclaration - } - - // websocketErrorTemplateData stores the public error names written by one - // WebSocket client. - websocketErrorTemplateData struct { - // Type is the error category type. - Type *codegen.NameDeclaration - // Connection identifies connection failures. - Connection *codegen.NameDeclaration - // Protocol identifies invalid JSON-RPC messages. - Protocol *codegen.NameDeclaration - // Parsing identifies messages that cannot be decoded. - Parsing *codegen.NameDeclaration - // Orphaned identifies responses that match no request. - Orphaned *codegen.NameDeclaration - // Timeout identifies requests that waited too long. - Timeout *codegen.NameDeclaration - // Handler is the function type used to report a stream error. - Handler *codegen.NameDeclaration - } -) - -// websocketClientFile returns the client file for the WebSocket endpoints in -// planned. It returns nil when the service has no WebSocket endpoint. -func websocketClientFile(planned *servicePlan) *codegen.File { - data := planned.data - if !planned.hasWebSocket { - return nil - } - - svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket JSON-RPC client", planned.name) - - // These imports are shared by every generated WebSocket method stream. - imports := make([]*codegen.ImportSpec, 0, 11) - imports = append(imports, - &codegen.ImportSpec{Path: "bytes"}, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "sync"}, - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), - ) - - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", imports), - } - - // Generate the error types used by every method stream. - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-stream-error-types", - Source: jsonrpcTemplates.Read(websocketStreamErrorTypesT), - Data: &websocketErrorTemplateData{ - Type: planned.clientNames.streamErrorType, - Connection: planned.clientNames.streamErrorConnection, - Protocol: planned.clientNames.streamErrorProtocol, - Parsing: planned.clientNames.streamErrorParsing, - Orphaned: planned.clientNames.streamErrorOrphaned, - Timeout: planned.clientNames.streamErrorTimeout, - Handler: planned.clientNames.streamErrorHandler, - }, - }) - - // Generate a method stream only for endpoints carried over WebSocket. - for _, e := range planned.endpoints { - if !isJSONRPCWebSocketEndpoint(e) { - continue - } - - funcs := viewedResultFuncs(planned) - funcs["lowerInitial"] = lowerInitial - // client.go creates this method stream and websocket.go implements it. - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-client-stream", - Source: jsonrpcTemplates.Read(websocketClientStreamT), - Data: &websocketClientTemplateData{ - JSONRPCWebSocketData: e.ClientWebSocket, - Endpoint: e, - Pending: e.websocketPending, - Result: e.websocketResult, - Connection: planned.clientNames.websocketConnection, - RequestOwner: planned.clientNames.websocketRequestOwner, - ClosedError: planned.clientNames.websocketClosedError, - }, - FuncMap: funcs, - }) - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "websocket.go"), - SectionTemplates: sections, - } -} - -// allErrors returns each named service error once so the generated WebSocket -// server writes one branch for each error. -func allErrors(data httpcodegen.JSONRPCServiceSnapshot) []*httpcodegen.JSONRPCErrorData { - seen := make(map[string]struct{}) - var errors []*httpcodegen.JSONRPCErrorData - for _, e := range data.Endpoints { - for _, gerr := range e.Errors { - for index := range gerr.Errors { - err := &gerr.Errors[index] - if _, ok := seen[err.Name]; ok { - continue - } - seen[err.Name] = struct{}{} - errors = append(errors, err) - } - } - } - return errors -} diff --git a/jsonrpc/codegen/websocket_connection_runtime_test.go b/jsonrpc/codegen/websocket_connection_runtime_test.go deleted file mode 100644 index 5e0bcd5daf..0000000000 --- a/jsonrpc/codegen/websocket_connection_runtime_test.go +++ /dev/null @@ -1,699 +0,0 @@ -// This file renders a JSON-RPC WebSocket client and runs its request, response, -// timeout, close, and concurrent-send tests with Go's race detector. -package codegen_test - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/require" - - goacodegen "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" - jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" -) - -// TestGeneratedWebSocketClientAndServer renders the Chat WebSocket packages -// and runs their request, response, failure, and close tests with Go's race -// detector. -func TestGeneratedWebSocketClientAndServer(t *testing.T) { - dir := renderWebSocketRuntimeModule(t) - clientDir := filepath.Join(dir, "jsonrpc", "chat", "client") - serverDir := filepath.Join(dir, "jsonrpc", "chat", "server") - require.NoError(t, os.WriteFile(filepath.Join(clientDir, "websocket_client_test.go"), []byte(websocketClientTest), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(serverDir, "websocket_server_test.go"), []byte(websocketServerTest), 0o600)) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "./jsonrpc/chat/client", "./jsonrpc/chat/server") - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GOWORK=off") - output, err := cmd.CombinedOutput() - require.NoError(t, err, string(output)) -} - -// renderWebSocketRuntimeModule writes the service, client, and server files -// needed by the generated tests. The temporary module uses this Goa checkout. -func renderWebSocketRuntimeModule(t *testing.T) string { - t.Helper() - root := expr.RunDSL(t, websocketConnectionDSL) - generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) - require.NoError(t, err) - plan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) - httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ - Root: root, - Service: plan, - }) - require.NoError(t, err) - jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ - Root: root, - Service: plan, - HTTP: httpPlans[0], - }) - require.NoError(t, err) - require.NoError(t, example.Plan(generation)) - require.NoError(t, generation.Freeze()) - require.NoError(t, plan.Link()) - require.NoError(t, httpPlans[0].Link()) - require.NoError(t, jsonPlans[0].Link()) - - files, err := service.Files(plan) - require.NoError(t, err) - files = append(files, jsonPlans[0].ClientFiles()...) - files = append(files, jsonPlans[0].ServerFiles()...) - files = append(files, jsonPlans[0].ClientTypeFiles()...) - files = append(files, jsonPlans[0].ServerTypeFiles()...) - files = append(files, jsonPlans[0].PathFiles()...) - - base := t.TempDir() - for _, file := range files { - _, err := file.Render(base) - require.NoError(t, err) - } - moduleDir := filepath.Join(base, goacodegen.Gendir) - workingDir, err := os.Getwd() - require.NoError(t, err) - repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) - goMod := fmt.Sprintf("module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", repository) - require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) - return moduleDir -} - -const websocketClientTest = `package client - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - chat "generated.local/gen/chat" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -type contextKey string - -type callbackResult struct { - contextValue any - err error -} - -type errorEvent struct { - type_ jsonrpc.StreamErrorType - contextValue any - err error - response *jsonrpc.RawResponse -} - -type routedResponse struct { - stream string - response *jsonrpc.RawResponse -} - -func TestMethodCloseWhileRecvWaitsForResponse(t *testing.T) { - client, conn, stop := newClientConnection(t, nil, serverConfig{}) - defer stop() - defer closeClient(t, client) - for range 100 { - streamCtx, cancel := context.WithCancel(context.Background()) - stream := &EchoClientStream{ - conn: conn, - owner: &websocketRequestOwner{}, - ctx: streamCtx, - cancel: cancel, - pendingReady: make(chan struct{}, 1), - } - pending := &echoClientStreamPendingRequest{ - resultChan: make(chan echoClientStreamStreamResult, 1), - } - id, err := conn.sendRequest(context.Background(), request("close-pending"), stream.owner, func(ctx context.Context, response *jsonrpc.RawResponse, err error) { - stream.completeResponse(ctx, pending, response, err) - }) - if err != nil { - t.Fatal(err) - } - pending.id = id - stream.enqueuePending(pending) - received := make(chan error, 1) - go func() { - _, err := stream.Recv() - received <- err - }() - if err := stream.Close(); err != nil { - t.Fatal(err) - } - if err := receive(t, received); err != errWebsocketMethodStreamClosed { - t.Fatalf("Recv after Close returned %v", err) - } - } -} - -func TestMethodCloseWhileRecvWaitsForFirstSend(t *testing.T) { - client, conn, stop := newClientConnection(t, nil, serverConfig{}) - defer stop() - defer closeClient(t, client) - for range 100 { - streamCtx, cancel := context.WithCancel(context.Background()) - stream := &EchoClientStream{ - conn: conn, - owner: &websocketRequestOwner{}, - ctx: streamCtx, - cancel: cancel, - pendingReady: make(chan struct{}, 1), - } - started := make(chan struct{}) - received := make(chan error, 1) - go func() { - close(started) - _, err := stream.Recv() - received <- err - }() - <-started - if err := stream.Close(); err != nil { - t.Fatal(err) - } - if err := receive(t, received); err != errWebsocketMethodStreamClosed { - t.Fatalf("Recv before Send returned %v after Close", err) - } - } -} - -func TestConcurrentMethodStreamsReceiveTheirResponses(t *testing.T) { - client, conn, stop := newClientConnection(t, nil, serverConfig{reverseResponses: true}) - defer stop() - defer closeClient(t, client) - - type sentRequest struct { - stream string - id string - err error - } - routed := make(chan routedResponse, 2) - sent := make(chan sentRequest, 2) - start := make(chan struct{}) - for _, stream := range []string{"alpha", "beta"} { - stream := stream - go func() { - <-start - id, err := conn.sendRequest(context.Background(), request(stream), &websocketRequestOwner{}, func(_ context.Context, response *jsonrpc.RawResponse, err error) { - if err != nil { - t.Errorf("complete %s: %v", stream, err) - return - } - routed <- routedResponse{stream: stream, response: response} - }) - sent <- sentRequest{stream: stream, id: id, err: err} - }() - } - close(start) - - ids := make(map[string]string, 2) - for range 2 { - request := receive(t, sent) - if request.err != nil { - t.Fatalf("send %s: %v", request.stream, request.err) - } - ids[request.stream] = request.id - } - if ids["alpha"] == ids["beta"] { - t.Fatalf("connection reused request ID %q", ids["alpha"]) - } - for range 2 { - response := receive(t, routed) - if got := jsonrpc.IDToString(response.response.ID); got != ids[response.stream] { - t.Errorf("%s response routed with ID %q, want %q", response.stream, got, ids[response.stream]) - } - if !strings.Contains(string(response.response.Result), response.stream) { - t.Errorf("%s callback received another method's result: %s", response.stream, response.response.Result) - } - } -} - -func TestClosedMethodStreamRejectsRequestsAndNotifications(t *testing.T) { - client, conn, stop := newClientConnection(t, nil, serverConfig{}) - defer stop() - defer closeClient(t, client) - owner := &websocketRequestOwner{} - ctx := context.WithValue(context.Background(), contextKey("request"), "closed-owner") - completed := make(chan callbackResult, 1) - _, err := conn.sendRequest(ctx, request("first"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { - if terminalErr := conn.terminalError(); terminalErr != nil { - t.Errorf("owner close made connection terminal: %v", terminalErr) - } - completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} - }) - if err != nil { - t.Fatal(err) - } - conn.closeOwner(owner) - result := receive(t, completed) - if result.contextValue != "closed-owner" || result.err == nil { - t.Fatalf("unexpected completion: %#v", result) - } - if _, err := conn.sendRequest(ctx, request("after-close"), owner, func(context.Context, *jsonrpc.RawResponse, error) {}); err == nil || !strings.Contains(err.Error(), "stream is closed") { - t.Fatalf("request after close error = %v", err) - } - if err := conn.sendNotification(ctx, request("after-close"), owner); err == nil || !strings.Contains(err.Error(), "stream is closed") { - t.Fatalf("notification after close error = %v", err) - } - conn.stateMu.Lock() - pending := len(conn.pending) - conn.stateMu.Unlock() - if pending != 0 { - t.Fatalf("closed owner retained %d pending requests", pending) - } -} - -func TestRequestTimeoutReturnsWithoutRecv(t *testing.T) { - events := make(chan errorEvent, 4) - var conn *websocketClientConn - client, gotConn, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if terminalErr := connTerminalError(conn); terminalErr != nil { - t.Errorf("timeout made connection terminal: %v", terminalErr) - } - events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} - }, serverConfig{}, jsonrpc.WithRequestTimeout(20*time.Millisecond)) - conn = gotConn - defer stop() - defer closeClient(t, client) - owner := &websocketRequestOwner{} - ctx := context.WithValue(context.Background(), contextKey("request"), "timeout-request") - completed := make(chan callbackResult, 1) - _, err := conn.sendRequest(ctx, request("timeout"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { - if terminalErr := conn.terminalError(); terminalErr != nil { - t.Errorf("timeout completion made connection terminal: %v", terminalErr) - } - completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} - }) - if err != nil { - t.Fatal(err) - } - result := receive(t, completed) - if result.contextValue != "timeout-request" || result.err == nil || !strings.Contains(result.err.Error(), "timed out") { - t.Fatalf("unexpected timeout completion: %#v", result) - } - event := receive(t, events) - if event.type_ != jsonrpc.StreamErrorTimeout || event.contextValue != "timeout-request" { - t.Fatalf("unexpected timeout event: %#v", event) - } - conn.stateMu.Lock() - pending := len(conn.pending) - conn.stateMu.Unlock() - if pending != 0 { - t.Fatalf("timeout retained %d pending requests", pending) - } -} - -func TestConnectionFailureUsesConnectionContext(t *testing.T) { - events := make(chan errorEvent, 8) - closeAfterRequest := make(chan struct{}) - var conn *websocketClientConn - client, gotConn, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if conn != nil { - if terminalErr := conn.terminalError(); terminalErr == nil { - t.Error("connection error callback ran before terminal state") - } - } - events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} - }, serverConfig{requestHook: closeAfterRequest}) - conn = gotConn - defer stop() - defer closeClient(t, client) - owner := &websocketRequestOwner{} - ctx := context.WithValue(context.Background(), contextKey("request"), "failed-request") - completed := make(chan callbackResult, 1) - _, err := conn.sendRequest(ctx, request("fail"), owner, func(ctx context.Context, _ *jsonrpc.RawResponse, err error) { - if terminalErr := conn.terminalError(); terminalErr == nil { - t.Error("failure completion ran before terminal state") - } - completed <- callbackResult{contextValue: ctx.Value(contextKey("request")), err: err} - }) - if err != nil { - t.Fatal(err) - } - close(closeAfterRequest) - result := receive(t, completed) - if result.contextValue != "failed-request" || result.err == nil { - t.Fatalf("unexpected failure completion: %#v", result) - } - for { - event := receive(t, events) - if event.type_ == jsonrpc.StreamErrorConnection { - if event.contextValue != nil { - t.Fatalf("connection event inherited request context: %#v", event.contextValue) - } - break - } - } - if err := conn.sendNotification(context.Background(), request("failed"), &websocketRequestOwner{}); err == nil { - t.Fatal("terminal connection accepted a notification") - } -} - -func TestEchoRecvReturnsConnectionFailure(t *testing.T) { - closeAfterRequest := make(chan struct{}) - client, conn, stop := newClientConnection(t, nil, serverConfig{requestHook: closeAfterRequest}) - defer stop() - defer closeClient(t, client) - streamCtx, cancel := context.WithCancel(context.Background()) - stream := &EchoClientStream{ - conn: conn, - owner: &websocketRequestOwner{}, - ctx: streamCtx, - cancel: cancel, - pendingReady: make(chan struct{}, 1), - } - if err := stream.Send(&chat.EchoPayload{}); err != nil { - t.Fatal(err) - } - received := make(chan error, 1) - go func() { - _, err := stream.Recv() - received <- err - }() - close(closeAfterRequest) - err := receive(t, received) - if err == errWebsocketMethodStreamClosed { - t.Fatalf("Recv returned the method Close error after a socket failure: %v", err) - } - connectionErr := conn.terminalError() - if connectionErr == nil { - t.Fatal("connection has no error after the server closed the socket") - } - if err != connectionErr { - t.Fatalf("Recv error = %v, want the connection error %v", err, connectionErr) - } -} - -func TestServerNotificationsAndNullIDsReportCorrectErrors(t *testing.T) { - events := make(chan errorEvent, 8) - serverMessages := make(chan []any, 1) - serverMessages <- []any{ - map[string]any{"jsonrpc": "2.0", "method": "tick", "params": map[string]any{"value": 1}}, - map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32603, "message": "failed"}}, - } - client, _, stop := newClientConnection(t, func(ctx context.Context, type_ jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - events <- errorEvent{type_: type_, contextValue: ctx.Value(contextKey("request")), err: err, response: response} - }, serverConfig{messages: serverMessages}) - defer stop() - defer closeClient(t, client) - notification := receive(t, events) - if notification.type_ != jsonrpc.StreamErrorNotification || !strings.Contains(notification.err.Error(), "tick") { - t.Fatalf("method notification misclassified: %#v", notification) - } - nullID := receive(t, events) - if nullID.type_ != jsonrpc.StreamErrorProtocol || nullID.response == nil || nullID.response.Error == nil { - t.Fatalf("null-ID error misclassified: %#v", nullID) - } -} - -func TestMethodCloseRejectsConcurrentRequests(t *testing.T) { - client, conn, stop := newClientConnection(t, nil, serverConfig{}) - defer stop() - defer closeClient(t, client) - owner := &websocketRequestOwner{} - var sends sync.WaitGroup - for i := 0; i < 32; i++ { - sends.Add(1) - go func(index int) { - defer sends.Done() - _, err := conn.sendRequest(context.Background(), request(fmt.Sprintf("race-%d", index)), owner, func(context.Context, *jsonrpc.RawResponse, error) {}) - if err != nil && !strings.Contains(err.Error(), "stream is closed") { - t.Errorf("concurrent send: %v", err) - } - }(i) - } - conn.closeOwner(owner) - sends.Wait() - if _, err := conn.sendRequest(context.Background(), request("after-race"), owner, func(context.Context, *jsonrpc.RawResponse, error) {}); err == nil { - t.Fatal("closed owner accepted request after concurrent sends") - } - conn.stateMu.Lock() - pending := len(conn.pending) - conn.stateMu.Unlock() - if pending != 0 { - t.Fatalf("owner close race retained %d requests", pending) - } -} - -type serverConfig struct { - requestHook <-chan struct{} - messages <-chan []any - reverseResponses bool -} - -func newClientConnection(t *testing.T, handler jsonrpc.StreamErrorHandler, config serverConfig, options ...jsonrpc.StreamConfigOption) (*Client, *websocketClientConn, func()) { - t.Helper() - streamOptions := append([]jsonrpc.StreamConfigOption(nil), options...) - if handler != nil { - streamOptions = append(streamOptions, jsonrpc.WithErrorHandler(handler)) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ws, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err != nil { - return - } - defer func() { - if err := ws.Close(); err != nil { - t.Errorf("close server WebSocket: %v", err) - } - }() - if config.messages != nil { - for _, message := range <-config.messages { - if err := ws.WriteJSON(message); err != nil { - return - } - } - } - if config.reverseResponses { - requests := make([]jsonrpc.RawRequest, 2) - for i := range requests { - if err := ws.ReadJSON(&requests[i]); err != nil { - return - } - } - for i := len(requests) - 1; i >= 0; i-- { - result := map[string]any{"method": requests[i].Method} - if err := ws.WriteJSON(jsonrpc.MakeSuccessResponse(requests[i].ID, result)); err != nil { - return - } - } - } - for { - var message any - if err := ws.ReadJSON(&message); err != nil { - return - } - if config.requestHook != nil { - <-config.requestHook - return - } - } - })) - host := strings.TrimPrefix(server.URL, "http://") - client := NewClient("http", host, http.DefaultClient, goahttp.RequestEncoder, goahttp.ResponseDecoder, false, websocket.DefaultDialer, nil, streamOptions...) - conn, err := client.getConn(context.Background()) - if err != nil { - server.Close() - t.Fatal(err) - } - return client, conn, server.Close -} - -func request(method string) *jsonrpc.Request { - return &jsonrpc.Request{JSONRPC: "2.0", Method: method} -} - -func receive[T any](t *testing.T, channel <-chan T) T { - t.Helper() - select { - case value := <-channel: - return value - case <-time.After(3 * time.Second): - var zero T - t.Fatal("timed out waiting for generated WebSocket lifecycle event") - return zero - } -} - -func connTerminalError(conn *websocketClientConn) error { - if conn == nil { - return nil - } - return conn.terminalError() -} - -func closeClient(t *testing.T, client *Client) { - t.Helper() - if err := client.Close(); err != nil { - t.Errorf("close client: %v", err) - } -} -` - -const websocketServerTest = `package server - -import ( - "bufio" - "errors" - "fmt" - "net" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" -) - -type trackedConn struct { - net.Conn - closed chan struct{} - once sync.Once -} - -type trackingWriter struct { - http.ResponseWriter - connections chan<- *trackedConn -} - -func TestCloseSendsNormalCodeWithoutWaitingForDataWrite(t *testing.T) { - stream, peer, _, stop := newServerConnection(t) - defer stop() - peerResult := make(chan error, 1) - go func() { - _, _, err := peer.ReadMessage() - peerResult <- err - }() - stream.writeMu.Lock() - defer stream.writeMu.Unlock() - closeResult := make(chan error, 1) - go func() { - closeResult <- stream.Close() - }() - if err := receiveServer(t, closeResult); err != nil { - t.Fatal(err) - } - err := receiveServer(t, peerResult) - var closeErr *websocket.CloseError - if !errors.As(err, &closeErr) || closeErr.Code != websocket.CloseNormalClosure { - t.Fatalf("peer close error = %v, want code %d", err, websocket.CloseNormalClosure) - } -} - -func TestCloseClosesSocketWhenNormalMessageFails(t *testing.T) { - stream, peer, connection, stop := newServerConnection(t) - defer stop() - tcp, ok := peer.UnderlyingConn().(*net.TCPConn) - if !ok { - t.Fatalf("peer connection type = %T, want *net.TCPConn", peer.UnderlyingConn()) - } - if err := tcp.SetLinger(0); err != nil { - t.Fatal(err) - } - if err := peer.Close(); err != nil { - t.Fatal(err) - } - if err := stream.conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatal(err) - } - if _, _, err := stream.conn.ReadMessage(); err == nil { - t.Fatal("server read succeeded after the peer reset the connection") - } - err := stream.Close() - if err == nil || !strings.Contains(err.Error(), "write normal WebSocket close message") { - t.Fatalf("Close error = %v, want normal-close write failure", err) - } - select { - case <-connection.closed: - case <-time.After(3 * time.Second): - t.Fatal("Close did not close the server socket after the control write failed") - } -} - -func (c *trackedConn) Close() error { - c.once.Do(func() { - close(c.closed) - }) - return c.Conn.Close() -} - -func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - hijacker, ok := w.ResponseWriter.(http.Hijacker) - if !ok { - return nil, nil, fmt.Errorf("HTTP response writer does not support connection takeover") - } - connection, buffer, err := hijacker.Hijack() - if err != nil { - return nil, nil, err - } - tracked := &trackedConn{Conn: connection, closed: make(chan struct{})} - w.connections <- tracked - return tracked, buffer, nil -} - -func newServerConnection(t *testing.T) (*chatStream, *websocket.Conn, *trackedConn, func()) { - t.Helper() - serverConnections := make(chan *websocket.Conn, 1) - trackedConnections := make(chan *trackedConn, 1) - release := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writer := &trackingWriter{ResponseWriter: w, connections: trackedConnections} - connection, err := (&websocket.Upgrader{}).Upgrade(writer, r, nil) - if err != nil { - t.Errorf("upgrade server connection: %v", err) - return - } - serverConnections <- connection - <-release - })) - peer, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) - if err != nil { - server.Close() - t.Fatal(err) - } - connection := receiveServer(t, serverConnections) - tracked := receiveServer(t, trackedConnections) - var once sync.Once - stop := func() { - once.Do(func() { - close(release) - if err := peer.Close(); err != nil && !errors.Is(err, net.ErrClosed) { - t.Errorf("close peer WebSocket: %v", err) - } - server.Close() - }) - } - return &chatStream{conn: connection}, peer, tracked, stop -} - -func receiveServer[T any](t *testing.T, values <-chan T) T { - t.Helper() - select { - case value := <-values: - return value - case <-time.After(3 * time.Second): - var zero T - t.Fatal("timed out waiting for generated WebSocket server test") - return zero - } -} -` diff --git a/jsonrpc/codegen/websocket_connection_test.go b/jsonrpc/codegen/websocket_connection_test.go deleted file mode 100644 index 6501da879e..0000000000 --- a/jsonrpc/codegen/websocket_connection_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// This file verifies that every generated JSON-RPC method uses the same -// WebSocket, while one reader matches each response to the request with that ID. -package codegen_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - goacodegen "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" - . "goa.design/goa/v3/dsl" - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" - jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" -) - -// websocketConnectionDSL defines one bidirectional WebSocket method so these -// tests do not depend on unrelated HTTP, SSE, or unary JSON-RPC generation. -var websocketConnectionDSL = func() { - API("websocket-connection", func() { - JSONRPC(func() {}) - }) - Service("Chat", func() { - JSONRPC(func() { - Path("/ws") - }) - Method("echo", func() { - StreamingPayload(func() { - ID("id", String, "Request ID") - Attribute("msg", String) - }) - StreamingResult(func() { - ID("id", String, "Request ID") - Attribute("echo", String) - }) - JSONRPC(func() {}) - }) - }) -} - -// TestGeneratedWebSocketSharesOneConnection proves that one generated client -// reads and writes its shared WebSocket in one place. Each method stream keeps -// the requests that its Recv calls will return. -func TestGeneratedWebSocketSharesOneConnection(t *testing.T) { - root := expr.RunDSL(t, websocketConnectionDSL) - generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) - require.NoError(t, err) - plan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) - require.NoError(t, err) - httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: plan}) - require.NoError(t, err) - jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ - Root: root, - Service: plan, - HTTP: httpPlans[0], - }) - require.NoError(t, err) - require.NoError(t, example.Plan(generation)) - require.NoError(t, generation.Freeze()) - require.NoError(t, plan.Link()) - require.NoError(t, httpPlans[0].Link()) - require.NoError(t, jsonPlans[0].Link()) - - dir := t.TempDir() - for _, file := range append(jsonPlans[0].ClientFiles(), jsonPlans[0].ServerFiles()...) { - _, err := file.Render(dir) - require.NoError(t, err) - } - - client := readGeneratedFile(t, dir, "gen/jsonrpc/chat/client/client.go") - clientStream := readGeneratedFile(t, dir, "gen/jsonrpc/chat/client/websocket.go") - serverStream := readGeneratedFile(t, dir, "gen/jsonrpc/chat/server/websocket.go") - - require.Contains(t, client, "websocketClientConn struct") - require.Contains(t, client, "atomic.Uint64") - require.Contains(t, client, "pending map[string]*websocketPendingRequest") - require.Contains(t, client, "go conn.readResponses()") - require.Contains(t, client, "case owner.closed.Load():") - require.Contains(t, client, "time.AfterFunc(c.config.RequestTimeout") - require.Contains(t, client, "request.complete(request.ctx, nil, err)") - require.Contains(t, client, "func (c *websocketClientConn) closeSocket() error") - require.Contains(t, client, "var errWebsocketMethodStreamClosed = errors.New(") - require.NotContains(t, client, "websocket.PingMessage") - require.Equal(t, 1, strings.Count(client, "ReadJSON(")) - require.NotContains(t, clientStream, "ReadJSON(") - require.NotContains(t, clientStream, "idGenerator") - require.NotContains(t, clientStream, "writeMu") - require.Contains(t, clientStream, "*websocketClientConn") - require.Contains(t, clientStream, "s.conn.closeOwner(s.owner") - require.Contains(t, clientStream, "return errWebsocketMethodStreamClosed") - require.NotContains(t, clientStream, "EchoClientStreamPendingRequest") - require.NotContains(t, clientStream, "EchoClientStreamStreamResult") - require.Contains(t, clientStream, "echoClientStreamPendingRequest") - require.Contains(t, clientStream, "echoClientStreamStreamResult") - - require.Contains(t, serverStream, "writeMu sync.Mutex") - require.Contains(t, serverStream, "func (s *chatStream) writeJSON(") - require.Equal(t, 1, strings.Count(serverStream, "s.conn.WriteJSON(")) - require.Contains(t, serverStream, "websocket.FormatCloseMessage(websocket.CloseNormalClosure") - require.Contains(t, serverStream, "time.Now().Add(time.Second)") - require.Contains(t, serverStream, "closeErr := s.conn.Close()") - require.Contains(t, serverStream, "return errors.Join(controlErr, closeErr)") - require.NotContains(t, serverStream, "func (s *chatStream) Close() error {\n\ts.writeMu.Lock()") -} - -// readGeneratedFile returns generated source inspected by the checks above. -func readGeneratedFile(t *testing.T, dir, path string) string { - t.Helper() - content, err := os.ReadFile(filepath.Join(dir, path)) - require.NoError(t, err) - return string(content) -} diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go deleted file mode 100644 index a36e71d3a9..0000000000 --- a/jsonrpc/codegen/websocket_server.go +++ /dev/null @@ -1,117 +0,0 @@ -// This file renders the JSON-RPC WebSocket server for each service and adds -// the imports used by that service's methods. -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -type ( - // websocketServerTemplateData stores the service values and shared stream - // name used by one WebSocket server. - websocketServerTemplateData struct { - httpcodegen.JSONRPCServiceSnapshot - // Stream is the WebSocket used by all methods in this server. - Stream *codegen.NameDeclaration - } -) - -// websocketServerFile returns the generated WebSocket server when the service -// has at least one WebSocket method. -func websocketServerFile(planned *servicePlan) *codegen.File { - data := planned.data - if !planned.hasWebSocket { - return nil - } - funcs := map[string]any{ - "lowerInitial": lowerInitial, - "allErrors": allErrors, - "isWebSocketEndpoint": isJSONRPCWebSocketEndpoint, - "websocketServerStreamName": planned.websocketServerStreamName, - "websocketWrapperName": planned.websocketWrapperName, - } - for name, function := range viewedResultFuncs(planned) { - funcs[name] = function - } - svcName := data.Service.PathName - renderData := &websocketServerTemplateData{ - JSONRPCServiceSnapshot: data, - Stream: planned.serverNames.websocketStream, - } - title := fmt.Sprintf("%s WebSocket server streaming", planned.name) - imports := make([]*codegen.ImportSpec, 0, 14) - imports = append(imports, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "errors"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strings"}, - &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - data.ServiceImport(), - ) - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), - { - Name: "jsonrpc-server-websocket-struct", - Source: jsonrpcTemplates.Read(websocketServerStreamT), - Data: renderData, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-stream-wrapper", - Source: jsonrpcTemplates.Read(websocketServerStreamWrapperT), - Data: renderData, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-send", - Source: jsonrpcTemplates.Read(websocketServerSendT), - Data: renderData, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-recv", - Source: jsonrpcTemplates.Read(websocketServerRecvT), - Data: renderData, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-close", - Source: jsonrpcTemplates.Read(websocketServerCloseT), - Data: renderData, - FuncMap: funcs, - }, - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "websocket.go"), - SectionTemplates: sections, - } -} - -// websocketServerStreamName returns the WebSocket type shared by all methods -// in this service. -func (s *servicePlan) websocketServerStreamName() string { - return s.serverNames.websocketStream.Name() -} - -// websocketWrapperName returns the type that gives one method access to its -// request ID and selected result view. -func (s *servicePlan) websocketWrapperName(method string) string { - names := s.endpointNames[method] - if names == nil || names.websocketWrapper == nil { - panic("JSON-RPC WebSocket wrapper requested for method " + method) - } - return names.websocketWrapper.Name() -} diff --git a/jsonrpc/doc.go b/jsonrpc/doc.go index 7f4c1d1f6f..f31966dc2e 100644 --- a/jsonrpc/doc.go +++ b/jsonrpc/doc.go @@ -12,7 +12,7 @@ // - Notification requests (fire-and-forget) // - Batch requests for multiple calls // - Structured error handling with error codes -// - HTTP, Server-Sent Events (SSE) and WebSocket transports +// - HTTP requests and server streams sent as Server-Sent Events (SSE) // // Code generated by Goa uses this package to create JSON-RPC clients and // servers that seamlessly integrate with Goa's design-first approach and diff --git a/jsonrpc/integration_tests/README.md b/jsonrpc/integration_tests/README.md index eac368734e..9b5ad2f45d 100644 --- a/jsonrpc/integration_tests/README.md +++ b/jsonrpc/integration_tests/README.md @@ -202,32 +202,10 @@ Use `request` to initiate, `sequence` for the event stream: # ... more events ``` -#### WebSocket Tests (Bidirectional Messages) -Use only `sequence` for back-and-forth communication: -```yaml -- name: "websocket_test" - method: "echo_string_ws" - transport: "websocket" - sequence: # Series of sends and receives - - type: "connect" # Optional: explicit connection - - type: "send" - data: - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello" - id: "ws-1" - - type: "receive" - expect: - id: "ws-1" - result: - value: "hello" - - type: "close" # Close the connection -``` - ## 📜 Method Naming Convention -Server behavior is determined entirely by the method name, which follows the pattern: `[action]_[type]_[modifier]`. +Server behavior is determined entirely by the method name. Unary methods use +`[action]_[type]_[modifier]`; SSE methods add the `_sse` suffix. ### Quick Reference Table @@ -251,9 +229,7 @@ Server behavior is determined entirely by the method name, which follows the pat * `echo`: Returns the `params` payload exactly as it was received. * `transform`: Returns a predictably modified version of the `params`. * `generate`: Ignores `params` and returns a fixed, predictable value. - * `stream`: (SSE/WebSocket) Sends a stream of messages to the client. Ideal for testing server-streaming RPC. - * `collect`: (WebSocket) Receives a stream of messages from a client and returns a single summary response after the stream is closed. Useful for testing client-streaming RPC. - * `broadcast`: (WebSocket) Tests the server's ability to send unsolicited messages to a client (server-initiated notifications). + * `stream`: Sends a stream of server-sent events to the client. ### Types and Their Structure @@ -292,7 +268,6 @@ Server behavior is determined entirely by the method name, which follows the pat * `_notify`: Indicates a JSON-RPC notification (no response expected). * `_error`: The method is hardcoded to always return a predefined JSON-RPC error. * `_validate`: The method includes Goa validation logic on the payload, which will return an error if the payload is invalid. - * `_final`: (SSE) The method sends several notifications before sending a final, ID-tagged response. ## 📊 Data-Driven Behavior @@ -347,7 +322,7 @@ sequence: value: "generated-3" ``` -#### `stream` Action (SSE/WebSocket) +#### `stream` Action (SSE) The payload data controls the streaming behavior: **For `string` type:** @@ -433,29 +408,6 @@ sequence: ### Modifier Effects -**`_final` modifier (SSE):** -Sends notifications followed by a final response with the request ID: - -```yaml -# Example: stream_string_final_sse -request: - params: "ab" # 2 characters = 2 notifications - id: "req-1" -sequence: - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 1 of 2" - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 2 of 2" - - expect: # Final response (with ID) - id: "req-1" - result: - value: "Final response" -``` - **`_error` modifier:** For streaming, sends notifications then returns an error: @@ -563,11 +515,11 @@ Each item in the top-level `scenarios` list is a `Scenario` object. It defines a | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | | **`name`** | `string` | **Yes** | A unique, human-readable name for the test. Used in test runner output. | -| **`method`** | `string` | **Yes** | The name of the server method to test. Must follow the `action_type_modifier` convention. | -| **`transport`** | `string` | **Yes** | The transport protocol. Must be one of `"http"`, `"websocket"`, or `"sse"`. | +| **`method`** | `string` | **Yes** | The server method. SSE method names end with `_sse`. | +| **`transport`** | `string` | **Yes** | The transport protocol. Must be either `"http"` or `"sse"`. | | `request` | `object` | Conditional | An object describing the request to send. **Required** for non-streaming (`http`) tests. | | `expect` | `object` | Conditional | An object describing the expected response. **Required** for non-streaming (`http`) tests. | -| `sequence` | `list` | Conditional | A list of steps for stateful interactions. **Required** for streaming (`websocket`, `sse`) tests. | +| `sequence` | `list` | Conditional | The events expected from an `sse` stream. | > A `Scenario` object must contain **either** a `request`/`expect` pair **or** a `sequence`, but not both. @@ -598,8 +550,7 @@ Each item in a `sequence` list is a step object that defines a single action in | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | -| **`type`** | `string` | **Yes** | The type of action. Must be one of `"send"`, `"receive"`, or `"close"`. | -| `data` | `object` | Conditional | The JSON-RPC payload to send. **Required** for `type: "send"`. | +| **`type`** | `string` | **Yes** | The event action. SSE sequences use `"receive"`. | | `expect`| `object` | Conditional | The expected JSON-RPC payload to receive. **Required** for `type: "receive"`. | | `delay` | `string` | No | A duration to wait before executing this step (e.g., `"100ms"`, `"1s"`). | @@ -632,7 +583,7 @@ In addition to the structure, the content of the YAML file must adhere to these * **Exclusivity**: A scenario cannot have both `request`/`expect` and `sequence` defined. * **ID Matching**: If a `request.id` is present, the corresponding `expect.id` must be identical. * **Result vs. Error**: An `expect` object cannot define both a `result` and an `error`. - * **Method Convention**: The `method` field must follow the `[action]_[type]_[modifier]` pattern, which determines the generated server's behavior. + * **Method Convention**: Unary methods follow `[action]_[type]_[modifier]`; SSE methods end with `_sse`. ## 🌐 Complete Examples @@ -652,39 +603,3 @@ scenarios: code: -32000 message: "A simulated server error occurred" ``` - -### WebSocket Bidirectional Streaming - -This example shows a client subscribing to a channel and then receiving a server-initiated broadcast. - -```yaml -scenarios: - - name: "broadcast_websocket_interaction" - method: "broadcast_string" - transport: "websocket" - sequence: - # 1. Client sends a subscription request - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string" # Method to call on the server - params: { "channel": "news" } - id: "sub-1" - - # 2. Client expects a confirmation response - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sub-1" - result: { "status": "subscribed", "channel": "news" } - - # 3. Client waits to receive an unsolicited broadcast from the server - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast" # Note: This is a server-initiated method, not a response - params: { "message": "Server update!" } -``` - - -Review each file one by one and each function one by one and think of ways it can be streamlined, improved, simplify, made more tuitive and follow Go best practice.  \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/codegen_data.go b/jsonrpc/integration_tests/framework/codegen_data.go index 98a3523993..dfb0bbabd5 100644 --- a/jsonrpc/integration_tests/framework/codegen_data.go +++ b/jsonrpc/integration_tests/framework/codegen_data.go @@ -44,9 +44,8 @@ type MethodData struct { Info MethodInfo // Type information - Payload *TypeSpec // Initial payload (if any) - StreamingPayload *TypeSpec // Streaming payload (if any) - Result *TypeSpec // Result - can be regular or streaming + Payload *TypeSpec // Request payload, if any + Result *TypeSpec // Unary result or streamed event // Behavior flags IsNotification bool // No response expected @@ -55,11 +54,8 @@ type MethodData struct { // Streaming information IsStreaming bool - StreamKind string // "payload", "result", "bidirectional" - Transport string // "http", "sse", "ws" + Transport string // "http" or "sse" - // For SSE with final response - HasFinalResponse bool } // TypeSpec describes a type semantically @@ -79,9 +75,6 @@ type TypeSpec struct { // For maps MapKey *TypeSpec MapValue *TypeSpec - - // Whether this type needs ID field (for bidirectional WebSocket) - NeedsID bool } // FieldSpec describes a field in an object @@ -130,34 +123,9 @@ type MethodImplData struct { StreamInterface string } -// ActionBehavior describes how a method should behave based on its action -type ActionBehavior struct { - // Action type (echo, transform, generate, collect, stream, broadcast) - Action string - // Type being operated on (string, array, object, map) - Type string - // Additional context (e.g., for streaming methods) - Context map[string]any -} - // Helper methods // IsSSE returns true if this method uses SSE transport func (m *MethodData) IsSSE() bool { return m.Transport == "sse" } - -// IsWebSocket returns true if this method uses WebSocket transport -func (m *MethodData) IsWebSocket() bool { - return m.Transport == "ws" -} - -// IsBidirectional returns true if this is a bidirectional streaming method -func (m *MethodData) IsBidirectional() bool { - return m.StreamKind == "bidirectional" -} - -// NeedsStreamingService returns true if this method requires a separate streaming service -func (m *MethodData) NeedsStreamingService() bool { - return m.IsStreaming && (m.IsSSE() || m.IsWebSocket()) -} diff --git a/jsonrpc/integration_tests/framework/constants.go b/jsonrpc/integration_tests/framework/constants.go index c729ab9107..e2a52eaa5a 100644 --- a/jsonrpc/integration_tests/framework/constants.go +++ b/jsonrpc/integration_tests/framework/constants.go @@ -2,9 +2,8 @@ package framework // Transport constants define available transport protocols const ( - TransportHTTP = "http" - TransportWebSocket = "websocket" - TransportSSE = "sse" + TransportHTTP = "http" + TransportSSE = "sse" ) // Action constants define server behavior patterns @@ -12,9 +11,7 @@ const ( ActionEcho = "echo" // Returns input unchanged ActionTransform = "transform" // Modifies input predictably ActionGenerate = "generate" // Returns fixed values - ActionStream = "stream" // Server-side streaming - ActionCollect = "collect" // Client-side streaming - ActionBroadcast = "broadcast" // Server-initiated messages + ActionStream = "stream" // Sends results with server-sent events ) // Type constants define data structures @@ -33,6 +30,5 @@ const ( ModifierNotify = "notify" // No response expected ModifierError = "error" // Always returns error ModifierValidate = "validate" // Includes validation - ModifierFinal = "final" // SSE: final response ModifierIDMap = "idmap" // Map envelope ID to payload/result field ) diff --git a/jsonrpc/integration_tests/framework/executor.go b/jsonrpc/integration_tests/framework/executor.go index 01af486778..c7f17d5a47 100644 --- a/jsonrpc/integration_tests/framework/executor.go +++ b/jsonrpc/integration_tests/framework/executor.go @@ -5,7 +5,6 @@ import ( "encoding/json" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,8 +20,7 @@ type executor struct { // newExecutor creates a new test executor func newExecutor(serverURL string, opts ...executorOption) *executor { config := executorConfig{ - WebSocketTimeout: 30 * time.Second, - Debug: false, + Debug: false, } for _, opt := range opts { @@ -62,8 +60,6 @@ func (e *executor) executeSimple(t *testing.T, scenario Scenario) { switch scenario.Transport { case TransportHTTP: e.executeHTTP(ctx, t, scenario) - case TransportWebSocket: - e.executeWebSocket(ctx, t, scenario) case TransportSSE: e.executeSSE(ctx, t, scenario) default: @@ -169,35 +165,6 @@ func (e *executor) executeHTTP(ctx context.Context, t *testing.T, scenario Scena } } -// executeWebSocket handles WebSocket transport scenarios -func (e *executor) executeWebSocket(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - // WebSocket scenarios always use sequence - if len(scenario.Sequence) > 0 { - e.executeWebSocketSequence(ctx, t, scenario) - return - } - - // If no sequence, create a simple send/receive sequence from request/expect - if scenario.Request.Params != nil { - // Pass method, params, and id as separate fields - data := map[string]any{ - "method": scenario.Method, - "params": scenario.Request.Params, - } - if scenario.Request.ID != nil { - data["id"] = scenario.Request.ID - } - - scenario.Sequence = []Action{ - {Type: "send", Data: data}, - {Type: "receive", Expect: scenario.Expect}, - } - e.executeWebSocketSequence(ctx, t, scenario) - } -} - // executeSSE handles Server-Sent Events scenarios func (e *executor) executeSSE(_ context.Context, t *testing.T, _ Scenario) { t.Helper() @@ -213,10 +180,8 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { ctx := context.Background() - // Only WebSocket and SSE support streaming + // JSON-RPC streaming uses server-sent events. switch scenario.Transport { - case TransportWebSocket: - e.executeWebSocketSequence(ctx, t, scenario) case TransportSSE: e.executeSSESequence(ctx, t, scenario) default: @@ -224,91 +189,6 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { } } -// executeWebSocketSequence handles WebSocket streaming sequences -func (e *executor) executeWebSocketSequence(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - client, err := harness.NewClient(e.serverURL, nil) - require.NoError(t, err, "Failed to create client") - - // Execute sequence steps - for i, step := range scenario.Sequence { - switch step.Type { - case "connect": - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to connect WebSocket", i) - - case "send": - // Auto-connect if not connected - if !client.IsConnected() { - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to auto-connect WebSocket", i) - } - - require.NotNilf(t, step.Data, "Step %d: send step requires data", i) - - // Extract method, params, and id from the data - reqData, ok := step.Data.(map[string]any) - require.Truef(t, ok, "Step %d: invalid request data format", i) - - req := harness.JSONRPCRequest{ - Method: reqData["method"].(string), - Params: reqData["params"], - ID: reqData["id"], - } - - // Mark HasID when id key present (even if it's null) - if _, hasID := reqData["id"]; hasID { - req.HasID = true - } - - // Handle custom jsonrpc field if specified - if jsonrpcVal, ok := reqData["jsonrpc"]; ok { - if jsonrpcStr, ok := jsonrpcVal.(string); ok { - if jsonrpcStr == "-" { - // Special value to omit the field - emptyStr := "" - req.JSONRPC = &emptyStr - } else { - req.JSONRPC = &jsonrpcStr - } - } - } - // If not specified, JSONRPC remains nil and defaults to "2.0" - - err := client.SendWebSocket(ctx, req) - require.NoErrorf(t, err, "Step %d: failed to send", i) - - case "receive": - msg, err := client.ReceiveWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to receive", i) - - var response map[string]any - err = json.Unmarshal(msg, &response) - require.NoErrorf(t, err, "Step %d: failed to unmarshal response", i) - - // Compare the response with expected - if expected, ok := step.Expect.(map[string]any); ok { - e.compareJSONRPCMessages(t, response, expected) - } else { - require.Failf(t, "Invalid expected value", "Step %d: expected value must be a map", i) - } - - case "close": - err := client.CloseWebSocket() - require.NoErrorf(t, err, "Step %d: failed to close WebSocket", i) - - default: - require.Failf(t, "Unknown step type", "Step %d: unknown step type: %s", i, step.Type) - } - - // Apply delay if specified - if step.Delay > 0 { - time.Sleep(step.Delay) - } - } -} - // executeSSESequence handles SSE streaming sequences func (e *executor) executeSSESequence(ctx context.Context, t *testing.T, scenario Scenario) { t.Helper() @@ -480,7 +360,7 @@ func (e *executor) validateJSONRPCResponse(t *testing.T, response any, expect Ex } } -// compareJSONRPCMessages compares two JSON-RPC messages (used for SSE/WebSocket validation) +// compareJSONRPCMessages compares two JSON-RPC messages from an SSE stream. func (e *executor) compareJSONRPCMessages(t *testing.T, actual, expected map[string]any) { t.Helper() diff --git a/jsonrpc/integration_tests/framework/framework_test.go b/jsonrpc/integration_tests/framework/framework_test.go index 5d743f5a58..9b9ca6012b 100644 --- a/jsonrpc/integration_tests/framework/framework_test.go +++ b/jsonrpc/integration_tests/framework/framework_test.go @@ -2,6 +2,8 @@ package framework import ( "testing" + + "github.com/stretchr/testify/require" ) // TestParseMethod verifies method name parsing @@ -19,8 +21,11 @@ func TestParseMethod(t *testing.T) { {"generate_object", "generate", "object", "", false}, {"echo_string_notify", "echo", "string", "notify", false}, {"transform_map_error", "transform", "map", "error", false}, - {"stream_string_final", "stream", "string", "final", false}, - + {"stream_string_sse", "stream", "string", "", false}, + {"stream_string_unknown_sse", "", "", "", true}, + {"stream_string", "", "", "", true}, + {"echo_string_ws", "", "", "", true}, + // Invalid methods {"invalid", "", "", "", true}, {"echo", "", "", "", true}, @@ -29,7 +34,7 @@ func TestParseMethod(t *testing.T) { {"", "", "", "", true}, {"echo__string", "", "", "", true}, } - + for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { info, err := ParseMethod(tt.method) @@ -39,11 +44,11 @@ func TestParseMethod(t *testing.T) { } return } - + if err != nil { t.Fatalf("ParseMethod(%q) failed: %v", tt.method, err) } - + if info.Action != tt.action { t.Errorf("Action: got %q, want %q", info.Action, tt.action) } @@ -55,4 +60,31 @@ func TestParseMethod(t *testing.T) { } }) } -} \ No newline at end of file +} + +// TestScenariosUseSupportedTransports checks that every checked-in scenario +// uses HTTP or server-sent events and that each server-sent event scenario +// contains only receive steps and ends with its request ID when one is present. +func TestScenariosUseSupportedTransports(t *testing.T) { + runner, err := NewRunner("../scenarios/scenarios.yaml") + require.NoError(t, err) + + for _, scenario := range runner.config.Scenarios { + t.Run(scenario.Name, func(t *testing.T) { + require.Contains(t, []string{TransportHTTP, TransportSSE}, scenario.Transport) + if scenario.Transport != TransportSSE { + return + } + for _, step := range scenario.Sequence { + require.Equal(t, "receive", step.Type) + } + if scenario.Request.ID == nil { + return + } + require.NotEmpty(t, scenario.Sequence) + response, ok := scenario.Sequence[len(scenario.Sequence)-1].Expect.(map[string]any) + require.True(t, ok) + require.EqualValues(t, scenario.Request.ID, response["id"]) + }) + } +} diff --git a/jsonrpc/integration_tests/framework/generator.go b/jsonrpc/integration_tests/framework/generator.go index fb082ed02b..047d733f18 100644 --- a/jsonrpc/integration_tests/framework/generator.go +++ b/jsonrpc/integration_tests/framework/generator.go @@ -128,39 +128,21 @@ func (g *Generator) renderImplementation(impl *ImplementationData) error { // buildMethodData creates semantic data for a method. func (g *Generator) buildMethodData(info MethodInfo) *MethodData { data := &MethodData{ - Name: info.Name(), - GoName: goify(info.Name()), - Description: g.getMethodDescription(info), - Info: info, - IsNotification: info.Modifier == ModifierNotify, - ReturnsError: info.Modifier == ModifierError, - HasValidation: info.Modifier == ModifierValidate, - HasFinalResponse: info.Modifier == ModifierFinal, - Transport: info.Transport, - IsStreaming: info.IsStreaming(), - } - // Non-streaming payload - if info.Modifier != ModifierNotify && info.Action != ActionGenerate && (!info.HasStreamingPayload() || info.IsSSE()) { + Name: info.Name(), + GoName: goify(info.Name()), + Description: g.getMethodDescription(info), + Info: info, + IsNotification: info.Modifier == ModifierNotify, + ReturnsError: info.Modifier == ModifierError, + HasValidation: info.Modifier == ModifierValidate, + Transport: info.Transport, + IsStreaming: info.IsStreaming(), + } + if info.Modifier != ModifierNotify && info.Action != ActionGenerate { data.Payload = g.buildTypeSpec(info.Type, info.Modifier) } - // Streaming if info.IsStreaming() { - isBidi := info.IsWebSocket() && info.HasStreamingPayload() && info.HasStreamingResult() - if info.HasStreamingPayload() { - data.StreamingPayload = g.buildStreamingTypeSpec(info.Type, true, isBidi, info) - data.StreamKind = "payload" - } - if info.HasStreamingResult() { - data.Result = g.buildStreamingTypeSpec(info.Type, false, isBidi, info) - if data.StreamKind == "payload" { - data.StreamKind = "bidirectional" - } else { - data.StreamKind = "result" - } - if info.IsSSE() && info.Modifier == ModifierFinal && data.Result != nil { - data.Result.Fields = append(data.Result.Fields, FieldSpec{Position: len(data.Result.Fields) + 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Description: "Response ID (for final response)", Required: false}) - } - } + data.Result = g.buildStreamingTypeSpec(info.Type) } else if info.Modifier != ModifierNotify && info.Modifier != ModifierError { data.Result = g.buildTypeSpec(info.Type, "") } @@ -245,62 +227,9 @@ func (g *Generator) buildTypeSpec(typeStr, modifier string) *TypeSpec { } } -// buildStreamingTypeSpec creates a TypeSpec for streaming types -func (g *Generator) buildStreamingTypeSpec(typeStr string, _ bool, isBidirectional bool, info MethodInfo) *TypeSpec { - // For WebSocket bidirectional methods, include mapping fields only when explicitly requested via idmap - if isBidirectional { - switch typeStr { - case TypeString: - if info.Modifier == ModifierIDMap { - return &TypeSpec{ - Kind: "object", - NeedsID: true, - Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Business-level ID"}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Mapped JSON-RPC envelope ID"}, - {Position: 3, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true, Description: "String value"}, - }, - } - } - // No id mapping: only value field - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}}} - case TypeArray: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}}} - case TypeObject: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 4, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 5, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{ - {Position: 1, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 2, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 3, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - default: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}}} - } - } - - // For non-bidirectional streaming, wrap primitives in objects +// buildStreamingTypeSpec creates the object sent in each SSE notification. +func (g *Generator) buildStreamingTypeSpec(typeStr string) *TypeSpec { + // Wrap primitives so each notification has a named JSON field. spec := g.buildTypeSpec(typeStr, "") if spec.Kind == "primitive" { return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: spec, Required: true, Description: fmt.Sprintf("%s value", spec.Primitive)}}} @@ -341,20 +270,18 @@ func (g *Generator) buildImplementationData(design *DesignData) *ImplementationD // buildMethodImplData creates implementation data for a method func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) *MethodImplData { - data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil || method.StreamingPayload != nil, HasResult: method.Result != nil} + data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil, HasResult: method.Result != nil} if method.Payload != nil { if method.Payload.Kind == "primitive" { data.PayloadRef = strings.ToLower(method.Payload.Primitive) } else { data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } - } else if method.StreamingPayload != nil && data.StreamKind == "bidirectional" { - data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } if method.Result != nil { - if method.Result.Kind == "primitive" { + if !method.IsStreaming && method.Result.Kind == "primitive" { data.ResultRef = strings.ToLower(method.Result.Primitive) - } else { + } else if !method.IsStreaming { data.ResultRef = fmt.Sprintf("*%s.%sResult", serviceName, method.GoName) } } @@ -368,14 +295,6 @@ func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) func (g *Generator) templateFuncs() template.FuncMap { return template.FuncMap{ "goify": goify, - "hasStreamingMethod": func(methods []*MethodImplData) bool { - for _, m := range methods { - if m.IsStreaming { - return true - } - } - return false - }, "collectRequired": func(fields []FieldSpec) []string { var required []string for _, f := range fields { @@ -393,9 +312,6 @@ func (g *Generator) getServiceName(info MethodInfo) string { if info.IsSSE() { return "testsse" } - if info.IsWebSocket() { - return "testws" - } return "test" } @@ -404,8 +320,6 @@ func (g *Generator) getJSONRPCPath(serviceName string) string { switch serviceName { case "testsse": return "/jsonrpc/sse" - case "testws": - return "/jsonrpc/ws" default: return "/jsonrpc" } @@ -487,9 +401,6 @@ func (g *Generator) parseServiceMethodPairs(serviceName string) []methodPair { continue } name := fld.Names[0].Name - if name == "HandleStream" { - continue - } goNames = append(goNames, name) } return false @@ -540,7 +451,6 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { {Path: "time"}, {Path: "strings"}, {Path: "sort"}, - {Path: "io"}, {Name: "goa", Path: "goa.design/goa/v3/pkg"}, {Name: service.ServicePackage, Path: fmt.Sprintf("testservice/gen/%s", service.ServicePackage)}, } @@ -548,7 +458,7 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { codegen.Header(fmt.Sprintf("%s service implementation", service.Title), "testservice", imports), { Name: "service-impl", - Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "streaming_websocket", "notify", "validate"), + Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "notify", "validate"), FuncMap: g.templateFuncs(), Data: service, }, diff --git a/jsonrpc/integration_tests/framework/options.go b/jsonrpc/integration_tests/framework/options.go index 8717a58684..1bc6174e00 100644 --- a/jsonrpc/integration_tests/framework/options.go +++ b/jsonrpc/integration_tests/framework/options.go @@ -113,15 +113,8 @@ func ApplyOptions(config *RunnerConfig, opts ...RunnerOption) { type executorOption func(*executorConfig) type executorConfig struct { - WebSocketTimeout time.Duration - Debug bool - WorkDir string -} - -func withWebSocketTimeout(d time.Duration) executorOption { - return func(c *executorConfig) { - c.WebSocketTimeout = d - } + Debug bool + WorkDir string } func withExecutorDebug(debug bool) executorOption { diff --git a/jsonrpc/integration_tests/framework/runner.go b/jsonrpc/integration_tests/framework/runner.go index 378d7e8e2a..35c3b70905 100644 --- a/jsonrpc/integration_tests/framework/runner.go +++ b/jsonrpc/integration_tests/framework/runner.go @@ -254,11 +254,8 @@ func (r *Runner) runScenario(t *testing.T, scenario Scenario) { t.Fatal("No server URL configured") } - // Create executor with timeout from settings + // Create the executor for this generated service. opts := []executorOption{} - if r.config.Settings.Timeout > 0 { - opts = append(opts, withWebSocketTimeout(r.config.Settings.Timeout)) - } opts = append(opts, withWorkDir(r.testDir)) // Enable debug if requested diff --git a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl index 1060b26148..c427c2350d 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl @@ -4,10 +4,7 @@ Method("{{ .Name }}", func() { {{- if .Payload }} Payload({{ template "inline_type" .Payload }}) {{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "inline_type" .StreamingPayload }}) -{{- end }} -{{- if and .Result .IsStreaming (or (eq .StreamKind "result") (eq .StreamKind "bidirectional")) }} +{{- if and .Result .IsStreaming }} StreamingResult({{ template "inline_type" .Result }}) {{- else if and .Result (not .IsNotification) }} Result({{ template "inline_type" .Result }}) @@ -59,4 +56,4 @@ func() { {{- else -}} Any {{- end -}} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl index e9aa2a019d..97b9bc4933 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl @@ -19,10 +19,6 @@ func() { {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} - {{- if .NeedsID }} - // Accept JSON-RPC ID in payload for WS, optional; transport-level ID is handled separately - Field(99, "id", String) - {{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -61,4 +57,4 @@ func() { {{- define "map_type" -}} MapOf({{ if .MapKey }}{{ template "type" .MapKey }}{{ else }}String{{ end }}, {{ if .MapValue }}{{ template "type" .MapValue }}{{ else }}Any{{ end }}) -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl index 6209f0c9e1..2df0c59f5f 100644 --- a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl @@ -1,44 +1,12 @@ // {{ .ServicePackage }}srvc implements the {{ .ServicePackage }} service. type {{ .ServicePackage }}srvc struct { logger *log.Logger -{{- range .Methods }} -{{- if and (eq .Info.Action "collect") (eq .Info.Type "array") (eq .Transport "ws") }} - // State for accumulating items in {{ .Name }} - collectedItems []string -{{- end }} -{{- end }} } // New{{ .Title }} returns the {{ .ServicePackage }} service implementation. func New{{ .Title }}() {{ .ServicePackage }}.Service { return &{{ .ServicePackage }}srvc{} } -{{- if eq .Name "testws" }} - -// HandleStream handles the JSON-RPC WebSocket streaming connection -func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ .ServicePackage }}.Stream) error { - // For testing purposes, we only send broadcasts when explicitly called through the broadcast method - // In a real application, you might send broadcasts based on external events or timers - - // Ensure the stream is closed on exit - defer func() { - _ = stream.Close() - }() - - // Loop to handle incoming requests - for { - // Recv reads and dispatches the next request - if err := stream.Recv(ctx); err != nil { - // Log the error type and value for diagnostics - log.Printf("HandleStream Recv error: %T %v", err, err) - if err == io.EOF { - return nil - } - return err - } - } -} -{{- end }} {{- range .Methods }} {{- if or (not .IsNotification) (and .IsNotification .IsStreaming) }} @@ -46,11 +14,7 @@ func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ {{ template "partial_method_signature" . }} { log.Printf("{{ .GoName }} called") {{- if .IsStreaming }} -{{- if .IsSSE }} {{ template "partial_streaming_sse" . }} -{{- else if .IsWebSocket }} -{{ template "partial_streaming_websocket" . }} -{{- end }} {{- else if .ReturnsError }} {{ template "partial_error" . }} {{- else }} @@ -71,4 +35,4 @@ func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ {{- end }} } {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl index aa78a2ac28..cff6953dd8 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl @@ -2,16 +2,9 @@ Method("{{ .Name }}", func() { Description("{{ .Description }}") {{- if .Payload }} Payload({{ template "partial_type" .Payload }}) -{{- else if and .StreamingPayload (eq .StreamKind "bidirectional") }} - Payload(func() { - Description("Initial payload") - }) -{{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "partial_type" .StreamingPayload }}) {{- end }} {{- if .Result }} -{{- if or (eq .StreamKind "result") (eq .StreamKind "bidirectional") }} +{{- if .IsStreaming }} StreamingResult({{ template "partial_type" .Result }}) {{- else if not .IsNotification }} Result({{ template "partial_type" .Result }}) @@ -25,4 +18,4 @@ Method("{{ .Name }}", func() { ServerSentEvents() {{- end }} }) -}) \ No newline at end of file +}) diff --git a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl index f2fd7f1acf..4061f4ea87 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl @@ -1,18 +1,6 @@ {{- /* Template for generating method signature */ -}} {{- if .IsStreaming -}} - {{- if .IsSSE -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if .IsWebSocket -}} - {{- if .IsBidirectional -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if eq .StreamKind "payload" -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- end -}} - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} - {{- end -}} {{- else -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl index 056d33b58f..43eb48e367 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl @@ -9,7 +9,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -18,7 +18,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{item}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -29,7 +29,7 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -37,7 +37,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -54,7 +54,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: strings.ToUpper(p), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -66,7 +66,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: reversed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -76,7 +76,7 @@ Field2: p.Field2 * 2, Field3: !p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -88,7 +88,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: transformed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -105,7 +105,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("generated-%d", i), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -115,7 +115,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("item-%d", i)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -127,7 +127,7 @@ Field2: i * 10, Field3: i%2 == 0, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -140,7 +140,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -165,7 +165,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("Stream %d of %d", i, count), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay to simulate streaming @@ -179,7 +179,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -188,7 +188,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("Processing: %s", item)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay between items @@ -202,7 +202,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"stream-1", "stream-2", "stream-3"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end }} @@ -229,7 +229,7 @@ Field2: i, Field3: i == count, // true for last item } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -242,7 +242,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -262,7 +262,7 @@ "value": v, }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -277,7 +277,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -293,14 +293,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: p.Items, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -309,14 +309,14 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -326,14 +326,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: "default", } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -342,41 +342,23 @@ Field2: 0, Field3: false, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} {{- end -}} -{{- end -}} +{{- end }} -{{- /* Handle modifiers for protocol-level behavior */ -}} -{{- if eq .Info.Modifier "final" -}} - // Send final response with ID using SendAndClose - finalResult := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - {{- if eq .Info.Type "string" -}} - Value: "Final response", - {{- else if eq .Info.Type "array" -}} - Items: []string{"completed"}, - {{- else if eq .Info.Type "object" -}} - Field1: "completed", - Field2: 100, - Field3: true, - {{- else if eq .Info.Type "map" -}} - Data: map[string]any{"status": "completed", "final": true}, - {{- end -}} - } - return stream.SendAndClose(ctx, finalResult) -{{- else if eq .Info.Modifier "error" -}} - // Return an error after streaming +{{ if eq .Info.Modifier "error" -}} + // Return an error after streaming. return &goa.ServiceError{Message: "Streaming error occurred"} -{{- else -}} - // No final response for pure notifications +{{ else -}} return nil -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl deleted file mode 100644 index dc1ffcf307..0000000000 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl +++ /dev/null @@ -1,522 +0,0 @@ -{{- /* Template for WebSocket streaming method implementation */ -}} -{{- /* WebSocket behavior is determined by the action, similar to SSE */ -}} - -{{- /* Handle validation first if validate modifier is set */ -}} -{{- if eq .Info.Modifier "validate" }} - {{- if eq .Info.Type "object" }} - if p != nil && (p.Field1 == "" || p.Field2 < 0) { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- else if eq .Info.Type "string" }} - if p != nil && p.Value == "" { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- end }} -{{- end }} - -{{- /* For echo with error modifier, return error immediately */ -}} -{{- if and (eq .Info.Action "echo") (eq .Info.Modifier "error") }} - // For echo methods with error modifier, always send error response - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Invalid params", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil -{{- else if eq .Info.Action "echo" }} - {{- /* Echo action: Return the payload exactly as received */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Echo back the received payload - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: p.Value, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Echo back the received array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: p.Items, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Echo back the received object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Echo back the received map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: p.Data, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Non-bidirectional echo - shouldn't happen for WebSocket - return fmt.Errorf("echo action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "transform" }} - {{- /* Transform action: Apply transformations to the payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Transform and return: uppercase - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: strings.ToUpper(p.Value), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Transform and return: reverse the array - if p != nil { - reversed := make([]string, len(p.Items)) - for i, item := range p.Items { - reversed[len(p.Items)-1-i] = item - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: reversed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Transform and return: uppercase field1, double field2, negate field3 - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: strings.ToUpper(p.Field1), - Field2: p.Field2 * 2, - Field3: !p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Transform and return: prefix all keys with "transformed_" - if p != nil && p.Data != nil { - transformed := make(map[string]any) - if data, ok := p.Data.(map[string]any); ok { - for k, v := range data { - transformed["transformed_"+k] = v - } - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: transformed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - return fmt.Errorf("transform action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "generate" }} - {{- /* Generate action: Return fixed values, ignoring payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Generate and return fixed string - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "generated-string", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Generate and return fixed array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"item1", "item2", "item3"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Generate and return fixed object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "generated-value1", - Field2: 42, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Generate and return fixed map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: map[string]any{ - "generated": true, - "count": 3, - "status": "ok", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Server-initiated generation (no client request) - {{- if eq .Info.Type "string" }} - for i := 1; i <= 3; i++ { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("generated-%d", i), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Generate default values - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- end }} - {{- end }} - -{{- else if eq .Info.Action "stream" }} - {{- /* Stream action: Send a series of messages based on payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Stream notifications based on string payload - if p != nil { - count := 3 // default - if p.Value != "" { - count = len(p.Value) - if count > 10 { - count = 10 - } - } - - // For error modifier, send fewer notifications - streamCount := count - {{- if eq .Info.Modifier "error" }} - if streamCount > 2 { - streamCount = 2 - } - {{- end }} - - // Send notifications without ID - for i := 1; i <= streamCount; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Stream %d of %d", i, count), - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "completed", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "array" }} - // Stream notifications for each array item - if p != nil { - // Send notification for each item - for _, item := range p.Items { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("Processing: %s", item)}, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"completed"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "object" }} - // Stream notifications based on field2 count - if p != nil { - count := p.Field2 - if count <= 0 { - count = 3 - } - if count > 10 { - count = 10 - } - // Send notifications without ID - for i := 1; i <= count; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("%s-%d", p.Field1, i), - Field2: i, - Field3: i == count, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "completed", - Field2: 100, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "map" }} - // Stream notifications for each key-value pair - if p != nil && p.Data != nil { - // Send notification for each pair - if data, ok := p.Data.(map[string]any); ok { - // Sort keys for deterministic ordering - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - v := data[k] - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "key": k, - "value": v, - }, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "status": "completed", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- end }} - {{- if ne .Info.Modifier "error" }} - return nil - {{- end }} - {{- else }} - // Server-side streaming without client payload - return fmt.Errorf("stream action requires bidirectional streaming for WebSocket") - {{- end }} - -{{- else if eq .Info.Action "collect" }} - {{- /* Collect action: Accumulate client messages */ -}} - {{- if eq .Info.Type "array" }} - // For JSON-RPC WebSocket, each request comes as a separate call to this method - // We accumulate items across requests using service-level state - if p != nil && p.Items != nil { - s.collectedItems = append(s.collectedItems, p.Items...) - } - - // Return the accumulated items - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: s.collectedItems, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- else }} - // Collect only supports array type currently - return fmt.Errorf("collect action only supports array type") - {{- end }} - -{{- else if eq .Info.Action "broadcast" }} - {{- /* Broadcast action: Server-initiated messages */ -}} - {{- if .IsBidirectional }} - // Broadcast method implementation - bidirectional streaming - // When called (e.g., with "subscribe"), send test broadcast notifications - {{- if eq .Info.Type "string" }} - // Send test broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Server announcement %d", i), - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "array" }} - // Send test array broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("broadcast-%d", i)}, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "object" }} - // Send test object broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("broadcast-%d", i), - Field2: i, - Field3: i%2 == 0, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "map" }} - // Send test map broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "broadcast": i, - "timestamp": time.Now().Unix(), - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Send default broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- end }} - {{- else }} - // Broadcast action requires bidirectional streaming - return fmt.Errorf("broadcast action requires bidirectional streaming") - {{- end }} - -{{- else }} - {{- /* Default: echo behavior for unknown actions */ -}} - // Default WebSocket implementation for JSON-RPC - // Each request comes as a separate call - if p != nil { - // Echo payload back - {{- if eq .Info.Type "string" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Value: p.Value, - } - {{- else if eq .Info.Type "array" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Items: p.Items, - } - {{- else if eq .Info.Type "object" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - {{- else if eq .Info.Type "map" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: p.Data, - } - {{- else }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - {{- end }} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } -{{- end }} - -{{- /* Handle remaining modifiers */ -}} -{{- if eq .Info.Modifier "error" }} - {{- /* For stream action with error, send error after streaming */ -}} - {{- if eq .Info.Action "stream" }} - // For stream methods with error modifier, send error after streaming - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Streaming error occurred", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil - {{- else }} - // Other actions with error modifier should have been handled above - return nil - {{- end }} -{{- else if eq .Info.Modifier "notify" }} - // Notification - no response sent (already handled above) - return nil -{{- else }} - // Normal completion - return nil -{{- end }} \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl index d876481209..d5f3c9a925 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl @@ -18,9 +18,6 @@ func() { {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} -{{- if .NeedsID }} - ID("id") -{{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -29,4 +26,4 @@ func() { } {{- else -}} Any -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/types.go b/jsonrpc/integration_tests/framework/types.go index ca0a02c859..bd3772f49e 100644 --- a/jsonrpc/integration_tests/framework/types.go +++ b/jsonrpc/integration_tests/framework/types.go @@ -6,13 +6,6 @@ import ( "time" ) -// Sequence action types for streaming scenarios -const ( - SequenceActionSend = "send" - SequenceActionReceive = "receive" - SequenceActionClose = "close" -) - // Scenario represents a test scenario loaded from YAML type Scenario struct { Name string `yaml:"name"` @@ -79,14 +72,14 @@ type Settings struct { type MethodInfo struct { Action string // echo, transform, generate, etc. Type string // string, array, object, etc. - Modifier string // notify, error, validate, final - Transport string // sse, ws (extracted from method suffix) + Modifier string // notify, error, validate, or idmap + Transport string // sse when the method sends server-sent events GRPC bool // whether to emit GRPC endpoint (method name starts with grpc_) } // ParseMethod parses a method name into its components. -// Format: action_type[_modifier][_transport] -// Examples: echo_string, stream_object_final_sse, broadcast_string_ws +// Format: action_type[_modifier][_sse] +// Examples: echo_string, stream_object_sse // Returns error if the method name is invalid. func ParseMethod(method string) (MethodInfo, error) { // Check for grpc_ prefix convention @@ -98,7 +91,7 @@ func ParseMethod(method string) (MethodInfo, error) { parts := strings.Split(method, "_") if len(parts) < 2 { - return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_transport]", method) + return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_sse]", method) } info := MethodInfo{ @@ -110,7 +103,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Check if last part is a transport if len(parts) > 2 { lastPart := parts[len(parts)-1] - if lastPart == "sse" || lastPart == "ws" { + if lastPart == "sse" { info.Transport = lastPart parts = parts[:len(parts)-1] // Remove transport from parts } @@ -119,7 +112,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Validate action validActions := map[string]bool{ ActionEcho: true, ActionTransform: true, ActionGenerate: true, - ActionStream: true, ActionCollect: true, ActionBroadcast: true, + ActionStream: true, } if !validActions[info.Action] { return MethodInfo{}, fmt.Errorf("invalid action %q in method %q: must be one of: %s", @@ -141,13 +134,16 @@ func ParseMethod(method string) (MethodInfo, error) { info.Modifier = parts[2] // Validate modifier validModifiers := map[string]bool{ - ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierFinal: true, ModifierIDMap: true, + ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierIDMap: true, } if !validModifiers[info.Modifier] { return MethodInfo{}, fmt.Errorf("invalid modifier %q in method %q: must be one of: %s", info.Modifier, method, strings.Join(getMapKeys(validModifiers), ", ")) } } + if info.Action == ActionStream && !info.IsSSE() { + return MethodInfo{}, fmt.Errorf("streaming method %q must end with _sse", method) + } return info, nil } @@ -178,41 +174,9 @@ func (info MethodInfo) IsSSE() bool { return info.Transport == "sse" } -// IsWebSocket returns true if this method uses WebSocket transport -func (info MethodInfo) IsWebSocket() bool { - return info.Transport == "ws" -} - // IsStreaming returns true if this method involves streaming func (info MethodInfo) IsStreaming() bool { - return info.IsSSE() || info.IsWebSocket() || info.Action == ActionStream || info.Action == ActionCollect || info.Action == ActionBroadcast -} - -// HasStreamingResult returns true if this method streams results -func (info MethodInfo) HasStreamingResult() bool { - if info.IsSSE() { - return true // SSE always streams results - } - if info.IsWebSocket() { - // WebSocket methods can stream results based on action - return info.Action == ActionStream || info.Action == ActionBroadcast || - info.Action == ActionEcho || info.Action == ActionTransform || info.Action == ActionGenerate || - info.Action == ActionCollect - } - return false -} - -// HasStreamingPayload returns true if this method streams payload -func (info MethodInfo) HasStreamingPayload() bool { - if info.IsSSE() { - return false // SSE doesn't support streaming payload - } - if info.IsWebSocket() { - // All WebSocket methods have streaming payload for bidirectional support - // This allows them to receive requests and send responses/notifications - return true - } - return false + return info.IsSSE() } // GetMethod returns the effective JSON-RPC method name to use on the wire. diff --git a/jsonrpc/integration_tests/go.mod b/jsonrpc/integration_tests/go.mod index 23bbfbe3d3..a433fd0519 100644 --- a/jsonrpc/integration_tests/go.mod +++ b/jsonrpc/integration_tests/go.mod @@ -3,7 +3,6 @@ module goa.design/goa/v3/jsonrpc/integration_tests go 1.25.0 require ( - github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.12.1 goa.design/goa/v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/jsonrpc/integration_tests/go.sum b/jsonrpc/integration_tests/go.sum index 369b4fb313..890f3eb085 100644 --- a/jsonrpc/integration_tests/go.sum +++ b/jsonrpc/integration_tests/go.sum @@ -7,8 +7,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/jsonrpc/integration_tests/harness/cli_client.go b/jsonrpc/integration_tests/harness/cli_client.go index 6a46dd275f..e4aec27272 100644 --- a/jsonrpc/integration_tests/harness/cli_client.go +++ b/jsonrpc/integration_tests/harness/cli_client.go @@ -49,8 +49,7 @@ func NewCLIClient(workDir, serverURL string) (*CLIClient, error) { func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payload any) (json.RawMessage, error) { // Convert method name from snake_case to kebab-case for CLI cliMethod := strings.ReplaceAll(method, "_", "-") - - + // Build command arguments - use go run to execute the CLI // URL must come before service and method for proper flag parsing args := []string{ @@ -85,7 +84,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl } } // If payload is nil, don't add any body argument - let the CLI handle it - + // Create command with all args cmd = exec.CommandContext(ctx, "go", args...) cmd.Dir = c.cliPath @@ -111,7 +110,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl // Parse verbose output from stderr to get the raw JSON-RPC response verboseOutput := stderr.String() lines := strings.Split(verboseOutput, "\n") - + // Find the JSON-RPC response - it's the last line starting with { for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) @@ -123,7 +122,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl Message string `json:"message"` } `json:"error"` } - + if err := json.Unmarshal([]byte(line), &resp); err == nil && resp.Result != nil { return resp.Result, nil } @@ -165,17 +164,16 @@ func (c *CLIClient) CallJSONRPC(ctx context.Context, request map[string]any) (js // CanHandle returns true if the CLI can handle this method func (c *CLIClient) CanHandle(method string, params any) bool { - // CLI can handle HTTP methods but not streaming - // Check if it's a streaming method by looking for WebSocket or SSE in the method name - if strings.Contains(method, "_ws") || strings.Contains(method, "_sse") { + // CLI can handle unary HTTP methods but not SSE streams. + if strings.Contains(method, "_sse") { return false } - + // CLI doesn't handle notification methods (no response expected) if strings.Contains(method, "_notify") { return false } - + // CLI can handle methods with payloads return true } diff --git a/jsonrpc/integration_tests/harness/client.go b/jsonrpc/integration_tests/harness/client.go index dfd027642b..80e0beb501 100644 --- a/jsonrpc/integration_tests/harness/client.go +++ b/jsonrpc/integration_tests/harness/client.go @@ -11,8 +11,6 @@ import ( "net/url" "strings" "time" - - "github.com/gorilla/websocket" ) // JSONRPCRequest represents a JSON-RPC 2.0 request @@ -21,7 +19,6 @@ type JSONRPCRequest struct { Method string `json:"method"` Params any `json:"params,omitempty"` ID any `json:"id,omitempty"` - HasID bool `json:"-"` // true if the id key must be included even if null } // Default values @@ -29,7 +26,6 @@ const ( DefaultHTTPTimeout = 10 * time.Second DefaultJSONRPCPath = "/jsonrpc" DefaultSSEPath = "/jsonrpc/sse" - DefaultWSPath = "/jsonrpc/ws" ) // ClientConfig holds client configuration @@ -40,14 +36,10 @@ type ClientConfig struct { JSONRPCPath string // SSEPath is the path for SSE endpoint SSEPath string - // WSPath is the path for WebSocket endpoint - WSPath string // Headers are additional headers to send with requests Headers map[string]string // HTTPClient allows using a custom HTTP client HTTPClient *http.Client - // WSDialer allows using a custom WebSocket dialer - WSDialer *websocket.Dialer } // DefaultConfig returns default client configuration @@ -56,7 +48,6 @@ func DefaultConfig() *ClientConfig { HTTPTimeout: DefaultHTTPTimeout, JSONRPCPath: DefaultJSONRPCPath, SSEPath: DefaultSSEPath, - WSPath: DefaultWSPath, Headers: make(map[string]string), } } @@ -66,8 +57,6 @@ type Client struct { baseURL *url.URL config *ClientConfig httpClient *http.Client - wsDialer *websocket.Dialer - wsConn *websocket.Conn } // NewClient creates a new JSON-RPC client @@ -89,25 +78,10 @@ func NewClient(baseURL string, config *ClientConfig) (*Client, error) { } } - // Create WebSocket dialer if not provided - wsDialer := config.WSDialer - if wsDialer == nil { - // Do not use proxies from environment variables for integration tests. - // A developer shell may have HTTP(S)_PROXY set, which breaks localhost - // WebSocket upgrades and causes "websocket: bad handshake" failures. - // - // Gorilla uses ProxyFromEnvironment in websocket.DefaultDialer; setting - // Proxy to nil disables proxy use entirely. - defaultDialer := *websocket.DefaultDialer - defaultDialer.Proxy = nil - wsDialer = &defaultDialer - } - return &Client{ baseURL: u, config: config, httpClient: httpClient, - wsDialer: wsDialer, }, nil } @@ -319,165 +293,3 @@ func (c *Client) parseSSEEvents(r io.Reader) ([]json.RawMessage, error) { return events, scanner.Err() } - -// ConnectWebSocket establishes a WebSocket connection -func (c *Client) ConnectWebSocket(ctx context.Context) error { - // Build WebSocket URL - wsURL := *c.baseURL - wsURL.Path = c.config.WSPath - - // Convert scheme - switch wsURL.Scheme { - case "http": - wsURL.Scheme = "ws" - case "https": - wsURL.Scheme = "wss" - default: - // Keep as is (might already be ws/wss) - } - - // Set headers - headers := http.Header{} - for k, v := range c.config.Headers { - headers.Set(k, v) - } - - conn, resp, err := c.wsDialer.DialContext(ctx, wsURL.String(), headers) - if err != nil { - if resp != nil { - if resp.Body == nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - body, readErr := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if readErr != nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - return fmt.Errorf("websocket dial failed (status %d): %w: %s", resp.StatusCode, err, string(body)) - } - return fmt.Errorf("websocket dial failed: %w", err) - } - if resp != nil && resp.Body != nil { - defer resp.Body.Close() //nolint:errcheck - } - - c.wsConn = conn - return nil -} - -// SendWebSocket sends a JSON-RPC request over WebSocket -func (c *Client) SendWebSocket(ctx context.Context, req JSONRPCRequest) error { - if c.wsConn == nil { - return fmt.Errorf("websocket not connected") - } - - // Build JSON-RPC request envelope - envelope := map[string]any{} - // Allow tests to omit the method by passing "-" (treated as missing field) - if req.Method != "-" && req.Method != "" { - envelope["method"] = req.Method - } - - // Add jsonrpc field if provided, or default to "2.0" - if req.JSONRPC != nil { - if *req.JSONRPC != "" { - envelope["jsonrpc"] = *req.JSONRPC - } - // If JSONRPC is explicitly set to empty string, omit the field - } else { - // Default behavior: include "jsonrpc": "2.0" - envelope["jsonrpc"] = "2.0" - } - - if req.Params != nil { - envelope["params"] = req.Params - } - // Preserve explicit id presence, even if null - if req.HasID { - envelope["id"] = req.ID - } else if req.ID != nil { - envelope["id"] = req.ID - } - - data, err := json.Marshal(envelope) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // Set write deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetWriteDeadline(deadline); err != nil { - return fmt.Errorf("failed to set write deadline: %w", err) - } - } - - return c.wsConn.WriteMessage(websocket.TextMessage, data) -} - -// ReceiveWebSocket receives a message from WebSocket -func (c *Client) ReceiveWebSocket(ctx context.Context) (json.RawMessage, error) { - if c.wsConn == nil { - return nil, fmt.Errorf("websocket not connected") - } - - // Set read deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetReadDeadline(deadline); err != nil { - return nil, fmt.Errorf("failed to set read deadline: %w", err) - } - } - - messageType, data, err := c.wsConn.ReadMessage() - if err != nil { - // Retry once on abnormal closure to tolerate immediate server close after response - if websocket.IsUnexpectedCloseError(err, websocket.CloseAbnormalClosure) || strings.Contains(err.Error(), "unexpected EOF") { - // Briefly wait and retry a single read within the same deadline window - time.Sleep(10 * time.Millisecond) - messageType, data, err = c.wsConn.ReadMessage() - } - if err != nil { - return nil, err - } - } - - if messageType != websocket.TextMessage { - return nil, fmt.Errorf("unexpected message type: %d", messageType) - } - - return json.RawMessage(data), nil -} - -// CloseWebSocket closes the WebSocket connection gracefully -func (c *Client) CloseWebSocket() error { - if c.wsConn == nil { - return nil - } - - // Send close message - deadline := time.Now().Add(5 * time.Second) - closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - err := c.wsConn.WriteControl(websocket.CloseMessage, closeMsg, deadline) - - // Always close the connection - closeErr := c.wsConn.Close() - c.wsConn = nil - - // Ignore "broken pipe" errors on close - the server may have already closed - if err != nil && strings.Contains(err.Error(), "broken pipe") { - err = nil - } - if closeErr != nil && strings.Contains(closeErr.Error(), "broken pipe") { - closeErr = nil - } - - // Return the first error - if err != nil { - return err - } - return closeErr -} - -// IsConnected returns true if WebSocket is connected -func (c *Client) IsConnected() bool { - return c.wsConn != nil -} diff --git a/jsonrpc/integration_tests/scenarios/scenarios.yaml b/jsonrpc/integration_tests/scenarios/scenarios.yaml index c9aaca982f..e5febdad07 100644 --- a/jsonrpc/integration_tests/scenarios/scenarios.yaml +++ b/jsonrpc/integration_tests/scenarios/scenarios.yaml @@ -181,7 +181,7 @@ scenarios: message: "Invalid params" - # SSE streaming without final response + # SSE streams with event notifications and a terminal null result - name: "stream_object_sse" method: "stream_object_sse" transport: "sse" @@ -217,38 +217,11 @@ scenarios: field2: 3 field3: true # Last item is true - # SSE streaming with final response - - name: "stream_string_final_sse" - method: "stream_string_final_sse" - transport: "sse" - request: - params: "abc" # Length 3 = 3 notifications - id: "sse-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 1 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 2 of 3" - type: "receive" expect: jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 3 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-1" - result: - value: "Final response" + id: "sse-2" + result: null # SSE additional streaming tests - name: "stream_array_sse" @@ -271,6 +244,11 @@ scenarios: method: "stream_array_sse" params: items: ["Processing: second"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-array-1" + result: null - name: "stream_map_sse" method: "stream_map_sse" @@ -298,6 +276,11 @@ scenarios: data: key: "key2" value: "value2" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-map-1" + result: null - name: "stream_string_sse" method: "stream_string_sse" @@ -319,111 +302,11 @@ scenarios: params: value: "Stream 2 of 2" - # SSE with final modifier - stream then send final response - - name: "stream_array_final_sse" - method: "stream_array_final_sse" - transport: "sse" - request: - params: - items: ["item1", "item2"] - id: "sse-array-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item1"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item2"] - type: "receive" expect: jsonrpc: "2.0" - id: "sse-array-final-1" - result: - items: ["completed"] - - - name: "stream_object_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "start" - field2: 3 # Count of notifications before final - field3: false - id: "sse-obj-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-2" - field2: 2 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-3" - field2: 3 - field3: true # Last is true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-obj-final-1" - result: - field1: "completed" - field2: 100 - field3: true - - - name: "stream_map_final_sse" - method: "stream_map_final_sse" - transport: "sse" - request: - params: - data: - first: "value1" - second: "value2" - id: "sse-map-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "first" - value: "value1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "second" - value: "value2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-map-final-1" - result: - data: - status: "completed" - final: true + id: "sse-string-1" + result: null # SSE error scenario - stream then error - name: "stream_string_error_sse" @@ -497,6 +380,12 @@ scenarios: params: items: ["empty"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-empty-1" + result: null + # Single string stream - one character = one notification - name: "stream_string_single_sse" method: "stream_string_sse" @@ -512,6 +401,12 @@ scenarios: params: value: "Stream 1 of 1" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-single-1" + result: null + # Multiple items streamed rapidly - name: "stream_string_multiple_sse" method: "stream_string_sse" @@ -551,43 +446,11 @@ scenarios: params: value: "Stream 5 of 5" - # Stream with count control then final response - - name: "stream_object_count_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "test" - field2: 2 # This controls the count of notifications - field3: true - id: "sse-mixed-1" - sequence: - # Notifications based on field2 count - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-2" - field2: 2 - field3: true # Last item is true - # Then the final response with ID - type: "receive" expect: jsonrpc: "2.0" - id: "sse-mixed-1" - result: - field1: "completed" - field2: 100 - field3: true + id: "sse-rapid-1" + result: null # Echo test for SSE - name: "echo_string_sse" @@ -604,6 +467,12 @@ scenarios: params: value: "echo this" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-echo-1" + result: null + # Transform test for SSE - name: "transform_string_sse" method: "transform_string_sse" @@ -619,6 +488,12 @@ scenarios: params: value: "HELLO WORLD" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-transform-1" + result: null + # Generate test for SSE - name: "generate_string_sse" method: "generate_string_sse" @@ -646,520 +521,11 @@ scenarios: params: value: "generated-3" - # WebSocket tests - TODO: Fix ID field mapping for bidirectional streaming - - name: "echo_string_websocket" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello websocket" - id: "ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-1" - result: - value: "hello websocket" - - type: "close" - - # WebSocket with server broadcasts - - name: "broadcast_string_websocket" - method: "broadcast_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - id: "subscribe" - value: "start" - id: "broadcast-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 2" - - type: "close" - - # WebSocket transform tests - - name: "transform_string_websocket" - method: "transform_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_string_ws" - params: - id: "ws-transform-1" - value: "hello" - id: "ws-transform-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-transform-1" - result: - value: "HELLO" - - type: "close" - - - name: "transform_object_websocket" - method: "transform_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_object_ws" - params: - id: "ws-obj-1" - field1: "lower" - field2: 10 - field3: false - id: "ws-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-obj-1" - result: - field1: "LOWER" - field2: 20 - field3: true - - type: "close" - - - name: "transform_map_websocket" - method: "transform_map_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_map_ws" - params: - id: "ws-map-1" - data: - key1: "value1" - key2: "value2" - id: "ws-map-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-map-1" - result: - data: - transformed_key1: "value1" - transformed_key2: "value2" - - type: "close" - - # WebSocket generate tests - - name: "generate_string_websocket" - method: "generate_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_string_ws" - params: - id: "ws-gen-1" - value: "ignored" - id: "ws-gen-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-1" - result: - value: "generated-string" - - type: "close" - - - name: "generate_array_websocket" - method: "generate_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_array_ws" - params: - id: "ws-gen-array-1" - items: [] # Ignored - id: "ws-gen-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-array-1" - result: - items: ["item1", "item2", "item3"] - - type: "close" - - - name: "generate_object_websocket" - method: "generate_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_object_ws" - params: - id: "ws-gen-obj-1" - field1: "" - field2: 0 - field3: false - id: "ws-gen-obj-1" - type: "receive" expect: jsonrpc: "2.0" - id: "ws-gen-obj-1" - result: - field1: "generated-value1" - field2: 42 - field3: true - - type: "close" - - # WebSocket stream tests (server streaming) - - name: "stream_string_websocket" - method: "stream_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - id: "ws-stream-1" - value: "ab" # 2 chars = 2 messages - id: "ws-stream-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 1 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 2 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-1" - result: - value: "completed" - - type: "close" - - - name: "stream_array_websocket" - method: "stream_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - id: "ws-stream-array-1" - items: ["first", "second"] - id: "ws-stream-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: first"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: second"] - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-array-1" - result: - items: ["completed"] - - type: "close" - - - name: "stream_object_websocket" - method: "stream_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - id: "ws-stream-obj-1" - field1: "test" - field2: 2 # Controls stream count - field3: false - id: "ws-stream-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-2" - field2: 2 - field3: true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-obj-1" - result: - field1: "completed" - field2: 100 - field3: true - - type: "close" - - # WebSocket error handling tests - - name: "echo_string_error_websocket" - method: "echo_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_error_ws" - params: - id: "ws-error-1" - value: "will fail" - id: "ws-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-error-1" - error: - code: -32602 - message: "Invalid params" - - type: "close" - - - name: "stream_string_error_websocket" - method: "stream_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - id: "ws-stream-error-1" - value: "fail" - id: "ws-stream-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 1 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 2 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-error-1" - error: - code: -32602 - message: "Streaming error occurred" - - type: "close" - - # WebSocket protocol compliance: request vs notification (JSON-RPC version and method presence) - - name: "websocket_invalid_version_request" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - id: "ws-invalid-1" - value: "ignore" - id: "ws-invalid-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-invalid-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_invalid_version_notification" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - value: "notification without version" - # No receive expected for notifications with invalid request - - - name: "websocket_missing_method_with_id" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "test" - id: "ws-missing-method-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-missing-method-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_missing_method_notification" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "notification with missing method" - # No receive expected - - - name: "websocket_method_not_found" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "non_existent_method_ws" - params: - value: "test" - id: "ws-not-found-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-not-found-1" - error: - code: -32601 - message: "Method not found" - - # WebSocket notification tests (no response expected) - - name: "echo_string_notify_websocket" - method: "echo_string_notify_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "notification" - # No id field - this is a notification - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "another notification" - # No id field - # No receive expected for notifications - - type: "close" - - # WebSocket validation test - - name: "echo_object_validate_websocket" - method: "echo_object_validate_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_object_validate_ws" - params: - id: "ws-validate-1" - field1: "" # Empty string might fail validation - field2: -1 # Negative number might fail validation - field3: true - id: "ws-validate-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-validate-1" - error: - code: -32602 - message: "validation error" - - type: "close" - - # WebSocket bidirectional streaming - - name: "collect_array_websocket" - method: "collect_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-1" - items: ["first"] - id: "collect-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-1" - result: - items: ["first"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-2" - items: ["second"] - id: "collect-2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-2" - result: - items: ["first", "second"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-3" - items: ["third"] - id: "collect-3" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-3" - result: - items: ["first", "second", "third"] + id: "sse-generate-1" + result: null # Batch request tests - name: "batch_mixed_requests" @@ -1292,6 +658,12 @@ scenarios: params: value: "Stream 4 of 4" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "mixed-sse-1" + result: null + # Mixed HTTP (CLI) and JSON-RPC (direct) for the same method - name: "mixed_http_cli_echo" method: "echo_string" @@ -1360,49 +732,6 @@ scenarios: code: -32600 message: "Invalid request" - # Additional WebSocket ID-type coverage - - name: "websocket_null_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: null - value: "null id" - id: null - - type: "receive" - expect: - jsonrpc: "2.0" - id: null - result: - value: "null id" - - type: "close" - - - name: "websocket_numeric_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: 42 - value: "number id" - id: 42 - - type: "receive" - expect: - jsonrpc: "2.0" - id: 42 - result: - value: "number id" - - type: "close" - # Additional SSE protocol edge cases - name: "sse_invalid_version_with_id" method: "echo_string_sse" @@ -1492,47 +821,8 @@ scenarios: method: "stream_string_sse" params: value: "Stream 2 of 2" - - - name: "websocket_params_id_with_request_id_mapping" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-42" # business-level id as string - request_id: "foo" # separate app field; mapping uses envelope id - value: "ws-hello" - id: "env-ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "env-ws-1" - result: - value: "ws-hello" - - type: "close" - - - name: "websocket_params_id_with_request_id_mapping_numeric_env_id" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-456" - request_id: "foo" - value: "ws-hello-num" - id: 202 - type: "receive" expect: jsonrpc: "2.0" - id: 202 - result: - value: "ws-hello-num" - - type: "close" \ No newline at end of file + id: "env-sse-1" + result: null diff --git a/jsonrpc/types.go b/jsonrpc/types.go index 74155e98d8..2f552b35cc 100644 --- a/jsonrpc/types.go +++ b/jsonrpc/types.go @@ -36,9 +36,12 @@ type ( Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` ID any `json:"id"` - // HasID is true when the "id" key is present in the incoming JSON (even if null). - // It is consumed by generated templates (WebSocket/SSE/HTTP) to decide whether - // to send a response for this request. Do not remove even if unused by this package. + // Invalid is true when the JSON value is not shaped like a JSON-RPC + // request object. + Invalid bool `json:"-"` + // HasID is true when the "id" key is present in the incoming JSON, even + // when its value is null. Generated servers use it to decide whether to + // send a response for this request. HasID bool `json:"-"` } @@ -114,6 +117,31 @@ func MakeNotification(method string, params any) *Request { } } +// MarshalJSON writes the result member for every success response, including +// responses whose result is null, and writes only the error for failures. +func (r *Response) MarshalJSON() ([]byte, error) { + if r.Error != nil { + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Error *ErrorResponse `json:"error"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Error: r.Error, + ID: r.ID, + }) + } + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Result any `json:"result"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Result: r.Result, + ID: r.ID, + }) +} + // Error returns a string representation of the error. func (e *ErrorResponse) Error() string { return fmt.Sprintf("jsonrpc: code %d: %s", e.Code, e.Message) @@ -137,40 +165,50 @@ func IDToString(id any) string { } } -// UnmarshalJSON decodes RawRequest and records whether the id field was present. +// UnmarshalJSON decodes one request and records invalid input and ID presence. func (r *RawRequest) UnmarshalJSON(data []byte) error { + *r = RawRequest{} var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { + if json.Valid(data) { + r.Invalid = true + return nil + } return err } + if raw == nil { + r.Invalid = true + return nil + } + if v, ok := raw["id"]; ok { + r.HasID = true + if string(v) != "null" { + if err := json.Unmarshal(v, &r.ID); err != nil { + r.Invalid = true + return nil + } + switch r.ID.(type) { + case string, float64: + default: + r.ID = nil + r.Invalid = true + } + } + } if v, ok := raw["jsonrpc"]; ok { if err := json.Unmarshal(v, &r.JSONRPC); err != nil { - return err + r.Invalid = true + return nil } } if v, ok := raw["method"]; ok { if err := json.Unmarshal(v, &r.Method); err != nil { - return err + r.Invalid = true + return nil } } if v, ok := raw["params"]; ok { r.Params = v } - if v, ok := raw["id"]; ok { - r.HasID = true - // Preserve null vs non-null values - if string(v) == "null" { - r.ID = nil - } else { - var id any - if err := json.Unmarshal(v, &id); err != nil { - return err - } - r.ID = id - } - } else { - r.HasID = false - r.ID = nil - } return nil } diff --git a/jsonrpc/websocket_config.go b/jsonrpc/websocket_config.go deleted file mode 100644 index 7291ba538e..0000000000 --- a/jsonrpc/websocket_config.go +++ /dev/null @@ -1,193 +0,0 @@ -package jsonrpc - -import ( - "context" - "time" -) - -type ( - // StreamErrorType represents different types of WebSocket stream errors - StreamErrorType int - - // StreamErrorHandler allows users to handle stream errors - StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *RawResponse) - - // StreamConfig contains configuration options for WebSocket streams - StreamConfig struct { - // Timeouts - RequestTimeout time.Duration // Timeout for individual requests (default: 30s) - ConnectionTimeout time.Duration // Timeout for establishing connections (default: 10s) - CloseTimeout time.Duration // Timeout for graceful stream closure (default: 5s) - - // Buffer Sizes - ResultChannelBuffer int // Buffer size for result channels (default: 1) - WriteBufferSize int // WebSocket write buffer size (default: 4096) - ReadBufferSize int // WebSocket read buffer size (default: 4096) - - // Retry Configuration - MaxRetries int // Maximum number of connection retries (default: 3) - RetryBackoffBase time.Duration // Base delay for exponential backoff (default: 1s) - RetryBackoffMax time.Duration // Maximum retry delay (default: 30s) - - // Advanced Options - EnableCompression bool // Enable WebSocket compression (default: false) - PingInterval time.Duration // Interval for sending ping frames (default: 30s) - - // Error Handling - ErrorHandler StreamErrorHandler // Optional error handler for stream events (default: nil) - } - - // StreamConfigOption is a function that modifies StreamConfig - StreamConfigOption func(*StreamConfig) -) - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout - StreamErrorNotification // Server-initiated notification received -) - -// WithRequestTimeout sets the timeout for individual requests -func WithRequestTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.RequestTimeout = timeout - } -} - -// WithConnectionTimeout sets the timeout for establishing connections -func WithConnectionTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.ConnectionTimeout = timeout - } -} - -// WithCloseTimeout sets the timeout for graceful stream closure -func WithCloseTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.CloseTimeout = timeout - } -} - -// WithResultChannelBuffer sets the buffer size for result channels -func WithResultChannelBuffer(size int) StreamConfigOption { - return func(c *StreamConfig) { - c.ResultChannelBuffer = size - } -} - -// WithWebSocketBuffers sets both read and write buffer sizes -func WithWebSocketBuffers(readSize, writeSize int) StreamConfigOption { - return func(c *StreamConfig) { - c.ReadBufferSize = readSize - c.WriteBufferSize = writeSize - } -} - -// WithRetryConfig sets retry behavior parameters -func WithRetryConfig(maxRetries int, baseDelay, maxDelay time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.MaxRetries = maxRetries - c.RetryBackoffBase = baseDelay - c.RetryBackoffMax = maxDelay - } -} - -// WithCompression enables or disables WebSocket compression -func WithCompression(enabled bool) StreamConfigOption { - return func(c *StreamConfig) { - c.EnableCompression = enabled - } -} - -// WithPingInterval sets the interval for sending ping frames -func WithPingInterval(interval time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.PingInterval = interval - } -} - -// WithErrorHandler sets the error handler for stream events -func WithErrorHandler(handler StreamErrorHandler) StreamConfigOption { - return func(c *StreamConfig) { - c.ErrorHandler = handler - } -} - -// NewStreamConfig creates a StreamConfig with the given options -func NewStreamConfig(opts ...StreamConfigOption) *StreamConfig { - config := defaultStreamConfig() - for _, opt := range opts { - opt(config) - } - return config.Validate() -} - -// defaultStreamConfig returns a StreamConfig with sensible production defaults -func defaultStreamConfig() *StreamConfig { - return &StreamConfig{ - // Reasonable timeout defaults - RequestTimeout: 30 * time.Second, - ConnectionTimeout: 10 * time.Second, - CloseTimeout: 5 * time.Second, - - // Conservative buffer sizes - ResultChannelBuffer: 1, - WriteBufferSize: 4096, - ReadBufferSize: 4096, - - // Moderate retry behavior - MaxRetries: 3, - RetryBackoffBase: 1 * time.Second, - RetryBackoffMax: 30 * time.Second, - - // Safe advanced defaults - EnableCompression: false, - PingInterval: 30 * time.Second, - } -} - -// Validate checks the configuration and applies constraints -func (c *StreamConfig) Validate() *StreamConfig { - // Ensure positive timeouts - if c.RequestTimeout <= 0 { - c.RequestTimeout = 30 * time.Second - } - if c.ConnectionTimeout <= 0 { - c.ConnectionTimeout = 10 * time.Second - } - if c.CloseTimeout <= 0 { - c.CloseTimeout = 5 * time.Second - } - - // Ensure reasonable buffer sizes - if c.ResultChannelBuffer < 1 { - c.ResultChannelBuffer = 1 - } - if c.WriteBufferSize < 1024 { - c.WriteBufferSize = 1024 - } - if c.ReadBufferSize < 1024 { - c.ReadBufferSize = 1024 - } - - // Ensure reasonable retry configuration - if c.MaxRetries < 0 { - c.MaxRetries = 0 - } - if c.RetryBackoffBase <= 0 { - c.RetryBackoffBase = 1 * time.Second - } - if c.RetryBackoffMax < c.RetryBackoffBase { - c.RetryBackoffMax = c.RetryBackoffBase * 30 - } - - // Ensure reasonable ping interval - if c.PingInterval <= 0 { - c.PingInterval = 30 * time.Second - } - - return c -} From 1729d977dce7d7d2966f202f699312799e50e17a Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 23 Aug 2026 20:39:37 -0700 Subject: [PATCH 35/43] fix(codegen): preserve released generator entry points --- codegen/generator/example.go | 6 + codegen/generator/generators.go | 143 ++++++++++++++---- codegen/generator/openapi.go | 6 + codegen/generator/plugin.go | 18 +++ .../public_api_compatibility_test.go | 40 +++++ codegen/generator/service.go | 6 + codegen/generator/transport.go | 6 + .../scenarios/scenarios.yaml | 18 ++- 8 files changed, 207 insertions(+), 36 deletions(-) diff --git a/codegen/generator/example.go b/codegen/generator/example.go index a6e8f3fc92..bad67ff0a8 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -8,11 +8,17 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) +// Example returns example service, server, and client files for roots. +func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, exampleGeneratorFactory) +} + // exampleFiles returns the service, server, and client examples selected for // this generation. func exampleFiles(plan *Plan) ([]*codegen.File, error) { diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index 3f8148a338..76fda65c73 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -3,6 +3,9 @@ package generator import ( + "fmt" + "reflect" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" ) @@ -28,43 +31,125 @@ type ( generatorFactory func() coreGenerator ) +// Generators returns the generator functions for command. Plugins may replace +// it to add, remove, or reorder generators. +var Generators = generators + +// generators returns Goa's built-in generator functions for command. +func generators(command string) ([]Genfunc, error) { + switch command { + case "gen": + return []Genfunc{Service, Transport, OpenAPI}, nil + case "example": + return []Genfunc{Example}, nil + default: + return nil, fmt.Errorf("unknown command %q", command) + } +} + +// generatorFactories returns fresh shared-plan adapters for the released +// generator list. Goa's built-in functions plan together. Additional plugin +// functions receive the prepared roots after every generated name is fixed. +func generatorFactories(command string) ([]generatorFactory, error) { + generate, err := Generators(command) + if err != nil { + return nil, err + } + factories := make([]generatorFactory, len(generate)) + for index, generator := range generate { + if generator == nil { + return nil, fmt.Errorf("generator %d for command %q is nil", index, command) + } + factories[index] = generatorFactoryFor(generator) + } + return factories, nil +} + +// generatorFactoryFor keeps built-in generators in the shared planning pass +// and adapts other released generator functions to the final rendering pass. +func generatorFactoryFor(generate Genfunc) generatorFactory { + pointer := reflect.ValueOf(generate).Pointer() + switch pointer { + case reflect.ValueOf(Service).Pointer(): + return serviceGeneratorFactory + case reflect.ValueOf(Transport).Pointer(): + return transportGeneratorFactory + case reflect.ValueOf(OpenAPI).Pointer(): + return openAPIGeneratorFactory + case reflect.ValueOf(Example).Pointer(): + return exampleGeneratorFactory + default: + return func() coreGenerator { + return coreGenerator{ + name: "external generator", + Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() + return generate(generation.GenPkg(), generation.Roots()) + }, + } + } + } +} + +// runStandaloneGenerator runs one released built-in function with a complete +// plan. It does not run registered plugins because callers invoked the core +// generator directly. +func runStandaloneGenerator(genpkg string, roots []eval.Root, factory generatorFactory) ([]*codegen.File, error) { + run := generationRun{cores: []coreGenerator{factory()}} + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err + } + return result.files, nil +} + // genGeneratorFactories returns the service, transport, and OpenAPI generators // used by the gen command. func genGeneratorFactories() []generatorFactory { return []generatorFactory{ - func() coreGenerator { - return coreGenerator{ - name: "service", - Plan: planServiceData, - Generate: serviceFiles, - } - }, - func() coreGenerator { - return coreGenerator{ - name: "transport", - Plan: planTransportData, - Generate: transportFiles, - } - }, - func() coreGenerator { - return coreGenerator{ - name: "openapi", - Plan: planOpenAPIData, - Generate: openAPIFiles, - } - }, + serviceGeneratorFactory, + transportGeneratorFactory, + openAPIGeneratorFactory, } } // exampleGeneratorFactories returns the generator used by the example command. func exampleGeneratorFactories() []generatorFactory { - return []generatorFactory{ - func() coreGenerator { - return coreGenerator{ - name: "example", - Plan: planExampleData, - Generate: exampleFiles, - } - }, + return []generatorFactory{exampleGeneratorFactory} +} + +// serviceGeneratorFactory returns a fresh service generator for one run. +func serviceGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "service", + Plan: planServiceData, + Generate: serviceFiles, + } +} + +// transportGeneratorFactory returns a fresh transport generator for one run. +func transportGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "transport", + Plan: planTransportData, + Generate: transportFiles, + } +} + +// openAPIGeneratorFactory returns a fresh OpenAPI generator for one run. +func openAPIGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "openapi", + Plan: planOpenAPIData, + Generate: openAPIFiles, + } +} + +// exampleGeneratorFactory returns a fresh example generator for one run. +func exampleGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "example", + Plan: planExampleData, + Generate: exampleFiles, } } diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index ac0a0cec57..5cb3538aad 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -4,9 +4,15 @@ package generator import ( "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" httpcodegen "goa.design/goa/v3/http/codegen" ) +// OpenAPI returns the OpenAPI documents for roots. +func OpenAPI(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, openAPIGeneratorFactory) +} + // openAPIFiles returns the OpenAPI files built during planning. func openAPIFiles(plan *Plan) ([]*codegen.File, error) { if len(plan.openapiReplacements) > 0 { diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go index 2c4dab0ec1..0eac52f595 100644 --- a/codegen/generator/plugin.go +++ b/codegen/generator/plugin.go @@ -32,6 +32,7 @@ type ( registry struct { mu sync.Mutex commands map[string][]generatorFactory + commandGenerators func(string) ([]generatorFactory, error) plugins []pluginDescriptor registeredPlugins func() []registeredPluginDescriptor sealed bool @@ -97,6 +98,7 @@ func newDefaultRegistry() *registry { registry := newRegistry() registry.commands["gen"] = genGeneratorFactories() registry.commands["example"] = exampleGeneratorFactories() + registry.commandGenerators = generatorFactories registry.registeredPlugins = snapshotRegisteredPlugins return registry } @@ -143,9 +145,25 @@ func (r *registry) registerPlugin(name, command string, position pluginPosition, // snapshot closes registration and returns copied factories in a repeatable order. func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescriptor, error) { + var ( + selectedFactories []generatorFactory + hasSelection bool + ) + if r.commandGenerators != nil { + var err error + selectedFactories, err = r.commandGenerators(command) + if err != nil { + return nil, nil, err + } + hasSelection = true + } r.mu.Lock() defer r.mu.Unlock() factories, ok := r.commands[command] + if hasSelection { + factories = selectedFactories + ok = true + } if !ok { return nil, nil, fmt.Errorf("unknown command %q", command) } diff --git a/codegen/generator/public_api_compatibility_test.go b/codegen/generator/public_api_compatibility_test.go index d449b7a076..f719076502 100644 --- a/codegen/generator/public_api_compatibility_test.go +++ b/codegen/generator/public_api_compatibility_test.go @@ -3,6 +3,7 @@ package generator import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -20,3 +21,42 @@ func TestReleasedGeneratorFunctionType(t *testing.T) { require.NoError(t, err) require.Equal(t, "generated.go", files[0].Path) } + +// TestReleasedGeneratorEntryPoints checks the generator functions that plugins +// can call directly or return from Generators. +func TestReleasedGeneratorEntryPoints(t *testing.T) { + var ( + service Genfunc = Service + transport Genfunc = Transport + openAPI Genfunc = OpenAPI + example Genfunc = Example + ) + require.NotNil(t, service) + require.NotNil(t, transport) + require.NotNil(t, openAPI) + require.NotNil(t, example) + + original := Generators + t.Cleanup(func() { + Generators = original + }) + Generators = func(command string) ([]Genfunc, error) { + if command != "custom" { + return nil, fmt.Errorf("unknown command %q", command) + } + return []Genfunc{func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "custom.go"}}, nil + }}, nil + } + generators, err := Generators("custom") + require.NoError(t, err) + require.Len(t, generators, 1) + require.NotNil(t, generators[0]) + + run, err := newGenerationRun("custom", newDefaultRegistry()) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Len(t, result.files, 1) + require.Equal(t, "custom.go", result.files[0].Path) +} diff --git a/codegen/generator/service.go b/codegen/generator/service.go index 348914c3a3..3e7efef654 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -9,6 +9,12 @@ import ( "goa.design/goa/v3/expr" ) +// Service returns the files that define service types, endpoints, clients, and +// result views for roots. +func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, serviceGeneratorFactory) +} + // serviceFiles returns the service files described by plan's completed package // declarations and the example generator created for this run. func serviceFiles(plan *Plan) ([]*codegen.File, error) { diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index 6e1c6ccb71..8939b29ce6 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -4,12 +4,18 @@ package generator import ( "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) +// Transport returns the HTTP, gRPC, and JSON-RPC files for roots. +func Transport(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, transportGeneratorFactory) +} + // transportFiles returns all HTTP, gRPC, and JSON-RPC files for one run. func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File diff --git a/jsonrpc/integration_tests/scenarios/scenarios.yaml b/jsonrpc/integration_tests/scenarios/scenarios.yaml index e5febdad07..2ad6e08df7 100644 --- a/jsonrpc/integration_tests/scenarios/scenarios.yaml +++ b/jsonrpc/integration_tests/scenarios/scenarios.yaml @@ -333,7 +333,7 @@ scenarios: jsonrpc: "2.0" id: "sse-err-1" error: - code: -32602 + code: -32603 message: "Streaming error occurred" # SSE with no ID (pure notifications) @@ -704,24 +704,28 @@ scenarios: # (to be added in harness/runner extensions if we choose to assert both paths here). # Additional HTTP protocol edge cases - - name: "http_invalid_version_notification" + - name: "http_invalid_version_request" transport: "http" request: jsonrpc: "-" # Omit version field method: "echo_string" params: "notify" - # No id -> notification + # No ID. The malformed object is still an invalid request, not a notification. expect: - no_response: true + error: + code: -32600 + message: "Invalid request" - - name: "http_missing_method_notification" + - name: "http_missing_method_request" transport: "http" request: jsonrpc: "2.0" - # No method field (invalid request) but notification (no id) + # No method or ID. A notification must still be a valid request object. params: "notify" expect: - no_response: true + error: + code: -32600 + message: "Missing method field" - name: "http_missing_method_with_id_raw" transport: "http" From 6ae234924f368344ee3e28f7947e5197f2801c53 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 23 Aug 2026 20:49:13 -0700 Subject: [PATCH 36/43] chore: satisfy repository checks --- codegen/example/example_server.go | 3 +- .../plugin_public_integration_test.go | 3 +- codegen/generator/plugin_test.go | 2 + codegen/plugin_test.go | 14 +++---- codegen/service/conversion_plan.go | 3 +- codegen/service/service_test.go | 6 --- grpc/codegen/planned_name_collision_test.go | 2 +- .../client_query_float_runtime_test.go | 4 +- .../client_response_body_runtime_test.go | 4 +- http/codegen/plan.go | 11 ----- http/codegen/service_data.go | 14 +++---- http/codegen/service_data_union_order_test.go | 4 +- http/codegen/sse_mixed_result_runtime_test.go | 7 +++- .../sse_primitive_wire_runtime_test.go | 4 +- http/codegen/transform_helper_test.go | 16 +++---- http/codegen/wire_catalog.go | 13 +----- http/codegen/wire_catalog_test.go | 42 +++++++++---------- jsonrpc/codegen/client.go | 5 +-- jsonrpc/codegen/plan.go | 15 ++++--- jsonrpc/codegen/single_endpoint_test.go | 1 - jsonrpc/types.go | 20 ++++----- 21 files changed, 86 insertions(+), 107 deletions(-) diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index c271df5d3c..f8aa049527 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -267,7 +267,8 @@ func planServerMain( // planServerMainHandlers copies each host and replaces service names with the // local variables chosen for this main function. func planServerMainHandlers(server *Data, services map[string]*serverMainServiceData) *serverMainServerData { - fixedFlags := []string{"host", "domain", "secure", "debug"} + fixedFlags := make([]string, 0, 4+len(server.Transports)) + fixedFlags = append(fixedFlags, "host", "domain", "secure", "debug") for _, transport := range server.Transports { fixedFlags = append(fixedFlags, string(transport.Type)+"-port") } diff --git a/codegen/generator/plugin_public_integration_test.go b/codegen/generator/plugin_public_integration_test.go index a5ed4f9d12..751d1fce3a 100644 --- a/codegen/generator/plugin_public_integration_test.go +++ b/codegen/generator/plugin_public_integration_test.go @@ -99,11 +99,10 @@ func runPublicLegacyHTTPEndpointChild(t *testing.T) { MountHandler: "MountCORSHandler", HandlerInit: "NewCORSHandler", }) - section.Source = strings.Replace( + section.Source = strings.ReplaceAll( section.Source, `e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }})`, `{{ if ne .Method.VarName "CORS" }}e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}{{ end }})`, - -1, ) } } diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go index ebabd86bb8..4300e73b27 100644 --- a/codegen/generator/plugin_test.go +++ b/codegen/generator/plugin_test.go @@ -515,11 +515,13 @@ func TestReleasedPluginCallbackReceivesEachRun(t *testing.T) { generatedRoots [][]eval.Root generatedFiles [][]*codegen.File ) + //nolint:unparam // The released callback signature includes an error result. prepare := func(genpkg string, roots []eval.Root) error { preparedPackages = append(preparedPackages, genpkg) preparedRoots = append(preparedRoots, append([]eval.Root(nil), roots...)) return nil } + //nolint:unparam // The released callback signature includes an error result. generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { generatedPackages = append(generatedPackages, genpkg) generatedRoots = append(generatedRoots, append([]eval.Root(nil), roots...)) diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go index ab264eba34..9d79ae3e8a 100644 --- a/codegen/plugin_test.go +++ b/codegen/plugin_test.go @@ -44,7 +44,7 @@ func TestPluginRegistryRejectsInvalidRegistration(t *testing.T) { t.Run(test.name, func(t *testing.T) { registry := pluginregistry.New() require.PanicsWithValue(t, test.error, func() { - registerPluginIn(registry, test.plugin, test.command, pluginNormal, nil, test.generate) + registerPluginIn(registry, test.plugin, test.command, pluginNormal, test.generate) }) }) } @@ -54,8 +54,8 @@ func TestPluginRegistryRejectsInvalidRegistration(t *testing.T) { // API keeps every callback when packages reuse the same command and name. func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { registry := pluginregistry.New() - registerPluginIn(registry, "plugin", "gen", pluginFirst, nil, unchangedFiles) - registerPluginIn(registry, "plugin", "gen", pluginLast, nil, changedFiles) + registerPluginIn(registry, "plugin", "gen", pluginFirst, unchangedFiles) + registerPluginIn(registry, "plugin", "gen", pluginLast, changedFiles) registrations := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) require.Len(t, registrations, 2) @@ -65,7 +65,7 @@ func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { require.NoError(t, err) require.Equal(t, "changed", files[0].Path) require.PanicsWithValue(t, "plugin registry is sealed", func() { - registerPluginIn(registry, "late", "gen", pluginNormal, nil, unchangedFiles) + registerPluginIn(registry, "late", "gen", pluginNormal, unchangedFiles) }) } @@ -73,7 +73,7 @@ func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { // registrations retained for later generation runs. func TestPluginRegistrySnapshotIsCopied(t *testing.T) { registry := pluginregistry.New() - registerPluginIn(registry, "plugin", "gen", pluginNormal, nil, unchangedFiles) + registerPluginIn(registry, "plugin", "gen", pluginNormal, unchangedFiles) first := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) first[0].Name = "changed" @@ -86,9 +86,9 @@ func TestPluginRegistrySnapshotIsCopied(t *testing.T) { // registerPluginIn applies the public registration checks to an isolated // registry so the test does not stop later process-wide registrations. -func registerPluginIn(registry *pluginregistry.Registry, name, command string, position pluginPosition, prepare PrepareFunc, generate GenerateFunc) { +func registerPluginIn(registry *pluginregistry.Registry, name, command string, position pluginPosition, generate GenerateFunc) { validatePlugin(name, command, generate) - pluginregistry.RegisterIn(registry, name, command, position, prepare, generate) + pluginregistry.RegisterIn[PrepareFunc, GenerateFunc](registry, name, command, position, nil, generate) } // unchangedFiles provides a valid generation callback for registration tests. diff --git a/codegen/service/conversion_plan.go b/codegen/service/conversion_plan.go index 060bcf69aa..03ac948d56 100644 --- a/codegen/service/conversion_plan.go +++ b/codegen/service/conversion_plan.go @@ -136,7 +136,7 @@ func collectExternalConversions(roots []*rootFacts, generation *codegen.Generati } operations[identity] = struct{}{} operation, err := planExternalConversion( - owners[owner], mapping, owner, identity, externalAlias, generation, + owners[owner], mapping, owner, identity, externalAlias, ) if err != nil { return err @@ -245,7 +245,6 @@ func planExternalConversion( owner *codegen.GeneratedPackage, identity externalConversionIdentity, externalAlias string, - generation *codegen.Generation, ) (*externalConversionFacts, error) { externalType := identity.externalType externalDataType, reflectedTypes, err := buildExternalDesignType(externalType, mapping.User) diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 3cb9f9d5a5..adecf2cbd7 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -926,12 +926,6 @@ func mustServicePlan(t *testing.T, root *expr.RootExpr) *Plan { return plan } -// mustServicesData returns the linked render model for focused analysis tests. -func mustServicesData(t *testing.T, root *expr.RootExpr) *ServicesData { - t.Helper() - return mustServicePlan(t, root).Services() -} - // mustServiceFiles renders linked plans or fails the calling test. func mustServiceFiles(t *testing.T, plans ...*Plan) []*codegen.File { t.Helper() diff --git a/grpc/codegen/planned_name_collision_test.go b/grpc/codegen/planned_name_collision_test.go index e4918e99b6..15edeaad38 100644 --- a/grpc/codegen/planned_name_collision_test.go +++ b/grpc/codegen/planned_name_collision_test.go @@ -104,7 +104,7 @@ func TestGRPCPlannedNamesSurvivePackageCollisions(t *testing.T) { // namedGRPCSections returns every section with name in file order. func namedGRPCSections(files []*codegen.File, name string) []*codegen.SectionTemplate { - var result []*codegen.SectionTemplate + result := make([]*codegen.SectionTemplate, 0, len(files)) for _, file := range files { result = append(result, file.Section(name)...) } diff --git a/http/codegen/client_query_float_runtime_test.go b/http/codegen/client_query_float_runtime_test.go index 69eb58b2c1..37b05c2649 100644 --- a/http/codegen/client_query_float_runtime_test.go +++ b/http/codegen/client_query_float_runtime_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -56,7 +57,8 @@ func TestGeneratedClientFormatsFloatQueriesCompactly(t *testing.T) { serviceFiles, err := service.Files(servicePlan) require.NoError(t, err) - files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) files = append(files, httpPlans[0].ClientTypeFiles()...) files = append(files, httpPlans[0].PathFiles()...) runGeneratedFloatQueryTest(t, files) diff --git a/http/codegen/client_response_body_runtime_test.go b/http/codegen/client_response_body_runtime_test.go index b85fe039a2..31bdb92821 100644 --- a/http/codegen/client_response_body_runtime_test.go +++ b/http/codegen/client_response_body_runtime_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" "time" @@ -42,7 +43,8 @@ func TestGeneratedClientResponseBodyLifecycle(t *testing.T) { serviceFiles, err := service.Files(servicePlan) require.NoError(t, err) - files := append(serviceFiles, clientFiles...) + files := slices.Clone(serviceFiles) + files = append(files, clientFiles...) files = append(files, httpPlans[0].ClientTypeFiles()...) files = append(files, httpPlans[0].PathFiles()...) runGeneratedResponseBodyLifecycleTest(t, files) diff --git a/http/codegen/plan.go b/http/codegen/plan.go index 700877caf1..99653d4dea 100644 --- a/http/codegen/plan.go +++ b/http/codegen/plan.go @@ -1410,17 +1410,6 @@ func requireHTTPCLIImports(outputPackage *codegen.GeneratedPackage) error { return nil } -// serviceHasResultViews reports whether transport files reference the service -// views package. -func serviceHasResultViews(service *expr.HTTPServiceExpr) bool { - for _, endpoint := range service.HTTPEndpoints { - if _, ok := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); ok { - return true - } - } - return false -} - // serviceHasMultipartRequest reports whether the example package writes a // multipart callback whose signature uses this service's generated server. func serviceHasMultipartRequest(service *expr.HTTPServiceExpr) bool { diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index ab6c61a699..d9eef00e31 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1321,9 +1321,9 @@ func collectPlannedWireTypes(api string, httpService *expr.HTTPServiceExpr, plan addMarshalTags(request) serverRequestPolicy := jsonBodyPolicy(true, true, true, "") clientRequestPolicy := jsonBodyPolicy(true, false, false, "") - server.collect(request, wireRequestBody, serverRequestPolicy, "", api) + server.collect(request, wireRequestBody, serverRequestPolicy, api) server.addValidationRoot(request, serverRequestPolicy) - clientRequest := client.collect(request, wireRequestBody, clientRequestPolicy, "", api) + clientRequest := client.collect(request, wireRequestBody, clientRequestPolicy, api) if userType, named := request.Type.(expr.UserType); named && userType.Attribute().Validation != nil { client.addValidationRoot(request, clientRequestPolicy) } @@ -1340,13 +1340,13 @@ func collectPlannedWireTypes(api string, httpService *expr.HTTPServiceExpr, plan addMarshalTags(streaming) serverStreamPolicy := jsonBodyPolicy(true, true, true, "") clientStreamPolicy := jsonBodyPolicy(true, false, false, "") - serverStream := server.collect(streaming, wireStreamPayload, serverStreamPolicy, "", api) + serverStream := server.collect(streaming, wireStreamPayload, serverStreamPolicy, api) server.addValidationRoot(streaming, serverStreamPolicy) if endpoint.UsesWebSocket() && needInit(endpoint.MethodExpr.StreamingPayload.Type) && serverStream != nil { serverStream.needsConstructor = true planned.streamPayloads[endpoint] = serverStream } - clientStream := client.collect(streaming, wireStreamPayload, clientStreamPolicy, "", api) + clientStream := client.collect(streaming, wireStreamPayload, clientStreamPolicy, api) if userType, named := streaming.Type.(expr.UserType); !named || userType.Attribute().Validation != nil { client.addValidationRoot(streaming, clientStreamPolicy) } @@ -2743,7 +2743,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A variableWire := viewed && origin == "" && clientResponseViewName(e, md) == "" && (e.UsesSSE() || e.IsJSONRPC()) if needInit(result.Type) && !variableWire { init = sds.buildResponseResultInit( - e, resp, result, resAttr, clientRespBody, origin, + e, resp, result, clientRespBody, origin, headersData, cookiesData, sd, "", clientBodyData, ) } @@ -2797,7 +2797,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A sd.clientWireTypes.applyNames(body, wireResponseBody, policy) } resultInit := sds.buildResponseResultInit( - e, resp, result, resAttr, body, origin, + e, resp, result, body, origin, headersData, cookiesData, sd, viewName, clientBody, ) representation := &ViewedRepresentationData{ @@ -2856,7 +2856,7 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // buildResponseResultInit builds the data used to write one client result // function. It uses the name chosen by NewPlans and converts the decoded HTTP // body, headers, and cookies into the method result. -func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, resAttr, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { +func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { var ( svc = sd.Service md = svc.Method(e.Name()) diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index 49bb269511..ba0f2ca84f 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -78,7 +78,7 @@ func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testi } catalog, generation := testWireTypeCatalog(t) - catalog.collect(bodies, wireAttribute, wireTypePolicy{}, "") + catalog.collect(bodies, wireAttribute, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(bodies, wireAttribute, wireTypePolicy{}) @@ -200,7 +200,7 @@ func sameShapedValueUnionDSL() { func collectHTTPUnionTypeNames(t *testing.T, att *expr.AttributeExpr) map[string]string { t.Helper() catalog, generation := testWireTypeCatalog(t) - catalog.collect(att, wireAttribute, wireTypePolicy{}, "") + catalog.collect(att, wireAttribute, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) names := make(map[string]string, len(catalog.unions)) diff --git a/http/codegen/sse_mixed_result_runtime_test.go b/http/codegen/sse_mixed_result_runtime_test.go index 11218453d7..2f3c2daf1f 100644 --- a/http/codegen/sse_mixed_result_runtime_test.go +++ b/http/codegen/sse_mixed_result_runtime_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" "time" @@ -36,7 +37,8 @@ func TestGeneratedMixedSSEClientValidatesMappedWireBody(t *testing.T) { serviceFiles, err := service.Files(servicePlan) require.NoError(t, err) - files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) files = append(files, httpPlans[0].ClientTypeFiles()...) files = append(files, httpPlans[0].PathFiles()...) runGeneratedMixedSSEClientTest(t, files) @@ -60,7 +62,8 @@ func TestGeneratedMixedSSEResultShapesCompile(t *testing.T) { serviceFiles, err := service.Files(servicePlan) require.NoError(t, err) - files := append(serviceFiles, httpPlans[0].ClientFiles()...) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) files = append(files, httpPlans[0].ClientTypeFiles()...) files = append(files, httpPlans[0].ServerFiles()...) files = append(files, httpPlans[0].ServerTypeFiles()...) diff --git a/http/codegen/sse_primitive_wire_runtime_test.go b/http/codegen/sse_primitive_wire_runtime_test.go index 2bba5465b4..6c584e7381 100644 --- a/http/codegen/sse_primitive_wire_runtime_test.go +++ b/http/codegen/sse_primitive_wire_runtime_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" "time" @@ -36,7 +37,8 @@ func TestGeneratedSSEFieldWireFormat(t *testing.T) { serviceFiles, err := service.Files(servicePlan) require.NoError(t, err) - files := append(serviceFiles, httpPlans[0].ServerFiles()...) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ServerFiles()...) files = append(files, httpPlans[0].ClientFiles()...) files = append(files, httpPlans[0].ServerTypeFiles()...) files = append(files, httpPlans[0].ClientTypeFiles()...) diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index 5942319e9a..d585395384 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -24,7 +24,7 @@ func TestTransformHelperOrderingSupportsMoreThan255Functions(t *testing.T) { source, target := manyDistinctTransformChildren(257, false) catalog, generation := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - catalog.collect(target, wireRequestBody, policy, "") + catalog.collect(target, wireRequestBody, policy) catalog.collectTransform(source, target, "marshal", "many helpers", wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, @@ -41,7 +41,7 @@ func TestTransformHandleSelectsTheCollectedPlan(t *testing.T) { source, target := manyDistinctTransformChildren(1, false) catalog, generation := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - catalog.collect(target, wireRequestBody, policy, "") + catalog.collect(target, wireRequestBody, policy) first := catalog.collectTransform(source, target, "marshal", "first", wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, @@ -68,7 +68,7 @@ func TestTransformHandleRejectsAnotherCatalog(t *testing.T) { source, target := manyDistinctTransformChildren(1, false) first, firstGeneration := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - first.collect(target, wireRequestBody, policy, "") + first.collect(target, wireRequestBody, policy) handle := first.collectTransform(source, target, "marshal", "first", wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, @@ -196,7 +196,7 @@ func TestTransformHelperUsesRetainedServicePackagePreference(t *testing.T) { source, target := manyDistinctTransformChildren(1, false) catalog, generation := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - catalog.collect(target, wireRequestBody, policy, "") + catalog.collect(target, wireRequestBody, policy) catalog.collectTransform(source, target, "marshal", test.name, wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, @@ -239,7 +239,7 @@ func TestViewedTransformHelpersNameViewsPackage(t *testing.T) { root := expr.RunDSL(t, testdata.ExplicitBodyUserResultObjectDSL) plan := linkedHTTPPlanForRoot(t, root) service := plan.services.Get("ServiceExplicitBodyUserResultObject") - var names []string + names := make([]string, 0, len(service.ClientTransformHelpers)) for _, helper := range service.ClientTransformHelpers { names = append(names, helper.Name) } @@ -250,7 +250,7 @@ func TestTransformHelpersUseConciseServiceAndWireTypeNames(t *testing.T) { root := expr.RunDSL(t, conciseTransformHelperDSL) plan := linkedHTTPPlanForRoot(t, root) service := plan.services.Get("Storage") - var names []string + names := make([]string, 0, len(service.ClientTransformHelpers)) for _, helper := range service.ClientTransformHelpers { names = append(names, helper.Name) } @@ -496,7 +496,7 @@ func plannedTransformHelperNames(t *testing.T, reverse bool) map[string]string { source, target := manyDistinctTransformChildren(3, reverse) catalog, generation := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - catalog.collect(target, wireRequestBody, policy, "") + catalog.collect(target, wireRequestBody, policy) catalog.collectTransform(source, target, "marshal", "ordered helpers", wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, @@ -521,7 +521,7 @@ func plannedMatchingTransforms( source, target := manyDistinctTransformChildren(1, false) catalog, generation := testWireTypeCatalog(t) policy := jsonBodyPolicy(true, false, false, "") - catalog.collect(target, wireRequestBody, policy, "") + catalog.collect(target, wireRequestBody, policy) layout := wireTransformLayout{ wireSide: wireTransformTarget, wirePolicy: policy, diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go index 7ea3d3a955..fde3fc5ff3 100644 --- a/http/codegen/wire_catalog.go +++ b/http/codegen/wire_catalog.go @@ -271,8 +271,8 @@ func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { } // collect records attribute and every named type it contains. -func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, api ...string) *wireTypeRecord { - return c.collectWithReleasedNames(attribute, role, policy, preferred, nil, api...) +func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, api ...string) *wireTypeRecord { + return c.collectWithReleasedNames(attribute, role, policy, "", nil, api...) } // collectWithReleasedNames records a response while keeping the public names @@ -1148,15 +1148,6 @@ func (r *wireTypeRecord) addReleasedName(name string) { slices.Sort(r.releasedNames) } -// replaceReleasedName records the public spelling used for one response after -// removing the name that the shared type planner first assigned to that use. -func (r *wireTypeRecord) replaceReleasedName(current, released string) { - r.releasedNames = slices.DeleteFunc(r.releasedNames, func(name string) bool { - return name == current - }) - r.addReleasedName(released) -} - // preferredName keeps a released spelling only when it still names exactly one // retained declaration. Shared declarations use their current designed name. func (r *wireTypeRecord) preferredName() string { diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go index da72024358..128856e04a 100644 --- a/http/codegen/wire_catalog_test.go +++ b/http/codegen/wire_catalog_test.go @@ -19,10 +19,10 @@ func TestWireTypeCatalogIdentity(t *testing.T) { catalog, generation := testWireTypeCatalog(t) firstBody := makeHTTPType(&expr.AttributeExpr{Type: first}) - catalog.collect(firstBody, wireRequestBody, request, "") - catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request, "") - catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request, "") - catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response, "") + catalog.collect(firstBody, wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) linkTestWireTypeCatalog(t, generation, catalog) firstRecord := catalog.lookupUser(firstBody, wireRequestBody, request) reusedRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) @@ -44,7 +44,7 @@ func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { catalog, generation := testWireTypeCatalog(t) body := makeHTTPType(&expr.AttributeExpr{Type: recursive}) policy := wireTypePolicy{request: true, pointer: true} - catalog.collect(body, wireRequestBody, policy, "") + catalog.collect(body, wireRequestBody, policy) linkTestWireTypeCatalog(t, generation, catalog) record := catalog.lookupUser(body, wireRequestBody, policy) @@ -89,7 +89,7 @@ func TestWireTypeCatalogPreservesReleasedNestedNames(t *testing.T) { for _, test := range cases { t.Run(test.name, func(t *testing.T) { catalog, generation := testWireTypeCatalog(t) - catalog.collect(test.body, test.role, wireTypePolicy{}, "") + catalog.collect(test.body, test.role, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) child := firstWireUserType(test.body) @@ -105,8 +105,8 @@ func TestWireTypeCatalogKeepsCurrentNameForSharedReleasedDeclarations(t *testing stream := wireCatalogContainer(child) catalog, generation := testWireTypeCatalog(t) - catalog.collect(request, wireRequestBody, wireTypePolicy{}, "") - catalog.collect(stream, wireStreamPayload, wireTypePolicy{}, "") + catalog.collect(request, wireRequestBody, wireTypePolicy{}) + catalog.collect(stream, wireStreamPayload, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) record := catalog.lookupUser(firstWireUserType(request), wireAttribute, wireTypePolicy{}) @@ -116,7 +116,7 @@ func TestWireTypeCatalogKeepsCurrentNameForSharedReleasedDeclarations(t *testing func TestWireTypeCatalogSuffixesReleasedNameAfterPackageCollision(t *testing.T) { body := wireCatalogContainer(wireCatalogType("Child", "child", "value", true)) catalog, generation := testWireTypeCatalog(t, "ChildRequestBody") - catalog.collect(body, wireRequestBody, wireTypePolicy{}, "") + catalog.collect(body, wireRequestBody, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) record := catalog.lookupUser(firstWireUserType(body), wireAttribute, wireTypePolicy{}) @@ -129,8 +129,8 @@ func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *te withValidator := wireTypePolicy{pointer: true, validate: true} catalog, generation := testWireTypeCatalog(t) - first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator, "") - second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator, "") + first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator) + second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator) catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ {Name: "shared", Attribute: expr.DupAtt(typeAttribute)}, }}, withValidator) @@ -180,7 +180,7 @@ func TestWireTypeCatalogErrorDescriptionUsesAllPlannedErrors(t *testing.T) { attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} policy := wireTypePolicy{pointer: true} catalog, generation := testWireTypeCatalog(t) - record := catalog.collect(attribute, wireResponseBody, policy, "") + record := catalog.collect(attribute, wireResponseBody, policy) for _, use := range test.uses { record.addErrorUse(use) } @@ -201,7 +201,7 @@ func TestWireTypeCatalogPlansNestedValidatorNameWithPackageNames(t *testing.T) { attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} policy := wireTypePolicy{pointer: true, validate: true} catalog, generation := testWireTypeCatalog(t, "validateShared") - record := catalog.collect(attribute, wireAttribute, policy, "") + record := catalog.collect(attribute, wireAttribute, policy) catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ {Name: "shared", Attribute: expr.DupAtt(attribute)}, }}, policy) @@ -219,7 +219,7 @@ func TestWireTypeCatalogDoesNotRewriteValidationCalls(t *testing.T) { attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} policy := wireTypePolicy{pointer: true, validate: true} catalog, generation := testWireTypeCatalog(t, "ValidateShared") - record := catalog.collect(attribute, wireAttribute, policy, "") + record := catalog.collect(attribute, wireAttribute, policy) linkTestWireTypeCatalog(t, generation, catalog) catalog.bind(record, &TypeData{ @@ -242,7 +242,7 @@ func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { attribute := &expr.AttributeExpr{Type: union} catalog, generation := testWireTypeCatalog(t) - catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) require.Len(t, catalog.unionOccurrences, 1) linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) @@ -254,7 +254,7 @@ func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} policy := wireTypePolicy{pointer: true} catalog, generation := testWireTypeCatalog(t, "Shared") - catalog.collect(attribute, wireAttribute, policy, "") + catalog.collect(attribute, wireAttribute, policy) linkTestWireTypeCatalog(t, generation, catalog) first := catalog.lookupUser(attribute, wireAttribute, policy) @@ -269,8 +269,8 @@ func TestWireTypeCatalogBindingUsesCurrentLayoutPolicy(t *testing.T) { valuePolicy := wireTypePolicy{} pointerPolicy := wireTypePolicy{pointer: true} catalog, generation := testWireTypeCatalog(t) - catalog.collect(attribute, wireAttribute, valuePolicy, "") - catalog.collect(attribute, wireAttribute, pointerPolicy, "") + catalog.collect(attribute, wireAttribute, valuePolicy) + catalog.collect(attribute, wireAttribute, pointerPolicy) linkTestWireTypeCatalog(t, generation, catalog) valueRecord := catalog.lookupUser(attribute, wireAttribute, valuePolicy) @@ -291,7 +291,7 @@ func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { }} catalog, generation := testWireTypeCatalog(t) - catalog.collect(attribute, wireAttribute, wireTypePolicy{}, "") + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) linkTestWireTypeCatalog(t, generation, catalog) catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) @@ -306,11 +306,11 @@ func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Known", "known", "value", true)} policy := wireTypePolicy{request: true, pointer: true} catalog, generation := testWireTypeCatalog(t) - catalog.collect(typeAttribute, wireRequestBody, policy, "") + catalog.collect(typeAttribute, wireRequestBody, policy) linkTestWireTypeCatalog(t, generation, catalog) require.Panics(t, func() { - catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy, "") + catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy) }) require.Panics(t, func() { catalog.lookupUser(&expr.AttributeExpr{Type: wireCatalogType("Unknown", "unknown", "value", true)}, wireRequestBody, policy) diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 7dac6a7883..685fd2dce2 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -127,9 +127,8 @@ func clientFile(planned *servicePlan) *codegen.File { codegen.GoaNamedImport("http", "goahttp"), data.ClientServiceImport(), } - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", imports), - } + sections := make([]*codegen.SectionTemplate, 0, 3+len(planned.endpoints)) + sections = append(sections, codegen.Header(title, "client", imports)) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-struct", Source: jsonrpcTemplates.Read(clientStructT), diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go index b19a4c613f..989ee7f565 100644 --- a/jsonrpc/codegen/plan.go +++ b/jsonrpc/codegen/plan.go @@ -522,11 +522,10 @@ func collectServicePlan(generation *codegen.Generation, input PlanInput, transpo pathName: pathName, helpers: make(map[string]*viewedHelperDeclarations), } - declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, method string, role uint8) (*codegen.NameDeclaration, error) { - declaration := codegen.NewPreferredName(kind, preferred, visibility, jsonRPCNameOrder{ + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, role uint8) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, codegen.UnexportedName, jsonRPCNameOrder{ api: planned.api, service: planned.name, - method: method, role: role, }) if err := pkg.DeclareName(declaration); err != nil { @@ -542,26 +541,26 @@ func collectServicePlan(generation *codegen.Generation, input PlanInput, transpo hasHTTP = true } } - planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", codegen.UnexportedName, "", jsonRPCBufferPoolRole) + planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", jsonRPCBufferPoolRole) if err != nil { return nil, err } - planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", codegen.UnexportedName, "", jsonRPCEncodeErrorRole) + planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", jsonRPCEncodeErrorRole) if err != nil { return nil, err } if hasHTTP { - planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", codegen.UnexportedName, "", jsonRPCBatchWriterRole) + planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", jsonRPCBatchWriterRole) if err != nil { return nil, err } } if hasSSE { - planned.serverNames.sseStream, err = declare(server, codegen.NameType, "sseServerStream", codegen.UnexportedName, "", jsonRPCSSEStreamRole) + planned.serverNames.sseStream, err = declare(server, codegen.NameType, "sseServerStream", jsonRPCSSEStreamRole) if err != nil { return nil, err } - planned.serverNames.sseBuffer, err = declare(server, codegen.NameType, "sseEventBuffer", codegen.UnexportedName, "", jsonRPCSSEBufferRole) + planned.serverNames.sseBuffer, err = declare(server, codegen.NameType, "sseEventBuffer", jsonRPCSSEBufferRole) if err != nil { return nil, err } diff --git a/jsonrpc/codegen/single_endpoint_test.go b/jsonrpc/codegen/single_endpoint_test.go index b768cfef50..3154c1e8c4 100644 --- a/jsonrpc/codegen/single_endpoint_test.go +++ b/jsonrpc/codegen/single_endpoint_test.go @@ -110,5 +110,4 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { require.NotNil(t, svc.Meta) assert.NotNil(t, svc.Meta["jsonrpc:service"], "service should be auto-marked as JSON-RPC") }) - } diff --git a/jsonrpc/types.go b/jsonrpc/types.go index 2f552b35cc..7051d8312b 100644 --- a/jsonrpc/types.go +++ b/jsonrpc/types.go @@ -185,26 +185,24 @@ func (r *RawRequest) UnmarshalJSON(data []byte) error { if string(v) != "null" { if err := json.Unmarshal(v, &r.ID); err != nil { r.Invalid = true - return nil - } - switch r.ID.(type) { - case string, float64: - default: - r.ID = nil - r.Invalid = true + } else { + switch r.ID.(type) { + case string, float64: + default: + r.ID = nil + r.Invalid = true + } } } } if v, ok := raw["jsonrpc"]; ok { - if err := json.Unmarshal(v, &r.JSONRPC); err != nil { + if json.Unmarshal(v, &r.JSONRPC) != nil { r.Invalid = true - return nil } } if v, ok := raw["method"]; ok { - if err := json.Unmarshal(v, &r.Method); err != nil { + if json.Unmarshal(v, &r.Method) != nil { r.Invalid = true - return nil } } if v, ok := raw["params"]; ok { From be08c8e1328febe5caff2ff5524000458bed4249 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 23 Aug 2026 20:56:34 -0700 Subject: [PATCH 37/43] fix(codegen): keep planned output paths portable --- codegen/example/plan.go | 4 ++-- codegen/generated_types_test.go | 2 +- codegen/generation.go | 13 ++++++++----- grpc/codegen/import_plan.go | 5 ++--- http/codegen/openapi_plan_test.go | 9 +++++---- http/codegen/plan.go | 4 ++-- http/codegen/plan_test.go | 1 + 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/codegen/example/plan.go b/codegen/example/plan.go index 40c85ccb86..cffc3e5b4f 100644 --- a/codegen/example/plan.go +++ b/codegen/example/plan.go @@ -67,7 +67,7 @@ func (p *Plan) Root(servicePlan *service.Plan) (*Root, bool) { func planMainPackages(generation *codegen.Generation, servicePlan *service.Plan, server *Data) error { rootPath := RootPath(generation.GenPkg()) serverPath := path.Join(rootPath, "cmd", server.Dir) - serverPackage, err := generation.ClaimOutputPackage(serverPath, filepath.Dir(server.serverMainPath)) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Dir(filepath.ToSlash(server.serverMainPath))) if err != nil { return err } @@ -98,7 +98,7 @@ func planMainPackages(generation *codegen.Generation, servicePlan *service.Plan, return nil } clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") - clientPackage, err := generation.ClaimOutputPackage(clientPath, filepath.Dir(server.clientMainPath)) + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Dir(filepath.ToSlash(server.clientMainPath))) if err != nil { return err } diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go index f2a7ff789c..f8a6b508dd 100644 --- a/codegen/generated_types_test.go +++ b/codegen/generated_types_test.go @@ -562,7 +562,7 @@ func TestExplicitOutputPackageClaims(t *testing.T) { // host-specific path separators. func TestExplicitOutputPackageRejectsInvalidDirectories(t *testing.T) { generation := mustTestGeneration(t, "generated.local/gen", nil) - for _, directory := range []string{"../starter", "/starter", `starter\service`} { + for _, directory := range []string{"../starter", "/starter", "C:/starter", `starter\service`} { _, err := generation.ClaimOutputPackage("generated.local/starter", directory) require.Error(t, err, directory) } diff --git a/codegen/generation.go b/codegen/generation.go index 6f0caa5136..d77311dc90 100644 --- a/codegen/generation.go +++ b/codegen/generation.go @@ -194,7 +194,7 @@ func (g *Generation) OwnsName(declaration *NameDeclaration) bool { // second result is false when no package claimed the file's directory during // planning or when outputPath is not a valid relative output path. func (g *Generation) PackageForFile(outputPath string) (*GeneratedPackage, bool) { - directory, err := canonicalOutputDirectory(filepath.Dir(outputPath)) + directory, err := canonicalOutputDirectory(path.Dir(filepath.ToSlash(outputPath))) if err != nil { return nil, false } @@ -280,11 +280,14 @@ func canonicalOutputDirectory(outputDirectory string) (string, error) { if strings.Contains(outputDirectory, "\\") { return "", fmt.Errorf("output directory %q contains a backslash", outputDirectory) } - if filepath.IsAbs(outputDirectory) { + if path.IsAbs(outputDirectory) { return "", fmt.Errorf("output directory %q must be relative", outputDirectory) } - canonical := filepath.Clean(filepath.FromSlash(outputDirectory)) - if canonical == ".." || strings.HasPrefix(canonical, ".."+string(filepath.Separator)) { + if strings.Contains(outputDirectory, ":") { + return "", fmt.Errorf("output directory %q is not portable", outputDirectory) + } + canonical := path.Clean(outputDirectory) + if canonical == ".." || strings.HasPrefix(canonical, "../") { return "", fmt.Errorf("output directory %q escapes the generation working directory", outputDirectory) } return canonical, nil @@ -340,5 +343,5 @@ func generatedOutputDirectory(genpkg, importPath string) (string, error) { genpkg, ) } - return filepath.Clean(filepath.FromSlash(path.Join(Gendir, relative))), nil + return canonicalOutputDirectory(path.Join(Gendir, relative)) } diff --git a/grpc/codegen/import_plan.go b/grpc/codegen/import_plan.go index 90e017be16..c33d335b2f 100644 --- a/grpc/codegen/import_plan.go +++ b/grpc/codegen/import_plan.go @@ -5,7 +5,6 @@ package codegen import ( "path" - "path/filepath" "strings" "goa.design/goa/v3/codegen" @@ -157,7 +156,7 @@ func planGRPCExampleImports(generation *codegen.Generation, plan *Plan, root *ex rootPath := path.Dir(generation.GenPkg()) for _, server := range root.Servers { serverPath := path.Join(rootPath, "cmd", server.Dir) - serverPackage, err := generation.ClaimOutputPackage(serverPath, filepath.Join("cmd", server.Dir)) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Join("cmd", server.Dir)) if err != nil { return err } @@ -194,7 +193,7 @@ func planGRPCExampleImports(generation *codegen.Generation, plan *Plan, root *ex continue } clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") - clientPackage, err := generation.ClaimOutputPackage(clientPath, filepath.Join("cmd", server.Dir+"-cli")) + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Join("cmd", server.Dir+"-cli")) if err != nil { return err } diff --git a/http/codegen/openapi_plan_test.go b/http/codegen/openapi_plan_test.go index e14603156b..3749f79dbc 100644 --- a/http/codegen/openapi_plan_test.go +++ b/http/codegen/openapi_plan_test.go @@ -3,6 +3,7 @@ package codegen import ( "bytes" + "path/filepath" "testing" "text/template" @@ -74,10 +75,10 @@ func TestNewOpenAPIPlanFromSpecsUsesExactVersionsAndPaths(t *testing.T) { paths[index] = file.Path } require.Equal(t, []string{ - "gen/docs/api.v2.json", - "gen/docs/api.v2.yaml", - "gen/reference/api.json", - "gen/reference/api.yaml", + filepath.Join("gen", "docs", "api.v2.json"), + filepath.Join("gen", "docs", "api.v2.yaml"), + filepath.Join("gen", "reference", "api.json"), + filepath.Join("gen", "reference", "api.yaml"), }, paths) } diff --git a/http/codegen/plan.go b/http/codegen/plan.go index 99653d4dea..5d995f89ed 100644 --- a/http/codegen/plan.go +++ b/http/codegen/plan.go @@ -1912,10 +1912,10 @@ func (p *jsonRPCServicePlan) prepareFileImports(services *ServicesData) { p.fileImports[clientPath] = cloneImportSpecs(services.fileImports[filepathKey(clientPath)]) p.fileImports[serverPath] = cloneImportSpecs(services.fileImports[filepathKey(serverPath)]) if p.clientCodec != nil { - p.fileImports[p.clientCodec.Path] = cloneImportSpecs(services.fileImports[filepathKey(p.clientCodec.Path)]) + p.fileImports[filepathKey(p.clientCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.clientCodec.Path)]) } if p.serverCodec != nil { - p.fileImports[p.serverCodec.Path] = cloneImportSpecs(services.fileImports[filepathKey(p.serverCodec.Path)]) + p.fileImports[filepathKey(p.serverCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.serverCodec.Path)]) } hasSSE := false for _, endpoint := range p.data.Endpoints { diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go index 36277b9c1b..8b9261ef9b 100644 --- a/http/codegen/plan_test.go +++ b/http/codegen/plan_test.go @@ -272,6 +272,7 @@ func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { clientPath := stored.clientCodec.Path imports := service.FileImports(clientPath) require.NotEmpty(t, imports) + require.Equal(t, imports, service.FileImports(strings.ReplaceAll(clientPath, "/", `\`))) original := *imports[0] imports[0].Path = "changed.example/package" freshImports := service.FileImports(clientPath) From 4c9eac4e8c7c67fe49abcdbe342f69a09e5759b8 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Sun, 23 Aug 2026 21:07:22 -0700 Subject: [PATCH 38/43] fix(codegen): support Windows tool output --- codegen/service/imports_test.go | 9 +++++---- grpc/codegen/protobuf_tools.go | 9 ++++++++- grpc/codegen/protobuf_tools_test.go | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go index c0912ee841..fa5f4c3ea7 100644 --- a/codegen/service/imports_test.go +++ b/codegen/service/imports_test.go @@ -5,6 +5,7 @@ package service import ( "go/format" "path" + "path/filepath" "strings" "testing" @@ -163,7 +164,7 @@ func TestRegisteredRootsShareImportAliases(t *testing.T) { files := mustServiceFiles(t, firstPlan, secondPlan) for _, name := range []string{"first_payload.go", "second_payload.go"} { - file := findFile(files, path.Join("gen", "types", name)) + file := findFile(files, filepath.Join("gen", "types", name)) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) require.Contains(t, code, `alpha "example.com/shared/value"`) @@ -237,7 +238,7 @@ func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { }) }) plan := mustServicePlan(t, root) - file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "service.go")) + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) require.Contains(t, code, "jason.RawMessage") @@ -341,7 +342,7 @@ func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { require.Equal(t, "valuesviews", services.aliases.name(servicePath, viewsPath)) require.Equal(t, "valuesviews2", services.aliases.name(servicePath, "example.com/custom/views")) - file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "service.go")) + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) _, err := format.Source([]byte(code)) @@ -378,7 +379,7 @@ func TestViewValidationReservesOnlyUsedImports(t *testing.T) { outputPackage := servicePackagePath(plan.Services().generation.GenPkg(), root.Service("Values")) + "/views" require.Equal(t, "utf8", plan.Services().aliases.name(outputPackage, customUTF8)) - file := findFile(mustServiceFiles(t, plan), path.Join("gen", "values", "views", "view.go")) + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "views", "view.go")) require.NotNil(t, file) code := renderSections(t, file.SectionTemplates) require.Contains(t, code, `"`+customUTF8+`"`) diff --git a/grpc/codegen/protobuf_tools.go b/grpc/codegen/protobuf_tools.go index 883ffd9282..50174e0c9f 100644 --- a/grpc/codegen/protobuf_tools.go +++ b/grpc/codegen/protobuf_tools.go @@ -111,12 +111,19 @@ func resolveProtobufPlugin(resolver protobufToolResolver, name, wantVersion stri if err != nil { return "", fmt.Errorf("read protobuf plugin %s version: %w", name, err) } - if version != wantVersion { + if !protobufPluginVersionMatches(name, version, wantVersion) { return "", fmt.Errorf("protobuf plugin %s reports version %s, want %s", name, version, wantVersion) } return path, nil } +// protobufPluginVersionMatches accepts the program name printed on Unix and +// the same name with the executable suffix printed on Windows. +func protobufPluginVersionMatches(name, version, wantVersion string) bool { + windowsVersion := name + ".exe" + strings.TrimPrefix(wantVersion, name) + return version == wantVersion || version == windowsVersion +} + // resolveProtobufExecutable returns an absolute path for one executable. func resolveProtobufExecutable(name string) (string, error) { path, err := exec.LookPath(name) diff --git a/grpc/codegen/protobuf_tools_test.go b/grpc/codegen/protobuf_tools_test.go index 0977e8f3a4..f7d538b638 100644 --- a/grpc/codegen/protobuf_tools_test.go +++ b/grpc/codegen/protobuf_tools_test.go @@ -122,6 +122,20 @@ func TestNewPlansChecksProtobufPluginVersions(t *testing.T) { } } +// TestResolveProtobufPluginAcceptsWindowsExecutableName checks the version text +// printed when Windows adds its executable suffix to the plugin name. +func TestResolveProtobufPluginAcceptsWindowsExecutableName(t *testing.T) { + resolver := fixedProtobufToolResolver() + resolver.version = func(string) (string, error) { + return "protoc-gen-go.exe v1.36.12", nil + } + + plugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + + require.NoError(t, err) + require.Equal(t, "/tools/protoc-gen-go", plugin) +} + // TestNewPlansRejectsGoPluginOverrides checks every protoc flag form that // could replace either required Go plugin. func TestNewPlansRejectsGoPluginOverrides(t *testing.T) { From 5d25a4e56cf7a6c595778ed47072794439f89700 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 24 Aug 2026 00:24:58 -0700 Subject: [PATCH 39/43] fix(cli): honor JSON contracts in generated commands --- codegen/cli/cli.go | 51 +++++---- codegen/cli/json_example_test.go | 41 +++++++ .../json_example_primitive_map_keys.golden | 35 ++++++ grpc/codegen/client_cli.go | 1 + grpc/codegen/client_cli_test.go | 1 + grpc/codegen/import_plan.go | 1 + grpc/codegen/service_data.go | 107 +++++++++++++++++- grpc/codegen/testdata/dsls.go | 17 +++ ...endpoint-endpoint-with-interceptors.golden | 6 +- .../client_cli_payload-with-message.go.golden | 35 ++++++ 10 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 codegen/cli/json_example_test.go create mode 100644 codegen/cli/testdata/golden/json_example_primitive_map_keys.golden create mode 100644 grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index ed94c1ac26..1b7f65884a 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -203,10 +203,11 @@ type ( // flagValuePlan records how command-line text becomes one generated Go value. flagValuePlan struct { - kind expr.Kind - typeName string - typeRef string - alias bool + kind expr.Kind + typeName string + typeRef string + alias bool + protobufMessage bool } // FieldData contains the data needed to generate the code that initializes a @@ -635,6 +636,15 @@ func NewFlagPlan(attribute *expr.AttributeExpr, typeName, typeRef string, valida } } +// NewProtobufFlagPlan records a command-line flag whose JSON value is a +// protobuf message. The generated code uses protobuf's JSON decoder so the +// accepted field names and values match the message contract. +func NewProtobufFlagPlan(attribute *expr.AttributeExpr, typeName string) *FlagPlan { + plan := NewFlagPlan(attribute, typeName, typeName, nil) + plan.value.protobufMessage = true + return plan +} + // NewFlagData creates flag data from the released string type description. // // svcn is the service name @@ -780,7 +790,8 @@ func fieldLoadCode( return fmt.Sprintf("%s%s%s", startIf, code, endIf), declErr } -// jsonExample generates a json example +// jsonExample turns a generated value into the JSON text shown in CLI help and +// invalid-value errors. func jsonExample(v any) string { // In JSON, keys must be a string. But goa allows map keys to be anything. r := reflect.ValueOf(v) @@ -790,21 +801,17 @@ func jsonExample(v any) string { a := make(map[string]any, len(keys)) var kstr string for _, k := range keys { - switch t := k.Interface().(type) { - case bool: - kstr = strconv.FormatBool(t) - case int32: - kstr = strconv.FormatInt(int64(t), 10) - case int64: - kstr = strconv.FormatInt(t, 10) - case int: - kstr = strconv.Itoa(t) - case float32: - kstr = strconv.FormatFloat(float64(t), 'f', -1, 32) - case float64: - kstr = strconv.FormatFloat(t, 'f', -1, 64) + switch k.Kind() { + case reflect.Bool: + kstr = strconv.FormatBool(k.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + kstr = strconv.FormatInt(k.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + kstr = strconv.FormatUint(k.Uint(), 10) + case reflect.Float32, reflect.Float64: + kstr = strconv.FormatFloat(k.Float(), 'f', -1, k.Type().Bits()) default: - kstr = k.String() + panic(fmt.Sprintf("unsupported CLI example map key kind %s", k.Kind())) } a[kstr] = r.MapIndex(k).Interface() } @@ -910,7 +917,11 @@ func conversionCode(from, to string, value *flagValuePlan, pointer bool, variabl } return conversionData{code: fmt.Sprintf("%s = %s", to, converted), value: to} default: - parse = fmt.Sprintf("%s = json.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + if value.protobufMessage { + parse = fmt.Sprintf("%s = protojson.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } else { + parse = fmt.Sprintf("%s = json.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } return conversionData{code: parse, value: to, declaresError: true, canError: true} } converted := fmt.Sprintf("%s(%s)", value.typeRef, variables.parsed) diff --git a/codegen/cli/json_example_test.go b/codegen/cli/json_example_test.go new file mode 100644 index 0000000000..c551ff6d17 --- /dev/null +++ b/codegen/cli/json_example_test.go @@ -0,0 +1,41 @@ +// This file checks that CLI examples turn every supported Goa map key into a +// distinct JSON object key. +package cli + +import ( + "fmt" + "strings" + "testing" + + "goa.design/goa/v3/codegen/testutil" +) + +// TestJSONExampleFormatsPrimitiveMapKeys catches map entries being merged when +// generated help turns primitive and primitive-alias keys into JSON text. +func TestJSONExampleFormatsPrimitiveMapKeys(t *testing.T) { + type ( + exampleBool bool + exampleInt int32 + exampleUint uint64 + exampleFloat float64 + exampleString string + ) + tests := []struct { + name string + value any + }{ + {"boolean alias", map[exampleBool]string{false: "disabled", true: "enabled"}}, + {"signed alias", map[exampleInt]string{-2: "negative", 10: "positive"}}, + {"unsigned", map[uint32]string{7: "seven", 42: "forty-two"}}, + {"unsigned alias", map[exampleUint]string{9: "nine", 11: "eleven"}}, + {"floating-point alias", map[exampleFloat]string{1.25: "one", 2.5: "two"}}, + {"string", map[string]string{"first": "one", "second": "two"}}, + {"string alias", map[exampleString]string{"left": "one", "right": "two"}}, + } + + var actual strings.Builder + for _, test := range tests { + fmt.Fprintf(&actual, "%s:\n%s\n", test.name, jsonExample(test.value)) + } + testutil.AssertString(t, "testdata/golden/json_example_primitive_map_keys.golden", actual.String()) +} diff --git a/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden new file mode 100644 index 0000000000..4c0f285ee7 --- /dev/null +++ b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden @@ -0,0 +1,35 @@ +boolean alias: +'{ + "false": "disabled", + "true": "enabled" + }' +signed alias: +'{ + "-2": "negative", + "10": "positive" + }' +unsigned: +'{ + "42": "forty-two", + "7": "seven" + }' +unsigned alias: +'{ + "11": "eleven", + "9": "nine" + }' +floating-point alias: +'{ + "1.25": "one", + "2.5": "two" + }' +string: +'{ + "first": "one", + "second": "two" + }' +string alias: +'{ + "left": "one", + "right": "two" + }' diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 64457668f1..f998083ed6 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -194,6 +194,7 @@ func payloadBuilders(servicePlan *grpcServicePlan, data *cli.CommandData, servic codegen.GoaImport(""), services.ServiceImport(outputPackage, svc.Name()), services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + {Path: "google.golang.org/protobuf/encoding/protojson"}, } // Add structpb import if Any type is used if servicePlan.usesAny { diff --git a/grpc/codegen/client_cli_test.go b/grpc/codegen/client_cli_test.go index 2aba391791..7568ebc7b6 100644 --- a/grpc/codegen/client_cli_test.go +++ b/grpc/codegen/client_cli_test.go @@ -18,6 +18,7 @@ func TestClientCLIFiles(t *testing.T) { DSL func() }{ {"payload-with-validations", testdata.PayloadWithValidationsDSL}, + {"payload-with-message", testdata.PayloadWithMessageDSL}, } for _, c := range cases { diff --git a/grpc/codegen/import_plan.go b/grpc/codegen/import_plan.go index c33d335b2f..00d877bbc0 100644 --- a/grpc/codegen/import_plan.go +++ b/grpc/codegen/import_plan.go @@ -37,6 +37,7 @@ func planGRPCImports(generation *codegen.Generation, plan *Plan) error { clientFixed = append(clientFixed, codegen.SimpleImport("encoding/json"), codegen.SimpleImport("fmt"), + codegen.SimpleImport("google.golang.org/protobuf/encoding/protojson"), ) } if servicePlan.usesAny { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index aabd8073c1..90d6033bd1 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -5,6 +5,7 @@ package codegen import ( "fmt" "path" + "reflect" "strings" "goa.design/goa/v3/codegen" @@ -943,8 +944,8 @@ func (d *ServicesData) analyze(servicePlan *grpcServicePlan) *ServiceData { Ref: "message", TypeName: typeName, TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), - CLIPlan: cli.NewFlagPlan(requestMessage, typeName, typeName, nil), - Example: d.Example(requestMessage, payloadIdentity), + CLIPlan: cli.NewProtobufFlagPlan(requestMessage, typeName), + Example: protobufCLIExample(requestMessage, d.Example(requestMessage, payloadIdentity), sd.protobuf.plan), }) } // pass the metadata as arguments to client CLI args @@ -1279,6 +1280,108 @@ func userTypeAttribute(ut expr.UserType) *expr.AttributeExpr { return att } +// protobufCLIExample writes object field names exactly as they appear in the +// protobuf file. For example, a Goa field named "tenantID" is shown as +// "tenant_id" in command help. +func protobufCLIExample(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan) any { + return protobufCLIExampleValue(attribute, value, plan, false) +} + +// protobufCLIExampleValue avoids adding the same protobuf object twice while +// visiting its field. Values inside arrays and maps are converted separately. +func protobufCLIExampleValue(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan, skipWrapper bool) any { + if value == nil { + return nil + } + if !skipWrapper && isWrappedAttr(attribute) { + field := unwrapAttr(attribute) + fieldValue := value + if !field.Type.IsCompatible(value) { + var ok bool + fieldValue, ok = namedExampleValue(value, wrappedField) + if !ok { + panic("protobuf CLI wrapper example has no field value") + } + } + return map[string]any{ + plan.sourceFieldName(field): protobufCLIExampleValue(field, fieldValue, plan, true), + } + } + if object := expr.AsObject(attribute.Type); object != nil { + result := make(map[string]any, len(*object)) + for _, field := range *object { + fieldValue, ok := namedExampleValue(value, field.Name) + if !ok { + continue + } + if expr.AsUnion(field.Attribute.Type) != nil { + for name, branchValue := range protobufCLIExampleValue(field.Attribute, fieldValue, plan, false).(map[string]any) { + result[name] = branchValue + } + continue + } + result[plan.sourceFieldName(field.Attribute)] = protobufCLIExampleValue(field.Attribute, fieldValue, plan, false) + } + return result + } + if union := expr.AsUnion(attribute.Type); union != nil { + branchName, ok := namedExampleValue(value, union.GetTypeKey()) + if !ok { + panic("protobuf CLI union example has no branch name") + } + branchValue, ok := namedExampleValue(value, union.GetValueKey()) + if !ok { + panic("protobuf CLI union example has no branch value") + } + for _, branch := range union.Values { + if branch.Name != branchName { + continue + } + return map[string]any{ + plan.sourceFieldName(branch.Attribute): protobufCLIExampleValue(branch.Attribute, branchValue, plan, false), + } + } + panic(fmt.Sprintf("protobuf CLI union example selects unknown branch %q", branchName)) + } + if array := expr.AsArray(attribute.Type); array != nil { + items := reflect.ValueOf(value) + if items.Kind() != reflect.Array && items.Kind() != reflect.Slice { + panic(fmt.Sprintf("protobuf CLI array example has type %T", value)) + } + result := make([]any, items.Len()) + for index := range items.Len() { + result[index] = protobufCLIExampleValue(array.ElemType, items.Index(index).Interface(), plan, false) + } + return result + } + if mapped := expr.AsMap(attribute.Type); mapped != nil { + entries := reflect.ValueOf(value) + if entries.Kind() != reflect.Map { + panic(fmt.Sprintf("protobuf CLI map example has type %T", value)) + } + result := make(map[string]any, entries.Len()) + for _, key := range entries.MapKeys() { + result[fmt.Sprint(key.Interface())] = protobufCLIExampleValue(mapped.ElemType, entries.MapIndex(key).Interface(), plan, false) + } + return result + } + return value +} + +// namedExampleValue returns the value stored under one Goa object or union +// field name. +func namedExampleValue(example any, name string) (any, bool) { + fields := reflect.ValueOf(example) + if fields.Kind() != reflect.Map || fields.Type().Key().Kind() != reflect.String { + panic(fmt.Sprintf("protobuf CLI object example has type %T", example)) + } + value := fields.MapIndex(reflect.ValueOf(name).Convert(fields.Type().Key())) + if !value.IsValid() { + return nil, false + } + return value.Interface(), true +} + // buildRequestConvertData builds the convert data for the server and client // requests. // - server side - converts the one-shot gRPC request message (if any) and diff --git a/grpc/codegen/testdata/dsls.go b/grpc/codegen/testdata/dsls.go index 1f98181460..46c3b9e659 100644 --- a/grpc/codegen/testdata/dsls.go +++ b/grpc/codegen/testdata/dsls.go @@ -1062,6 +1062,23 @@ var PayloadWithValidationsDSL = func() { }) } +var PayloadWithMessageDSL = func() { + Service("PayloadWithMessage", func() { + Method("show", func() { + Payload(func() { + Field(1, "tenantID", String, func() { + Example("tenant") + }) + Field(2, "recordID", String, func() { + Example("record") + }) + Required("tenantID", "recordID") + }) + GRPC(func() {}) + }) + }) +} + var StructMetaTypeDSL = func() { Service("UsingMetaTypes", func() { Method("Method", func() { diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 127b44f16b..be365f6906 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -27,7 +27,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "service-with-interceptors method-a --message \"hello\"" + "\n" + + return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'" + "\n" + "" } @@ -161,7 +161,7 @@ func serviceWithInterceptorsMethodAUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message \"hello\"") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'") } func serviceWithInterceptorsMethodBUsage() { @@ -179,5 +179,5 @@ func serviceWithInterceptorsMethodBUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message 42") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 42\n }'") } diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden new file mode 100644 index 0000000000..03d0793c12 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden @@ -0,0 +1,35 @@ +// PayloadWithMessage gRPC client CLI support package +// +// Command: +// goa + +package client + +import ( + "fmt" + + payload_with_messagepb "generated.local/gen/grpc/payload_with_message/pb" + payloadwithmessage "generated.local/gen/payload_with_message" + "google.golang.org/protobuf/encoding/protojson" +) + +// BuildShowPayload builds the payload for the PayloadWithMessage show endpoint +// from CLI flags. +func BuildShowPayload(payloadWithMessageShowMessage string) (*payloadwithmessage.ShowPayload, error) { + var err error + var message payload_with_messagepb.ShowRequest + { + if payloadWithMessageShowMessage != "" { + err = protojson.Unmarshal([]byte(payloadWithMessageShowMessage), &message) + if err != nil { + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"record_id\": \"record\",\n \"tenant_id\": \"tenant\"\n }'") + } + } + } + v := &payloadwithmessage.ShowPayload{ + TenantID: message.TenantId, + RecordID: message.RecordId, + } + + return v, nil +} From 0a23906b06ba497a86711b87b7e4c7f4b09d8c18 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 24 Aug 2026 00:25:04 -0700 Subject: [PATCH 40/43] fix(openapi): keep JSON and YAML values equivalent --- http/codegen/openapi/json_schema.go | 6 +++ http/codegen/openapi/v2/builder.go | 2 +- .../TestSections/security_file0.golden | 8 ++-- http/codegen/openapi/v3/files_test.go | 2 + .../golden/bytes-example_file0.golden | 44 ++++++++++++++++++ .../golden/bytes-example_file1.golden | 26 +++++++++++ .../golden/v3.2/bytes-example_file0.golden | 45 +++++++++++++++++++ .../golden/v3.2/bytes-example_file1.golden | 27 +++++++++++ http/codegen/testdata/openapi_dsls.go | 22 +++++++++ 9 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden create mode 100644 http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go index 599e0414b7..9f3e627fd0 100644 --- a/http/codegen/openapi/json_schema.go +++ b/http/codegen/openapi/json_schema.go @@ -3,6 +3,7 @@ package openapi import ( + "encoding/base64" "encoding/json" "reflect" "strconv" @@ -299,6 +300,11 @@ func AdditionalPropertiesFromExpr(meta expr.MetaExpr) any { func projectExample(t expr.DataType, val any) any { switch actual := t.(type) { + case expr.Primitive: + if actual.Kind() == expr.BytesKind { + return base64.StdEncoding.EncodeToString(reflect.ValueOf(val).Bytes()) + } + return ToStringMap(val) case *expr.UserTypeExpr: return ProjectExample(actual.Attribute(), val) case *expr.ResultTypeExpr: diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index 1c17cbf9dc..e4aeaee384 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -658,7 +658,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr for i, req := range endpoint.Requirements { requirement := make(map[string][]string) for _, s := range req.Schemes { - requirement[s.Hash()] = nil + requirement[s.Hash()] = make([]string, 0) switch s.Kind { case expr.OAuth2Kind: if len(req.Scopes) > 0 { diff --git a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden index dae601cbf0..caf6b7a4b4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden @@ -24,9 +24,9 @@ ], "security": [ { - "api_key_query_k": null, - "basic_header_Authorization": null, - "jwt_header_X-Authorization": null, + "api_key_query_k": [], + "basic_header_Authorization": [], + "jwt_header_X-Authorization": [], "oauth2_header_Token": [ "api:read" ] @@ -49,7 +49,7 @@ ], "security": [ { - "api_key_header_Authorization": null + "api_key_header_Authorization": [] }, { "oauth2_query_auth": [ diff --git a/http/codegen/openapi/v3/files_test.go b/http/codegen/openapi/v3/files_test.go index c2e48e1834..a8f4dfc6f2 100644 --- a/http/codegen/openapi/v3/files_test.go +++ b/http/codegen/openapi/v3/files_test.go @@ -34,6 +34,7 @@ func TestFiles(t *testing.T) { {"file-service-swagger", testdata.FileServiceSwaggerDSL}, {"file-service-wildcard", testdata.FileServiceWildcardDSL}, {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, @@ -125,6 +126,7 @@ func TestFilesV32(t *testing.T) { DSL func() }{ {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"v3.2-meta", testdata.OpenAPIV32MetaDSL}, {"with-tags", testdata.WithTagsDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden new file mode 100644 index 0000000000..dd8979f0f7 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden @@ -0,0 +1,44 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden new file mode 100644 index 0000000000..56b5313f7c --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden new file mode 100644 index 0000000000..c4ace5c082 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden @@ -0,0 +1,45 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "name": "bytes", + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden new file mode 100644 index 0000000000..b0019fd577 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden @@ -0,0 +1,27 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design + name: bytes +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go index d53ff2513a..85a8714ade 100644 --- a/http/codegen/testdata/openapi_dsls.go +++ b/http/codegen/testdata/openapi_dsls.go @@ -31,6 +31,28 @@ var SimpleDSL = func() { }) } +// BytesExampleDSL defines a response whose OpenAPI example must remain a +// string in both JSON and YAML documents. +var BytesExampleDSL = func() { + var _ = API("bytes", func() { + Server("bytes", func() { + Host("localhost", func() { + URI("https://goa.design") + }) + }) + }) + Service("bytes", func() { + Method("download", func() { + Result(Bytes, func() { + Example([]byte("hello")) + }) + HTTP(func() { + GET("/download") + }) + }) + }) +} + var MultipleServicesDSL = func() { var PayloadT = Type("Payload", func() { Attribute("string", String, func() { From b12e1e4d68edfe1e5605fad600290904febdf461 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 24 Aug 2026 00:25:11 -0700 Subject: [PATCH 41/43] fix(http): decode whole-query maps as generated clients send them --- http/codegen/service_data.go | 1 - ...decode-map-query-primitive-array.go.golden | 38 +++++++------------ ...de-map-query-primitive-primitive.go.golden | 22 +++-------- 3 files changed, 20 insertions(+), 41 deletions(-) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index d9eef00e31..06f81f8ef1 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -2147,7 +2147,6 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD validate := codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name) mapQueryParam = &ParamData{ MapQueryParams: e.MapQueryParams, - Map: expr.AsMap(payload.Type) != nil, Element: &Element{ HTTPName: name, AttributeData: &AttributeData{ diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden index b98e11f887..0036765930 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden @@ -12,34 +12,24 @@ func DecodeMapQueryPrimitiveArrayRequest(mux goahttp.Muxer, decoder func(*http.R if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string][]uint) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string][]uint) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - var val []uint - { - val = make([]uint, len(valRaw)) - for i, rv := range valRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) - } - val[i] = uint(v) + var key string + key = keyRaw + var val []uint + { + val = make([]uint, len(valRaw)) + for i, rv := range valRaw { + v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) } + val[i] = uint(v) } - query[keya] = val } + query[key] = val } } if err != nil { diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden index 52b194560c..49e38038bb 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden @@ -12,23 +12,13 @@ func DecodeMapQueryPrimitivePrimitiveRequest(mux goahttp.Muxer, decoder func(*ht if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string]string) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string]string) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - query[keya] = valRaw[0] - } + var key string + key = keyRaw + query[key] = valRaw[0] } } if err != nil { From 970710edeb68e14bdc55f9f33b0efd6f817c5372 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 24 Aug 2026 00:25:22 -0700 Subject: [PATCH 42/43] fix(http): close empty WebSocket result streams cleanly --- .../partial/websocket_upgrade.go.tpl | 4 +- http/codegen/templates/websocket_close.go.tpl | 29 ++- .../templates/websocket_struct_type.go.tpl | 6 + .../golden/planned_name_collisions.go.golden | 5 + ...ket-bidirectional-streaming-complex.golden | 33 +++- ...t-bidirectional-streaming-primitive.golden | 33 +++- ...-bidirectional-streaming-with-views.golden | 33 +++- .../websocket-conn-configurer.golden | 33 +++- .../websocket-mixed-endpoints.golden | 33 +++- .../websocket-no-payload-streaming.golden | 33 +++- .../websocket-no-result-streaming.golden | 33 +++- .../websocket-server-streaming-array.golden | 33 +++- .../websocket-server-streaming-object.golden | 33 +++- ...ebsocket-server-streaming-primitive.golden | 33 +++- ...ebsocket-server-streaming-user-type.golden | 33 +++- ...bsocket-server-streaming-with-views.golden | 45 ++++- .../websocket/websocket-struct-types.golden | 33 +++- http/codegen/testdata/streaming_code.go | 181 ++++++++++++++++-- http/codegen/websocket.go | 2 +- 19 files changed, 623 insertions(+), 45 deletions(-) diff --git a/http/codegen/templates/partial/websocket_upgrade.go.tpl b/http/codegen/templates/partial/websocket_upgrade.go.tpl index 9978bd9506..18944f7f0c 100644 --- a/http/codegen/templates/partial/websocket_upgrade.go.tpl +++ b/http/codegen/templates/partial/websocket_upgrade.go.tpl @@ -1,13 +1,13 @@ {{ printf "Upgrade the HTTP connection to a websocket connection only once. Connection upgrade is done here so that authorization logic in the endpoint is executed before calling the actual service method which may call %s()." .Function | comment }} s.once.Do(func() { - {{- if and .ViewedResult (eq .Function "Send") }} + {{- if and .ViewedResult (or (eq .Function "Send") (eq .Function "Close")) }} {{- if not .ViewedResult.ViewName }} respHdr := make(http.Header) respHdr.Add("goa-view", view) {{- end }} {{- end }} var conn *websocket.Conn - {{- if eq .Function "Send" }} + {{- if or (eq .Function "Send") (eq .Function "Close") }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) diff --git a/http/codegen/templates/websocket_close.go.tpl b/http/codegen/templates/websocket_close.go.tpl index 87e986130e..fa8c1d4f8b 100644 --- a/http/codegen/templates/websocket_close.go.tpl +++ b/http/codegen/templates/websocket_close.go.tpl @@ -1,10 +1,29 @@ {{ printf "Close closes the %q endpoint websocket connection." .Endpoint.Method.Name | comment }} func (s *{{ .VarDeclaration.Name }}) Close() error { - var err error {{- if eq .Type "server" }} - if s.conn == nil { - return nil + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +{{ comment "close opens the websocket connection when needed, sends its normal close message, and closes it." }} +func (s *{{ .VarDeclaration.Name }}) close() error { + var err error + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } + {{- end }} + {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint "Close") }} if err = s.conn.WriteControl( websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), @@ -12,11 +31,13 @@ func (s *{{ .VarDeclaration.Name }}) Close() error { ); err != nil { return err } + return s.conn.Close() {{- else }} {{/* client side code */}} + var err error {{ comment "Send a nil payload to the server implying client closing connection." }} if err = s.conn.WriteJSON(nil); err != nil { return err } -{{- end }} return s.conn.Close() +{{- end }} } diff --git a/http/codegen/templates/websocket_struct_type.go.tpl b/http/codegen/templates/websocket_struct_type.go.tpl index 8148a8ab2b..2512b925cf 100644 --- a/http/codegen/templates/websocket_struct_type.go.tpl +++ b/http/codegen/templates/websocket_struct_type.go.tpl @@ -4,6 +4,12 @@ type {{ .VarDeclaration.Name }} struct { once sync.Once {{ comment "upgradeErr is the error returned by the websocket upgrade attempt." }} upgradeErr error + {{- if .MustClose }} + {{ comment "closeOnce makes repeated Close calls return the first close result without writing again." }} + closeOnce sync.Once + {{ comment "closeErr is the result of the first Close call." }} + closeErr error + {{- end }} {{ comment "upgrader is the websocket connection upgrader." }} upgrader goahttp.Upgrader {{ comment "configurer is the websocket connection configurer." }} diff --git a/http/codegen/testdata/golden/planned_name_collisions.go.golden b/http/codegen/testdata/golden/planned_name_collisions.go.golden index c5f25c75e1..e463e477fb 100644 --- a/http/codegen/testdata/golden/planned_name_collisions.go.golden +++ b/http/codegen/testdata/golden/planned_name_collisions.go.golden @@ -114,6 +114,11 @@ type SocketServerStream2 struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden index 040440eae3..379e652a51 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden @@ -31,6 +31,11 @@ type BidirectionalComplexServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -135,9 +140,33 @@ func (s *BidirectionalComplexServerStream) RecvWithContext(ctx context.Context) // Close closes the "BidirectionalComplex" endpoint websocket connection. func (s *BidirectionalComplexServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalComplexServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden index c8b483536b..6ba33149e9 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden @@ -31,6 +31,11 @@ type BidirectionalPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -129,9 +134,33 @@ func (s *BidirectionalPrimitiveServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalPrimitive" endpoint websocket connection. func (s *BidirectionalPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden index 2a820a81da..41a86bab81 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden @@ -31,6 +31,11 @@ type BidirectionalWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -135,9 +140,33 @@ func (s *BidirectionalWithViewsServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalWithViews" endpoint websocket connection. func (s *BidirectionalWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden index 068c0dafdf..b966c516ff 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden @@ -31,6 +31,11 @@ type ConfigurableStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *ConfigurableStreamServerStream) SendWithContext(ctx context.Context, v // Close closes the "ConfigurableStream" endpoint websocket connection. func (s *ConfigurableStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *ConfigurableStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden index f3ca5995b8..77067640f8 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden @@ -31,6 +31,11 @@ type StreamingEndpointServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamingEndpointServerStream) SendWithContext(ctx context.Context, v s // Close closes the "StreamingEndpoint" endpoint websocket connection. func (s *StreamingEndpointServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingEndpointServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden index f24784f0ca..22fe380582 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden @@ -31,6 +31,11 @@ type NoPayloadStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *NoPayloadStreamServerStream) SendWithContext(ctx context.Context, v str // Close closes the "NoPayloadStream" endpoint websocket connection. func (s *NoPayloadStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoPayloadStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden index fb0db2ca6a..083b5f4f88 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden @@ -31,6 +31,11 @@ type NoResultStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -97,9 +102,33 @@ func (s *NoResultStreamServerStream) RecvWithContext(ctx context.Context) (strin // Close closes the "NoResultStream" endpoint websocket connection. func (s *NoResultStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoResultStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden index 3f73eaf1c0..1f247e64d2 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden @@ -31,6 +31,11 @@ type StreamArrayServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamArrayServerStream) SendWithContext(ctx context.Context, v []strin // Close closes the "StreamArray" endpoint websocket connection. func (s *StreamArrayServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamArrayServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden index 6f9ba4e01a..d53ff69fab 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden @@ -31,6 +31,11 @@ type StreamObjectServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamObjectServerStream) SendWithContext(ctx context.Context, v *tests // Close closes the "StreamObject" endpoint websocket connection. func (s *StreamObjectServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamObjectServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden index 2ff0286cb4..06f2727173 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden @@ -31,6 +31,11 @@ type StreamPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamPrimitiveServerStream) SendWithContext(ctx context.Context, v str // Close closes the "StreamPrimitive" endpoint websocket connection. func (s *StreamPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden index 94c421bd5b..d03ea3a7f6 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden @@ -31,6 +31,11 @@ type StreamUserServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamUserServerStream) SendWithContext(ctx context.Context, v *testser // Close closes the "StreamUser" endpoint websocket connection. func (s *StreamUserServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden index 9530157aa0..e34e31fc65 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden @@ -31,6 +31,11 @@ type StreamUserWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -120,9 +125,45 @@ func (s *StreamUserWithViewsServerStream) SendWithContext(ctx context.Context, v // Close closes the "StreamUserWithViews" endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden index d0388454c2..11b40e43c0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden @@ -31,6 +31,11 @@ type StructStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -131,9 +136,33 @@ func (s *StructStreamServerStream) RecvWithContext(ctx context.Context) (*testse // Close closes the "StructStream" endpoint websocket connection. func (s *StructStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StructStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index 43522d84d9..1b508f7efb 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -223,9 +223,33 @@ func (s *StreamingResultMethodServerStream) SendWithContext(ctx context.Context, var StreamingResultServerStreamCloseCode = `// Close closes the "StreamingResultMethod" endpoint websocket connection. func (s *StreamingResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -407,9 +431,46 @@ func (c *Client) StreamingResultMethod() goa.Endpoint { var StreamingResultWithViewsServerStreamCloseCode = `// Close closes the "StreamingResultWithViewsMethod" endpoint websocket // connection. func (s *StreamingResultWithViewsMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultWithViewsMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -1600,9 +1661,33 @@ func (s *StreamingPayloadNoResultMethodServerStream) RecvWithContext(ctx context var StreamingPayloadNoResultServerStreamCloseCode = `// Close closes the "StreamingPayloadNoResultMethod" endpoint websocket // connection. func (s *StreamingPayloadNoResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingPayloadNoResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -2837,9 +2922,33 @@ func (s *BidirectionalStreamingMethodServerStream) RecvWithContext(ctx context.C var BidirectionalStreamingServerStreamCloseCode = `// Close closes the "BidirectionalStreamingMethod" endpoint websocket // connection. func (s *BidirectionalStreamingMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -2999,9 +3108,33 @@ func NewBidirectionalStreamingNoPayloadMethodHandler( var BidirectionalStreamingNoPayloadServerStreamCloseCode = `// Close closes the "BidirectionalStreamingNoPayloadMethod" endpoint websocket // connection. func (s *BidirectionalStreamingNoPayloadMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingNoPayloadMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -3182,9 +3315,33 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) RecvWithContex var BidirectionalStreamingResultWithViewsServerStreamCloseCode = `// Close closes the "BidirectionalStreamingResultWithViewsMethod" endpoint // websocket connection. func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingResultWithViewsMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index ad7908ec97..2858cb2c76 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -383,7 +383,7 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { if e.ServerWebSocket.MustClose { sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-close", - Source: httpTemplates.Read(websocketCloseT), + Source: httpTemplates.Read(websocketCloseT, websocketUpgradeP), Data: e.ServerWebSocket, FuncMap: map[string]any{ "upgradeParams": upgradeParams, From 91277e903d4dab15cee860c9d325a2853429b948 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Mon, 24 Aug 2026 00:39:44 -0700 Subject: [PATCH 43/43] docs(codegen): describe released generator compatibility --- codegen/ARCHITECTURE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md index 126512c648..8d908c4df4 100644 --- a/codegen/ARCHITECTURE.md +++ b/codegen/ARCHITECTURE.md @@ -105,9 +105,11 @@ starts. A factory may close over immutable configuration. Per-run roots, plans, files, caches, and errors belong to the returned object. Concurrent and repeated -generation runs must not observe one another. The registry itself is immutable -while runs execute; tests install isolated registries rather than replacing a -public global `Generators` function. +generation runs must not observe one another. The factory registry is immutable +while runs execute, and tests install isolated registries. The released +`Generators` variable remains replaceable for compatibility. Callers configure +it before starting concurrent runs; each run reads its function list once and +turns that list into fresh internal generators. ## The retained core plan